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(), 12); 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"], 12); } #[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)); }