use crank_core::{Secret, SecretId, SecretKind, SecretStatus, WorkspaceId}; use crank_registry::{ CreateSecretRequest, MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate, RegistryError, RotateSecretRequest, }; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use super::common::TestDatabase; fn timestamp(value: &str) -> OffsetDateTime { OffsetDateTime::parse(value, &Rfc3339).unwrap() } #[tokio::test] async fn registers_and_verifies_active_master_key_identity() { let database = TestDatabase::new().await; let registry = database.registry().await; let now = timestamp("2026-08-21T00:00:00Z"); let fingerprint = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; let registered = registry .verify_or_register_master_key_identity(MasterKeyIdentityCandidate { epoch: 1, fingerprint, cipher_contract: MASTER_KEY_CIPHER_CONTRACT, observed_at: &now, }) .await .unwrap(); let verified = registry .verify_or_register_master_key_identity(MasterKeyIdentityCandidate { epoch: 1, fingerprint, cipher_contract: MASTER_KEY_CIPHER_CONTRACT, observed_at: &now, }) .await .unwrap(); assert_eq!(registered.epoch, 1); assert_eq!(registered.status, "active"); assert_eq!(registered, verified); database.cleanup().await; } #[tokio::test] async fn rejects_different_active_master_key_identity() { let database = TestDatabase::new().await; let registry = database.registry().await; let now = timestamp("2026-08-21T00:00:00Z"); registry .verify_or_register_master_key_identity(MasterKeyIdentityCandidate { epoch: 1, fingerprint: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", cipher_contract: MASTER_KEY_CIPHER_CONTRACT, observed_at: &now, }) .await .unwrap(); let error = registry .verify_or_register_master_key_identity(MasterKeyIdentityCandidate { epoch: 1, fingerprint: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", cipher_contract: MASTER_KEY_CIPHER_CONTRACT, observed_at: &now, }) .await .unwrap_err(); assert!(matches!( error, RegistryError::MasterKeyIdentityMismatch { epoch: 1 } )); assert!(!error.to_string().contains("fedcba")); assert!(!error.to_string().contains("012345")); database.cleanup().await; } #[tokio::test] async fn master_key_rotation_is_resumable_and_promotes_atomically() { let database = TestDatabase::new().await; let registry = database.registry().await; let now = timestamp("2026-08-21T00:00:00Z"); let later = timestamp("2026-08-21T00:01:00Z"); let current_fingerprint = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; let target_fingerprint = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; registry .verify_or_register_master_key_identity(MasterKeyIdentityCandidate { epoch: 1, fingerprint: current_fingerprint, cipher_contract: MASTER_KEY_CIPHER_CONTRACT, observed_at: &now, }) .await .unwrap(); insert_secret_version(®istry, "secret_a", "cipher-a", &now).await; insert_secret_version(®istry, "secret_b", "cipher-b", &now).await; let snapshot = registry .list_secret_versions_for_master_key_epoch(1) .await .unwrap(); assert_eq!(snapshot.len(), 2); let preflight_status = registry.master_key_rotation_status().await.unwrap(); assert_eq!(preflight_status.active_identity.unwrap().epoch, 1); assert!(preflight_status.rotations.is_empty()); assert!( registry .list_secret_versions_for_master_key_epoch(1) .await .unwrap() .iter() .all(|version| version.target_ciphertext.is_none()) ); let rotation = registry .begin_master_key_rotation(1, 2, target_fingerprint, Some("offline-backup-ref"), &now) .await .unwrap(); assert_eq!(rotation.state, "running"); assert_eq!(rotation.total_secret_versions, 2); registry .stage_master_key_rotation_ciphertext( &rotation.id, &SecretId::new("secret_a"), 1, 1, "target-cipher-a", "v2", 2, &later, ) .await .unwrap(); let resumed = registry .begin_master_key_rotation(1, 2, target_fingerprint, Some("offline-backup-ref"), &later) .await .unwrap(); assert_eq!(resumed.processed_secret_versions, 1); registry .stage_master_key_rotation_ciphertext( &rotation.id, &SecretId::new("secret_b"), 1, 1, "target-cipher-b", "v2", 2, &later, ) .await .unwrap(); let ready = registry .finish_master_key_rotation_batches(&rotation.id, &later) .await .unwrap(); assert_eq!(ready.state, "verifying"); registry .verify_master_key_rotation(&rotation.id, 2, &later) .await .unwrap(); registry .promote_master_key_rotation( &rotation.id, MasterKeyIdentityCandidate { epoch: 2, fingerprint: target_fingerprint, cipher_contract: MASTER_KEY_CIPHER_CONTRACT, observed_at: &later, }, &later, ) .await .unwrap(); let status = registry.master_key_rotation_status().await.unwrap(); let active = status.active_identity.unwrap(); assert_eq!(active.epoch, 2); assert_eq!(active.fingerprint, target_fingerprint); assert_eq!(status.rotations[0].state, "promoted"); assert!( registry .list_secret_versions_for_master_key_epoch(1) .await .unwrap() .is_empty() ); let promoted = registry .list_secret_versions_for_master_key_epoch(2) .await .unwrap(); assert_eq!(promoted.len(), 2); assert!( promoted .iter() .all(|version| version.target_ciphertext.is_none()) ); database.cleanup().await; } #[tokio::test] async fn abort_preserves_current_epoch_and_clears_target_ciphertext() { let database = TestDatabase::new().await; let registry = database.registry().await; let now = timestamp("2026-08-21T00:00:00Z"); registry .verify_or_register_master_key_identity(MasterKeyIdentityCandidate { epoch: 1, fingerprint: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", cipher_contract: MASTER_KEY_CIPHER_CONTRACT, observed_at: &now, }) .await .unwrap(); insert_secret_version(®istry, "secret_abort", "cipher-a", &now).await; let rotation = registry .begin_master_key_rotation( 1, 2, "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", None, &now, ) .await .unwrap(); registry .stage_master_key_rotation_ciphertext( &rotation.id, &SecretId::new("secret_abort"), 1, 1, "target-cipher-a", "v2", 2, &now, ) .await .unwrap(); registry .abort_master_key_rotation(&rotation.id, &now) .await .unwrap(); let status = registry.master_key_rotation_status().await.unwrap(); assert_eq!(status.active_identity.unwrap().epoch, 1); assert_eq!(status.rotations[0].state, "aborted"); let versions = registry .list_secret_versions_for_master_key_epoch(1) .await .unwrap(); assert_eq!(versions.len(), 1); assert!(versions[0].target_ciphertext.is_none()); database.cleanup().await; } #[tokio::test] async fn aborted_rotation_can_be_rerun_for_same_source_epoch() { let database = TestDatabase::new().await; let registry = database.registry().await; let now = timestamp("2026-08-21T00:00:00Z"); let target_fingerprint = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; registry .verify_or_register_master_key_identity(MasterKeyIdentityCandidate { epoch: 1, fingerprint: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", cipher_contract: MASTER_KEY_CIPHER_CONTRACT, observed_at: &now, }) .await .unwrap(); insert_secret_version(®istry, "secret_retry", "cipher-a", &now).await; let rotation = registry .begin_master_key_rotation(1, 2, target_fingerprint, None, &now) .await .unwrap(); registry .stage_master_key_rotation_ciphertext( &rotation.id, &SecretId::new("secret_retry"), 1, 1, "target-cipher-a", "v2", 2, &now, ) .await .unwrap(); registry .abort_master_key_rotation(&rotation.id, &now) .await .unwrap(); let rerun = registry .begin_master_key_rotation(1, 2, target_fingerprint, Some("second-attempt"), &now) .await .unwrap(); assert_eq!(rerun.id, rotation.id); assert_eq!(rerun.state, "running"); assert_eq!(rerun.processed_secret_versions, 0); assert_eq!(rerun.backup_ref.as_deref(), Some("second-attempt")); assert!( registry .list_secret_versions_for_master_key_epoch(1) .await .unwrap() .iter() .all(|version| version.target_ciphertext.is_none()) ); database.cleanup().await; } #[tokio::test] async fn active_rotation_rejects_new_secret_writes() { let database = TestDatabase::new().await; let registry = database.registry().await; let now = timestamp("2026-08-21T00:00:00Z"); registry .verify_or_register_master_key_identity(MasterKeyIdentityCandidate { epoch: 1, fingerprint: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", cipher_contract: MASTER_KEY_CIPHER_CONTRACT, observed_at: &now, }) .await .unwrap(); insert_secret_version(®istry, "existing_secret", "cipher-a", &now).await; registry .begin_master_key_rotation( 1, 2, "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", None, &now, ) .await .unwrap(); let create_error = registry .create_secret(CreateSecretRequest { secret: &test_secret("new_secret", &now), ciphertext: "cipher-new", key_version: "v2", master_key_epoch: 1, created_by: None, }) .await .unwrap_err(); assert!(matches!( create_error, RegistryError::MasterKeyRotationInProgress )); let rotate_error = registry .rotate_secret(RotateSecretRequest { workspace_id: &WorkspaceId::new("ws_default"), secret_id: &SecretId::new("existing_secret"), ciphertext: "cipher-rotated", key_version: "v2", master_key_epoch: 1, created_at: &now, updated_at: &now, created_by: None, }) .await .unwrap_err(); assert!(matches!( rotate_error, RegistryError::MasterKeyRotationInProgress )); let delete_error = registry .delete_secret( &WorkspaceId::new("ws_default"), &SecretId::new("existing_secret"), ) .await .unwrap_err(); assert!(matches!( delete_error, RegistryError::MasterKeyRotationInProgress )); database.cleanup().await; } #[tokio::test] async fn stale_source_epoch_writer_is_rejected_after_promotion() { let database = TestDatabase::new().await; let registry = database.registry().await; let now = timestamp("2026-08-21T00:00:00Z"); let source_fingerprint = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; let target_fingerprint = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; registry .verify_or_register_master_key_identity(MasterKeyIdentityCandidate { epoch: 1, fingerprint: source_fingerprint, cipher_contract: MASTER_KEY_CIPHER_CONTRACT, observed_at: &now, }) .await .unwrap(); insert_secret_version(®istry, "promoted_secret", "cipher-a", &now).await; let rotation = registry .begin_master_key_rotation(1, 2, target_fingerprint, None, &now) .await .unwrap(); registry .stage_master_key_rotation_ciphertext( &rotation.id, &SecretId::new("promoted_secret"), 1, 1, "target-cipher-a", "v2", 2, &now, ) .await .unwrap(); registry .finish_master_key_rotation_batches(&rotation.id, &now) .await .unwrap(); registry .verify_master_key_rotation(&rotation.id, 1, &now) .await .unwrap(); registry .promote_master_key_rotation( &rotation.id, MasterKeyIdentityCandidate { epoch: 2, fingerprint: target_fingerprint, cipher_contract: MASTER_KEY_CIPHER_CONTRACT, observed_at: &now, }, &now, ) .await .unwrap(); let create_error = registry .create_secret(CreateSecretRequest { secret: &test_secret("stale_new_secret", &now), ciphertext: "cipher-new", key_version: "v2", master_key_epoch: 1, created_by: None, }) .await .unwrap_err(); assert!(matches!( create_error, RegistryError::MasterKeyIdentityMismatch { epoch: 2 } )); let rotate_error = registry .rotate_secret(RotateSecretRequest { workspace_id: &WorkspaceId::new("ws_default"), secret_id: &SecretId::new("promoted_secret"), ciphertext: "cipher-rotated", key_version: "v2", master_key_epoch: 1, created_at: &now, updated_at: &now, created_by: None, }) .await .unwrap_err(); assert!(matches!( rotate_error, RegistryError::MasterKeyIdentityMismatch { epoch: 2 } )); database.cleanup().await; } async fn insert_secret_version( registry: &crank_registry::PostgresRegistry, id: &str, ciphertext: &str, now: &OffsetDateTime, ) { registry .create_secret(CreateSecretRequest { secret: &test_secret(id, now), ciphertext, key_version: "v2", master_key_epoch: 1, created_by: None, }) .await .unwrap(); } fn test_secret(id: &str, now: &OffsetDateTime) -> 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, } }