use std::fs;
use std::process::Output;
const TEST_KEY: &str = "4242424242424242424242424242424242424242424242424242424242424242";
const WRONG_KEY: &str = "2424242424242424242424242424242424242424242424242424242424242424";
fn parse_json(output: &Output) -> serde_json::Value {
serde_json::from_slice(&output.stdout).expect("stdout must contain one JSON document")
}
fn assert_success(output: &Output, operation: &str) {
assert!(
output.status.success(),
"{operation} failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
#[ignore = "requires a live local PostgreSQL database via DATABASE_URL"]
fn live_encrypted_cache_round_trip_and_rejection_contract() {
let database_url =
std::env::var("DATABASE_URL").expect("DATABASE_URL is required for encryption proof");
let temp_dir = tempfile::tempdir().expect("create live encryption temp directory");
let config_path = temp_dir.path().join("encrypted.toml");
let plain_config_path = temp_dir.path().join("plain.toml");
let cache_path = temp_dir.path().join("encrypted.cache");
let plaintext_cache_path = temp_dir.path().join("plaintext.cache");
fs::write(&config_path, "cache_encryption = true\n").expect("write encryption config");
fs::write(&plain_config_path, "").expect("write plain config");
let mut sync = assert_cmd::Command::cargo_bin("safe-migrate").expect("safe-migrate binary");
let sync_output = sync
.arg("sync")
.arg("--out")
.arg(&cache_path)
.arg("--config")
.arg(&config_path)
.arg("--schemas")
.arg("public")
.env("DATABASE_URL", &database_url)
.env("SAFE_MIGRATE_CACHE_KEY", TEST_KEY)
.output()
.expect("run encrypted sync");
assert_success(&sync_output, "encrypted sync");
assert!(
String::from_utf8_lossy(&sync_output.stdout)
.contains("Syncing PostgreSQL schema metadata and statistics")
);
assert!(!String::from_utf8_lossy(&sync_output.stdout).contains(TEST_KEY));
assert!(!String::from_utf8_lossy(&sync_output.stderr).contains(TEST_KEY));
let mut plain_sync =
assert_cmd::Command::cargo_bin("safe-migrate").expect("safe-migrate binary");
let plain_sync_output = plain_sync
.arg("sync")
.arg("--out")
.arg(&plaintext_cache_path)
.arg("--config")
.arg(&plain_config_path)
.arg("--schemas")
.arg("public")
.env("DATABASE_URL", &database_url)
.output()
.expect("run plaintext sync");
assert_success(&plain_sync_output, "plaintext sync");
let cache_bytes = fs::read(&cache_path).expect("read encrypted cache");
assert!(cache_bytes.starts_with(b"SMENC001"));
assert!(
!cache_bytes
.windows(TEST_KEY.len())
.any(|bytes| bytes == TEST_KEY.as_bytes())
);
let mut inspect = assert_cmd::Command::cargo_bin("safe-migrate").expect("safe-migrate binary");
let inspect_output = inspect
.arg("cache")
.arg("inspect")
.arg("--cache")
.arg(&cache_path)
.arg("--config")
.arg(&config_path)
.arg("--json")
.env("SAFE_MIGRATE_CACHE_KEY", TEST_KEY)
.output()
.expect("inspect encrypted cache");
assert_success(&inspect_output, "encrypted cache inspect");
let inspection = parse_json(&inspect_output);
assert_eq!(inspection["encrypted"], true);
assert_eq!(inspection["format_version"], 7);
assert!(inspection["contents"]["roles"].is_number());
let migration_path = temp_dir.path().join("migration.sql");
fs::write(&migration_path, "SET search_path TO public;\n").expect("write lint migration");
let mut lint = assert_cmd::Command::cargo_bin("safe-migrate").expect("safe-migrate binary");
let lint_output = lint
.arg("lint")
.arg("--file")
.arg(&migration_path)
.arg("--cache")
.arg(&cache_path)
.arg("--config")
.arg(&config_path)
.arg("--json")
.env("SAFE_MIGRATE_CACHE_KEY", TEST_KEY)
.output()
.expect("lint with encrypted cache");
assert_success(&lint_output, "lint with encrypted cache");
let lint_report = parse_json(&lint_output);
assert_eq!(lint_report["baseline"]["status"], "available");
assert_eq!(lint_report["confidence"], "Exact");
let auto_config_path = temp_dir.path().join("encrypted-auto-sync.toml");
let auto_cache_path = temp_dir.path().join("encrypted-auto-sync.cache");
fs::write(
&auto_config_path,
"auto_sync = true\ncache_encryption = true\nschemas = [\"public\"]\n",
)
.expect("write encrypted auto-sync config");
let mut encrypted_auto_sync =
assert_cmd::Command::cargo_bin("safe-migrate").expect("safe-migrate binary");
let auto_sync_output = encrypted_auto_sync
.arg("lint")
.arg("--file")
.arg(&migration_path)
.arg("--cache")
.arg(&auto_cache_path)
.arg("--config")
.arg(&auto_config_path)
.arg("--json")
.env("DATABASE_URL", &database_url)
.env("SAFE_MIGRATE_CACHE_KEY", TEST_KEY)
.output()
.expect("run encrypted automatic sync");
assert_success(&auto_sync_output, "encrypted automatic sync");
let auto_sync_report = parse_json(&auto_sync_output);
assert_eq!(auto_sync_report["baseline"]["auto_sync"], "refreshed");
assert_eq!(auto_sync_report["baseline"]["status"], "available");
assert!(
fs::read(&auto_cache_path)
.expect("read encrypted auto-sync cache")
.starts_with(b"SMENC001")
);
let migrations_dir = temp_dir.path().join("migrations");
fs::create_dir(&migrations_dir).expect("create lint-chain directory");
fs::write(
migrations_dir.join("001_first.sql"),
"SET search_path TO public;\n",
)
.expect("write first chain migration");
fs::write(
migrations_dir.join("002_second.sql"),
"SET search_path TO public;\n",
)
.expect("write second chain migration");
let mut lint_chain =
assert_cmd::Command::cargo_bin("safe-migrate").expect("safe-migrate binary");
let chain_output = lint_chain
.arg("lint-chain")
.arg("--dir")
.arg(&migrations_dir)
.arg("--cache")
.arg(&cache_path)
.arg("--config")
.arg(&config_path)
.arg("--json")
.env("SAFE_MIGRATE_CACHE_KEY", TEST_KEY)
.output()
.expect("lint-chain with encrypted cache");
assert_success(&chain_output, "lint-chain with encrypted cache");
let chain_report = parse_json(&chain_output);
assert_eq!(chain_report["baseline"]["status"], "available");
assert_eq!(chain_report["confidence"], "Exact");
for (label, rejection_cache, config, key, expected) in [
(
"plaintext cache with encryption enabled",
&plaintext_cache_path,
&config_path,
Some(TEST_KEY),
"Cache file is not encrypted",
),
(
"disabled encryption",
&cache_path,
&plain_config_path,
Some(TEST_KEY),
"Cache file is encrypted",
),
(
"missing key",
&cache_path,
&config_path,
None,
"SAFE_MIGRATE_CACHE_KEY must contain",
),
(
"wrong key",
&cache_path,
&config_path,
Some(WRONG_KEY),
"key is incorrect or the file was modified",
),
] {
let mut rejected =
assert_cmd::Command::cargo_bin("safe-migrate").expect("safe-migrate binary");
rejected
.arg("cache")
.arg("inspect")
.arg("--cache")
.arg(rejection_cache)
.arg("--config")
.arg(config);
if let Some(key) = key {
rejected.env("SAFE_MIGRATE_CACHE_KEY", key);
} else {
rejected.env_remove("SAFE_MIGRATE_CACHE_KEY");
}
let output = rejected.output().expect("run encrypted-cache rejection");
assert!(!output.status.success(), "{label} unexpectedly succeeded");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains(expected),
"{label} produced unexpected stderr: {stderr}"
);
}
}