438 lines
15 KiB
Rust
438 lines
15 KiB
Rust
use std::process::{Command, Output};
|
|
|
|
use crank_community_auth::{hash_password, verify_password};
|
|
use crank_core::{Secret, SecretId, SecretKind, SecretStatus, UserSessionId, WorkspaceId};
|
|
use crank_registry::{
|
|
CreateSecretRequest, MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate, PostgresRegistry,
|
|
};
|
|
use crank_runtime::SecretCrypto;
|
|
use serde_json::json;
|
|
use time::{Duration as TimeDuration, OffsetDateTime, format_description::well_known::Rfc3339};
|
|
|
|
fn command(arguments: &[&str], database_url: Option<&str>) -> Output {
|
|
let mut command = Command::new(env!("CARGO_BIN_EXE_crank-migrate"));
|
|
command.args(arguments);
|
|
command.current_dir(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."));
|
|
for (name, _) in std::env::vars() {
|
|
if name.starts_with("CRANK_") || name.starts_with("POSTGRES_") || name.starts_with("OTEL_")
|
|
{
|
|
command.env_remove(name);
|
|
}
|
|
}
|
|
if let Some(database_url) = database_url {
|
|
command.env("CRANK_DATABASE_URL", database_url);
|
|
}
|
|
command.output().expect("migration command must run")
|
|
}
|
|
|
|
fn timestamp(value: &str) -> OffsetDateTime {
|
|
OffsetDateTime::parse(value, &Rfc3339).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn plan_is_deterministic_and_committed_contract_is_current() {
|
|
let first = command(&["plan"], None);
|
|
let second = command(&["plan"], None);
|
|
assert!(
|
|
first.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&first.stderr)
|
|
);
|
|
assert_eq!(first.stdout, second.stdout);
|
|
let plan: serde_json::Value = serde_json::from_slice(&first.stdout).unwrap();
|
|
assert_eq!(plan["sequence"].as_array().unwrap().len(), 11);
|
|
|
|
let checked = command(&["plan", "--check"], None);
|
|
assert!(
|
|
checked.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&checked.stderr)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_command_is_bounded_and_does_not_echo_arguments() {
|
|
let canary = "secret-command-canary";
|
|
let output = command(&[canary], None);
|
|
assert!(!output.status.success());
|
|
let stderr = String::from_utf8(output.stderr).unwrap();
|
|
assert!(stderr.len() < 1_024);
|
|
assert!(!stderr.contains(canary));
|
|
let diagnostic: serde_json::Value = serde_json::from_str(stderr.trim()).unwrap();
|
|
assert_eq!(diagnostic["code"], "invalid_command");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn database_only_config_can_apply_and_preflight_a_fresh_schema() {
|
|
let database_url = crank_test_support::postgres_schema_url("test_migration_command").await;
|
|
let applied = command(&["apply"], Some(&database_url));
|
|
assert!(
|
|
applied.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&applied.stderr)
|
|
);
|
|
let result: serde_json::Value = serde_json::from_slice(&applied.stdout).unwrap();
|
|
assert_eq!(result["status"], "applied");
|
|
|
|
let preflight = command(&["preflight"], Some(&database_url));
|
|
assert!(
|
|
preflight.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&preflight.stderr)
|
|
);
|
|
let result: serde_json::Value = serde_json::from_slice(&preflight.stdout).unwrap();
|
|
assert_eq!(result["status"], "current");
|
|
assert_eq!(result["version"], 11);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn admin_auth_bootstrap_create_outputs_bootstrap_secret_without_service_secrets() {
|
|
let database_url =
|
|
crank_test_support::postgres_schema_url("test_migration_admin_auth_bootstrap").await;
|
|
assert!(command(&["apply"], Some(&database_url)).status.success());
|
|
|
|
let created = command(
|
|
&[
|
|
"admin-auth",
|
|
"bootstrap-create",
|
|
"--email",
|
|
"operator@example.local",
|
|
"--display-name",
|
|
"Local Operator",
|
|
"--ttl-seconds",
|
|
"900",
|
|
],
|
|
Some(&database_url),
|
|
);
|
|
assert!(
|
|
created.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&created.stderr)
|
|
);
|
|
let payload: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap();
|
|
assert_eq!(payload["status"], "bootstrap_created");
|
|
assert!(
|
|
payload["contract_id"]
|
|
.as_str()
|
|
.unwrap()
|
|
.starts_with("boot_")
|
|
);
|
|
assert!(payload["bootstrap_token"].as_str().unwrap().len() >= 32);
|
|
let stdout = String::from_utf8(created.stdout).unwrap();
|
|
assert!(!stdout.contains("CRANK_SESSION_SECRET"));
|
|
assert!(!stdout.contains("CRANK_PASSWORD_PEPPER"));
|
|
|
|
let duplicate = command(
|
|
&[
|
|
"admin-auth",
|
|
"bootstrap-create",
|
|
"--email",
|
|
"operator@example.local",
|
|
],
|
|
Some(&database_url),
|
|
);
|
|
assert!(!duplicate.status.success());
|
|
let diagnostic: serde_json::Value = serde_json::from_slice(&duplicate.stderr).unwrap();
|
|
assert_eq!(diagnostic["code"], "admin_bootstrap_unavailable");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn admin_auth_recovery_requires_master_key_and_revokes_sessions_without_secret_output() {
|
|
let database_url =
|
|
crank_test_support::postgres_schema_url("test_migration_admin_auth_recovery").await;
|
|
assert!(command(&["apply"], Some(&database_url)).status.success());
|
|
let registry = PostgresRegistry::connect(&database_url).await.unwrap();
|
|
let now = timestamp("2026-08-21T00:00:00Z");
|
|
let master_key = "recovery-master-key-CANARY_SECRET_VALUE-000000000";
|
|
let crypto = SecretCrypto::new(master_key).unwrap();
|
|
registry
|
|
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
|
|
epoch: crypto.master_key_epoch(),
|
|
fingerprint: crypto.master_key_fingerprint(),
|
|
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
|
observed_at: &now,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let pepper = "recovery-pepper-CANARY_SECRET_VALUE";
|
|
let old_password = "old-recovery-password-CANARY_SECRET_VALUE";
|
|
let old_hash = hash_password(old_password, pepper).unwrap();
|
|
let user_id = registry
|
|
.upsert_bootstrap_user("recover@example.local", "Recover Operator", &old_hash)
|
|
.await
|
|
.unwrap();
|
|
let session_id = UserSessionId::new("session_recovery_cli");
|
|
registry
|
|
.create_user_session(
|
|
&session_id,
|
|
&user_id,
|
|
Some(&WorkspaceId::new("ws_default")),
|
|
"session-secret-hash",
|
|
None,
|
|
&(OffsetDateTime::now_utc() + TimeDuration::hours(1)),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let key_dir =
|
|
std::env::temp_dir().join(format!("crank-admin-auth-recovery-{}", std::process::id()));
|
|
std::fs::create_dir_all(&key_dir).unwrap();
|
|
let password_path = key_dir.join("new-password.txt");
|
|
let pepper_path = key_dir.join("pepper.txt");
|
|
let master_key_path = key_dir.join("master.key");
|
|
let new_password = "new-recovery-password-CANARY_SECRET_VALUE";
|
|
std::fs::write(&password_path, format!("{new_password}\n")).unwrap();
|
|
std::fs::write(&pepper_path, format!("{pepper}\n")).unwrap();
|
|
std::fs::write(&master_key_path, format!("{master_key}\n")).unwrap();
|
|
|
|
let recovered = command(
|
|
&[
|
|
"admin-auth",
|
|
"recover",
|
|
"--email",
|
|
"recover@example.local",
|
|
"--password-file",
|
|
password_path.to_str().unwrap(),
|
|
"--password-pepper-file",
|
|
pepper_path.to_str().unwrap(),
|
|
"--master-key-file",
|
|
master_key_path.to_str().unwrap(),
|
|
],
|
|
Some(&database_url),
|
|
);
|
|
assert!(
|
|
recovered.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&recovered.stderr)
|
|
);
|
|
let stdout = String::from_utf8(recovered.stdout).unwrap();
|
|
assert!(stdout.contains("\"status\":\"admin_recovered\""));
|
|
assert!(!stdout.contains("CANARY_SECRET_VALUE"));
|
|
assert!(
|
|
registry
|
|
.get_user_session(&session_id, "session-secret-hash")
|
|
.await
|
|
.unwrap()
|
|
.is_none()
|
|
);
|
|
let user = registry
|
|
.get_auth_user_by_email("recover@example.local")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(verify_password(new_password, pepper, &user.password_hash));
|
|
assert!(!verify_password(old_password, pepper, &user.password_hash));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn migration_error_json_preserves_affected_version() {
|
|
let database_url = crank_test_support::postgres_schema_url("test_migration_cli_version").await;
|
|
assert!(command(&["apply"], Some(&database_url)).status.success());
|
|
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
|
sqlx::query("update __crank_migrations set checksum = 'tampered' where version = 2")
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
let output = command(&["preflight"], Some(&database_url));
|
|
assert!(!output.status.success());
|
|
let diagnostic: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap();
|
|
assert_eq!(diagnostic["code"], "checksum_mismatch");
|
|
assert_eq!(diagnostic["version"], 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn master_key_rotation_command_is_resumable_and_redacted() {
|
|
let database_url =
|
|
crank_test_support::postgres_schema_url("test_master_key_rotation_cli").await;
|
|
assert!(command(&["apply"], Some(&database_url)).status.success());
|
|
let registry = PostgresRegistry::connect(&database_url).await.unwrap();
|
|
let now = timestamp("2026-08-21T00:00:00Z");
|
|
let current_key = "current-master-key-cli-canary-0000000000000000";
|
|
let target_key = "target-master-key-cli-canary-00000000000000000";
|
|
let current_crypto = SecretCrypto::new(current_key).unwrap();
|
|
registry
|
|
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
|
|
epoch: 1,
|
|
fingerprint: current_crypto.master_key_fingerprint(),
|
|
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
|
observed_at: &now,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
insert_secret(®istry, "cli_secret_a", ¤t_crypto, &now).await;
|
|
insert_secret(®istry, "cli_secret_b", ¤t_crypto, &now).await;
|
|
|
|
let key_dir =
|
|
std::env::temp_dir().join(format!("crank-master-key-rotation-{}", std::process::id()));
|
|
std::fs::create_dir_all(&key_dir).unwrap();
|
|
let current_path = key_dir.join("current.key");
|
|
let target_path = key_dir.join("target.key");
|
|
std::fs::write(¤t_path, current_key).unwrap();
|
|
std::fs::write(&target_path, target_key).unwrap();
|
|
let current_path = current_path.to_string_lossy().to_string();
|
|
let target_path = target_path.to_string_lossy().to_string();
|
|
|
|
let preflight = command(
|
|
&[
|
|
"master-key",
|
|
"preflight",
|
|
"--current-key-file",
|
|
¤t_path,
|
|
"--target-key-file",
|
|
&target_path,
|
|
"--backup-ref",
|
|
"offline-backup-ref",
|
|
],
|
|
Some(&database_url),
|
|
);
|
|
assert!(
|
|
preflight.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&preflight.stderr)
|
|
);
|
|
assert_redacted(&preflight, current_key, target_key);
|
|
let preflight_json: serde_json::Value = serde_json::from_slice(&preflight.stdout).unwrap();
|
|
assert_eq!(preflight_json["status"], "preflight_ok");
|
|
assert_eq!(preflight_json["affected_secret_versions"], 2);
|
|
assert!(
|
|
registry
|
|
.master_key_rotation_status()
|
|
.await
|
|
.unwrap()
|
|
.rotations
|
|
.is_empty()
|
|
);
|
|
|
|
let partial = command(
|
|
&[
|
|
"master-key",
|
|
"rotate",
|
|
"--current-key-file",
|
|
¤t_path,
|
|
"--target-key-file",
|
|
&target_path,
|
|
"--backup-ref",
|
|
"offline-backup-ref",
|
|
"--max-versions",
|
|
"1",
|
|
],
|
|
Some(&database_url),
|
|
);
|
|
assert!(
|
|
partial.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&partial.stderr)
|
|
);
|
|
assert_redacted(&partial, current_key, target_key);
|
|
let partial_json: serde_json::Value = serde_json::from_slice(&partial.stdout).unwrap();
|
|
assert_eq!(partial_json["status"], "running");
|
|
assert_eq!(partial_json["processed_secret_versions"], 1);
|
|
|
|
let resumed = command(
|
|
&[
|
|
"master-key",
|
|
"rotate",
|
|
"--current-key-file",
|
|
¤t_path,
|
|
"--target-key-file",
|
|
&target_path,
|
|
"--backup-ref",
|
|
"offline-backup-ref",
|
|
],
|
|
Some(&database_url),
|
|
);
|
|
assert!(
|
|
resumed.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&resumed.stderr)
|
|
);
|
|
let resumed_json: serde_json::Value = serde_json::from_slice(&resumed.stdout).unwrap();
|
|
assert_eq!(resumed_json["status"], "verifying");
|
|
assert_eq!(resumed_json["processed_secret_versions"], 2);
|
|
|
|
let verified = command(
|
|
&["master-key", "verify", "--target-key-file", &target_path],
|
|
Some(&database_url),
|
|
);
|
|
assert!(
|
|
verified.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&verified.stderr)
|
|
);
|
|
let verified_json: serde_json::Value = serde_json::from_slice(&verified.stdout).unwrap();
|
|
assert_eq!(verified_json["status"], "verified");
|
|
|
|
let promoted = command(
|
|
&["master-key", "promote", "--target-key-file", &target_path],
|
|
Some(&database_url),
|
|
);
|
|
assert!(
|
|
promoted.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&promoted.stderr)
|
|
);
|
|
assert_redacted(&promoted, current_key, target_key);
|
|
let promoted_json: serde_json::Value = serde_json::from_slice(&promoted.stdout).unwrap();
|
|
assert_eq!(promoted_json["status"], "promoted");
|
|
assert_eq!(promoted_json["active_epoch"], 2);
|
|
|
|
let old_key_rejected = command(
|
|
&[
|
|
"master-key",
|
|
"preflight",
|
|
"--current-key-file",
|
|
¤t_path,
|
|
"--target-key-file",
|
|
&target_path,
|
|
],
|
|
Some(&database_url),
|
|
);
|
|
assert!(!old_key_rejected.status.success());
|
|
assert_redacted(&old_key_rejected, current_key, target_key);
|
|
let diagnostic: serde_json::Value = serde_json::from_slice(&old_key_rejected.stderr).unwrap();
|
|
assert_eq!(diagnostic["code"], "master_key_identity_mismatch");
|
|
}
|
|
|
|
async fn insert_secret(
|
|
registry: &PostgresRegistry,
|
|
id: &str,
|
|
crypto: &SecretCrypto,
|
|
now: &OffsetDateTime,
|
|
) {
|
|
let ciphertext = crypto
|
|
.encrypt(&json!({ "token": format!("{id}_value") }))
|
|
.unwrap();
|
|
let secret = Secret {
|
|
id: SecretId::new(id),
|
|
workspace_id: WorkspaceId::new("ws_default"),
|
|
name: id.to_owned(),
|
|
kind: SecretKind::Token,
|
|
status: SecretStatus::Active,
|
|
current_version: 1,
|
|
created_at: *now,
|
|
updated_at: *now,
|
|
last_used_at: None,
|
|
};
|
|
registry
|
|
.create_secret(CreateSecretRequest {
|
|
secret: &secret,
|
|
ciphertext: &ciphertext,
|
|
key_version: crypto.key_version(),
|
|
master_key_epoch: crypto.master_key_epoch(),
|
|
created_by: None,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
fn assert_redacted(output: &Output, current_key: &str, target_key: &str) {
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(!stdout.contains(current_key));
|
|
assert!(!stdout.contains(target_key));
|
|
assert!(!stderr.contains(current_key));
|
|
assert!(!stderr.contains(target_key));
|
|
}
|