use assert_cmd::Command;
use mockito::Server;
use predicates::prelude::*;
use tempfile::tempdir;
fn tsafe() -> Command {
Command::cargo_bin("tsafe").unwrap()
}
fn init_vault(dir: &std::path::Path) {
tsafe()
.args(["--profile", "default", "init"])
.env("TSAFE_VAULT_DIR", dir)
.env("TSAFE_PASSWORD", "test-pw")
.assert()
.success();
}
fn set_secret(dir: &std::path::Path, key: &str, value: &str) {
tsafe()
.args(["--profile", "default", "set", key, value])
.env("TSAFE_VAULT_DIR", dir)
.env("TSAFE_PASSWORD", "test-pw")
.assert()
.success();
}
const TEST_PROJECT: &str = "test-project";
fn gcp_push_cmd(server_url: &str) -> Command {
let mut cmd = tsafe();
cmd.arg("--profile")
.arg("default")
.env("TSAFE_GCP_TEST_LOCAL_URL", server_url)
.env("TSAFE_GCP_TEST_TOKEN", "test-token")
.env("TSAFE_GCP_TEST_PROJECT", TEST_PROJECT)
.env_remove("GOOGLE_CLOUD_PROJECT")
.env_remove("GCLOUD_PROJECT")
.env_remove("GOOGLE_OAUTH_TOKEN")
.env_remove("GOOGLE_APPLICATION_CREDENTIALS");
cmd
}
fn mock_empty_remote(server: &mut Server) {
server
.mock(
"GET",
mockito::Matcher::Regex(format!(r"^/v1/projects/{TEST_PROJECT}/secrets\?").to_string()),
)
.match_header("Authorization", "Bearer test-token")
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(r#"{"secrets":[]}"#)
.create();
}
fn b64_encode(s: &str) -> String {
use std::io::Write as _;
let data = s.as_bytes();
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
let mut i = 0;
while i < data.len() {
let b0 = data[i] as u32;
let b1 = if i + 1 < data.len() {
data[i + 1] as u32
} else {
0
};
let b2 = if i + 2 < data.len() {
data[i + 2] as u32
} else {
0
};
out.push(CHARS[((b0 >> 2) & 0x3F) as usize] as char);
out.push(CHARS[(((b0 << 4) | (b1 >> 4)) & 0x3F) as usize] as char);
out.push(if i + 1 < data.len() {
CHARS[(((b1 << 2) | (b2 >> 6)) & 0x3F) as usize] as char
} else {
'='
});
out.push(if i + 2 < data.len() {
CHARS[(b2 & 0x3F) as usize] as char
} else {
'='
});
i += 3;
}
let _ = std::io::sink().write_all(&[]);
out
}
fn mock_remote_with_secret(server: &mut Server, name: &str, value: &str) {
server
.mock(
"GET",
mockito::Matcher::Regex(format!(r"^/v1/projects/{TEST_PROJECT}/secrets\?").to_string()),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(format!(
r#"{{"secrets":[{{"name":"projects/{TEST_PROJECT}/secrets/{name}"}}]}}"#
))
.create();
server
.mock(
"GET",
mockito::Matcher::Regex(
format!(r"^/v1/projects/{TEST_PROJECT}/secrets/{name}/versions/latest:access")
.to_string(),
),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(format!(
r#"{{"payload":{{"data":"{}"}}}}"#,
b64_encode(value)
))
.create();
}
#[cfg(feature = "cloud-pull-gcp")]
#[test]
fn gcp_push_dry_run_shows_diff_without_writing() {
let dir = tempdir().unwrap();
init_vault(dir.path());
set_secret(dir.path(), "MY_SECRET", "hello");
let mut server = Server::new();
mock_empty_remote(&mut server);
let no_create_mock = server
.mock("POST", mockito::Matcher::Any)
.expect(0)
.create();
gcp_push_cmd(&server.url())
.args(["gcp-push", "--dry-run"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "test-pw")
.assert()
.success()
.stdout(predicate::str::contains("create").or(predicate::str::contains("update")))
.stdout(predicate::str::contains("Dry-run complete"));
no_create_mock.assert();
}
#[cfg(feature = "cloud-pull-gcp")]
#[test]
fn gcp_push_aborts_without_yes_in_non_interactive() {
let dir = tempdir().unwrap();
init_vault(dir.path());
set_secret(dir.path(), "SOME_KEY", "value123");
let mut server = Server::new();
mock_empty_remote(&mut server);
let no_write_mock = server
.mock("POST", mockito::Matcher::Any)
.expect(0)
.create();
gcp_push_cmd(&server.url())
.args(["gcp-push"]) .env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "test-pw")
.assert()
.failure()
.stderr(predicate::str::contains("--yes"));
no_write_mock.assert();
}
#[cfg(feature = "cloud-pull-gcp")]
#[test]
fn gcp_push_new_secret_uses_two_calls() {
let dir = tempdir().unwrap();
init_vault(dir.path());
set_secret(dir.path(), "DB_PASSWORD", "s3cr3t");
let mut server = Server::new();
mock_empty_remote(&mut server);
let _existence_check = server
.mock(
"GET",
mockito::Matcher::Regex(
format!(r"^/v1/projects/{TEST_PROJECT}/secrets/db-password$").to_string(),
),
)
.with_status(404)
.with_body(r#"{"error":{"code":404,"message":"Secret not found","status":"NOT_FOUND"}}"#)
.expect(1)
.create();
let create_mock = server
.mock(
"POST",
mockito::Matcher::Regex(
format!(r"^/v1/projects/{TEST_PROJECT}/secrets\?secretId=db-password$").to_string(),
),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(format!(
r#"{{"name":"projects/{TEST_PROJECT}/secrets/db-password"}}"#
))
.expect(1)
.create();
let version_add_mock = server
.mock(
"POST",
mockito::Matcher::Regex(
format!(r"^/v1/projects/{TEST_PROJECT}/secrets/db-password/versions:add$")
.to_string(),
),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(format!(
r#"{{"name":"projects/{TEST_PROJECT}/secrets/db-password/versions/1"}}"#
))
.expect(1)
.create();
gcp_push_cmd(&server.url())
.args(["gcp-push", "--yes"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "test-pw")
.assert()
.success()
.stdout(predicate::str::contains("created"));
create_mock.assert();
version_add_mock.assert();
}
#[cfg(feature = "cloud-pull-gcp")]
#[test]
fn gcp_push_existing_secret_uses_one_call() {
let dir = tempdir().unwrap();
init_vault(dir.path());
set_secret(dir.path(), "API_KEY", "new-value");
let mut server = Server::new();
mock_remote_with_secret(&mut server, "api-key", "old-value");
let _existence_check = server
.mock(
"GET",
mockito::Matcher::Regex(
format!(r"^/v1/projects/{TEST_PROJECT}/secrets/api-key$").to_string(),
),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(format!(
r#"{{"name":"projects/{TEST_PROJECT}/secrets/api-key"}}"#
))
.expect(1)
.create();
let no_create_mock = server
.mock(
"POST",
mockito::Matcher::Regex(format!(r"^/v1/projects/{TEST_PROJECT}/secrets\?").to_string()),
)
.expect(0)
.create();
let version_add_mock = server
.mock(
"POST",
mockito::Matcher::Regex(
format!(r"^/v1/projects/{TEST_PROJECT}/secrets/api-key/versions:add$").to_string(),
),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(format!(
r#"{{"name":"projects/{TEST_PROJECT}/secrets/api-key/versions/2"}}"#
))
.expect(1)
.create();
gcp_push_cmd(&server.url())
.args(["gcp-push", "--yes"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "test-pw")
.assert()
.success()
.stdout(predicate::str::contains("updated"));
no_create_mock.assert();
version_add_mock.assert();
}
#[cfg(feature = "cloud-pull-gcp")]
#[test]
fn gcp_push_partial_failure_logged_when_version_add_fails() {
let dir = tempdir().unwrap();
init_vault(dir.path());
set_secret(dir.path(), "BROKEN_SECRET", "value");
let mut server = Server::new();
mock_empty_remote(&mut server);
let _existence_check = server
.mock(
"GET",
mockito::Matcher::Regex(
format!(r"^/v1/projects/{TEST_PROJECT}/secrets/broken-secret$").to_string(),
),
)
.with_status(404)
.with_body(r#"{"error":{"code":404,"status":"NOT_FOUND"}}"#)
.create();
let _create_mock = server
.mock(
"POST",
mockito::Matcher::Regex(
format!(r"^/v1/projects/{TEST_PROJECT}/secrets\?secretId=broken-secret$")
.to_string(),
),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(format!(
r#"{{"name":"projects/{TEST_PROJECT}/secrets/broken-secret"}}"#
))
.create();
let _version_fail_mock = server
.mock(
"POST",
mockito::Matcher::Regex(
format!(r"^/v1/projects/{TEST_PROJECT}/secrets/broken-secret/versions:add$")
.to_string(),
),
)
.with_status(500)
.with_body(r#"{"error":{"code":500,"message":"Internal error","status":"INTERNAL"}}"#)
.create();
gcp_push_cmd(&server.url())
.args(["gcp-push", "--yes"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "test-pw")
.assert()
.failure()
.stderr(
predicate::str::contains("failed to create 'broken-secret'")
.or(predicate::str::contains("500")),
);
}