use serde::{Deserialize, Serialize};
use crate::generation::CatalogManifest;
use crate::migration::diff::{ChangeOperation, ChangeSafety};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct PlanOperation {
pub kind: String,
pub schema: String,
pub table: String,
pub column: String,
pub safety: String,
#[serde(skip_serializing_if = "String::is_empty", default)]
pub blocked_reason: String,
pub fingerprint: String,
#[serde(skip_serializing_if = "String::is_empty", default)]
pub sql_preview: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ExportedPlan {
pub generated_at: String,
pub manifest_checksum: String,
pub auto_count: usize,
pub blocked_count: usize,
pub hint_warnings: usize,
pub operations: Vec<PlanOperation>,
pub blocked: Vec<PlanOperation>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub hints: Vec<String>,
pub operations_hash: String,
}
pub fn build_exported_plan(
manifest: &CatalogManifest,
changes: &[ChangeOperation],
) -> ExportedPlan {
use sha2::{Digest, Sha256};
let (operations, blocked): (Vec<_>, Vec<_>) = changes
.iter()
.partition(|c| c.safety != ChangeSafety::Blocked);
let hint_warnings = changes
.iter()
.filter(|c| c.kind == crate::migration::diff::ChangeKind::HintWarning)
.count();
let make_op = |c: &&ChangeOperation| {
let kind = format!("{:?}", c.kind);
let safety = format!("{:?}", c.safety);
let fp = op_fingerprint(
&kind,
&c.schema,
&c.table,
&c.column,
&c.object_name,
&safety,
);
PlanOperation {
kind,
schema: c.schema.clone(),
table: c.table.clone(),
column: c.column.clone(),
safety,
blocked_reason: c.blocked_reason.clone(),
fingerprint: fp,
sql_preview: String::new(),
}
};
let ops: Vec<PlanOperation> = operations.iter().map(make_op).collect();
let blk: Vec<PlanOperation> = blocked.iter().map(make_op).collect();
let mut hasher = Sha256::new();
for op in &ops {
hasher.update(op.fingerprint.as_bytes());
hasher.update(b"\x01");
}
hasher.update(b"\x02"); for op in &blk {
hasher.update(op.fingerprint.as_bytes());
hasher.update(b"\x01");
}
let operations_hash = format!("sha256:{:x}", hasher.finalize());
ExportedPlan {
generated_at: rfc3339_now(),
manifest_checksum: manifest.checksum_sha256.clone(),
auto_count: ops.len(),
blocked_count: blk.len(),
hint_warnings,
operations: ops,
blocked: blk,
hints: Vec::new(),
operations_hash,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum PlanMatchResult {
Matches,
ManifestChanged { approved: String, current: String },
CountMismatch {
approved_auto: usize,
current_auto: usize,
approved_blocked: usize,
current_blocked: usize,
},
OperationsHashMismatch {
approved_hash: String,
current_hash: String,
},
}
impl PlanMatchResult {
pub fn is_match(&self) -> bool {
*self == PlanMatchResult::Matches
}
pub fn describe(&self) -> String {
match self {
Self::Matches => "plan matches approved file".to_string(),
Self::ManifestChanged { approved, current } => format!(
"manifest checksum changed: approved={} current={}",
&approved[..12.min(approved.len())],
¤t[..12.min(current.len())]
),
Self::CountMismatch {
approved_auto,
current_auto,
approved_blocked,
current_blocked,
} => format!(
"operation count mismatch: approved=({auto_a} auto, {blk_a} blocked) current=({auto_c} auto, {blk_c} blocked)",
auto_a = approved_auto,
blk_a = approved_blocked,
auto_c = current_auto,
blk_c = current_blocked,
),
Self::OperationsHashMismatch {
approved_hash,
current_hash,
} => format!(
"operations hash mismatch: approved={} current={}",
&approved_hash[..16.min(approved_hash.len())],
¤t_hash[..16.min(current_hash.len())]
),
}
}
}
pub fn plan_matches_current_diff(
approved: &ExportedPlan,
current_manifest: &CatalogManifest,
current_changes: &[ChangeOperation],
) -> PlanMatchResult {
if approved.manifest_checksum != current_manifest.checksum_sha256 {
return PlanMatchResult::ManifestChanged {
approved: approved.manifest_checksum.clone(),
current: current_manifest.checksum_sha256.clone(),
};
}
let current = build_exported_plan(current_manifest, current_changes);
if approved.auto_count != current.auto_count || approved.blocked_count != current.blocked_count
{
return PlanMatchResult::CountMismatch {
approved_auto: approved.auto_count,
current_auto: current.auto_count,
approved_blocked: approved.blocked_count,
current_blocked: current.blocked_count,
};
}
if approved.operations_hash != current.operations_hash {
return PlanMatchResult::OperationsHashMismatch {
approved_hash: approved.operations_hash.clone(),
current_hash: current.operations_hash.clone(),
};
}
PlanMatchResult::Matches
}
pub fn op_fingerprint(
kind: &str,
schema: &str,
table: &str,
column: &str,
object_name: &str,
safety: &str,
) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
for part in &[
kind,
"\x00",
schema,
"\x00",
table,
"\x00",
column,
"\x00",
object_name,
"\x00",
safety,
] {
h.update(part.as_bytes());
}
format!("sha256:{:x}", h.finalize())
}
fn rfc3339_now() -> String {
chrono::Utc::now().to_rfc3339()
}
pub fn current_unix_ms() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApprovalSignature {
pub approver_id: String,
pub approver_role: String,
pub approved_at_unix_ms: i64,
pub reason: String,
pub signature: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApprovedPlan {
pub plan: ExportedPlan,
pub signatures: Vec<ApprovalSignature>,
pub expires_at_unix_ms: i64,
pub seal: String,
}
#[derive(Debug, Clone)]
pub struct ApprovalConfig {
pub quorum_size: u32,
pub allowed_roles: Vec<String>,
pub expiry: std::time::Duration,
pub signing_key: Vec<u8>,
}
impl Default for ApprovalConfig {
fn default() -> Self {
let quorum = std::env::var("UDB_APPROVAL_QUORUM_SIZE")
.ok()
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(1)
.max(1);
let expiry_secs = std::env::var("UDB_APPROVAL_EXPIRY_SECS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(86_400)
.clamp(60, 30 * 86_400); let allowed_roles = std::env::var("UDB_APPROVAL_ALLOWED_ROLES")
.ok()
.map(|s| {
s.split(',')
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
.collect()
})
.unwrap_or_default();
let signing_key = std::env::var("UDB_APPROVAL_SIGNING_KEY")
.ok()
.map(|s| s.into_bytes())
.unwrap_or_default();
Self {
quorum_size: quorum,
allowed_roles,
expiry: std::time::Duration::from_secs(expiry_secs),
signing_key,
}
}
}
impl ApprovalConfig {
pub fn from_env() -> Self {
Self::default()
}
pub fn requires_signed_plan(&self) -> bool {
self.quorum_size > 1 || !self.signing_key.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApprovalError {
InsufficientQuorum { required: u32, got: u32 },
DuplicateApprover { approver_id: String },
UnknownRole { role: String },
EmptyReason { approver_id: String },
BadSignature { approver_id: String },
SealMismatch,
Expired {
expired_at_unix_ms: i64,
now_unix_ms: i64,
},
}
impl std::fmt::Display for ApprovalError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InsufficientQuorum { required, got } => write!(
f,
"approval rejected: quorum {required} not met (got {got})"
),
Self::DuplicateApprover { approver_id } => write!(
f,
"approval rejected: approver '{approver_id}' signed more than once"
),
Self::UnknownRole { role } => write!(
f,
"approval rejected: role '{role}' is not in the allowed-roles list"
),
Self::EmptyReason { approver_id } => write!(
f,
"approval rejected: approver '{approver_id}' did not supply a reason"
),
Self::BadSignature { approver_id } => write!(
f,
"approval rejected: HMAC signature for '{approver_id}' failed verification"
),
Self::SealMismatch => f.write_str(
"approval rejected: seal does not match plan + signatures (tampered or corrupted)",
),
Self::Expired {
expired_at_unix_ms,
now_unix_ms,
} => write!(
f,
"approval expired at {expired_at_unix_ms} (now {now_unix_ms})"
),
}
}
}
impl std::error::Error for ApprovalError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApprovalFreshness {
Valid {
remaining_secs: u64,
},
Expired {
expired_at_unix_ms: i64,
now_unix_ms: i64,
},
}
pub fn compute_signature(
plan_operations_hash: &str,
approver_id: &str,
reason: &str,
signing_key: &[u8],
) -> String {
use crate::runtime::security::hmac_sha256;
let mut msg =
Vec::with_capacity(plan_operations_hash.len() + approver_id.len() + reason.len() + 2);
msg.extend_from_slice(plan_operations_hash.as_bytes());
msg.push(0);
msg.extend_from_slice(approver_id.as_bytes());
msg.push(0);
msg.extend_from_slice(reason.as_bytes());
let mac = hmac_sha256(signing_key, &msg);
let mut out = String::with_capacity("hmac-sha256:".len() + mac.len() * 2);
out.push_str("hmac-sha256:");
for b in mac {
out.push_str(&format!("{b:02x}"));
}
out
}
fn compute_seal(plan: &ExportedPlan, signatures: &[ApprovalSignature]) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(plan.operations_hash.as_bytes());
h.update(b"\x00");
for sig in signatures {
h.update(sig.approver_id.as_bytes());
h.update(b"\x00");
h.update(sig.approver_role.as_bytes());
h.update(b"\x00");
h.update(sig.approved_at_unix_ms.to_be_bytes());
h.update(b"\x00");
h.update(sig.reason.as_bytes());
h.update(b"\x00");
h.update(sig.signature.as_bytes());
h.update(b"\x01");
}
format!("sha256:{:x}", h.finalize())
}
fn validate_signature_policy(
plan: &ExportedPlan,
signatures: &[ApprovalSignature],
config: &ApprovalConfig,
) -> Result<(), ApprovalError> {
let mut seen_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
for sig in signatures {
if sig.reason.trim().is_empty() {
return Err(ApprovalError::EmptyReason {
approver_id: sig.approver_id.clone(),
});
}
if !seen_ids.insert(sig.approver_id.clone()) {
return Err(ApprovalError::DuplicateApprover {
approver_id: sig.approver_id.clone(),
});
}
if !config.allowed_roles.is_empty()
&& !config.allowed_roles.iter().any(|r| r == &sig.approver_role)
{
return Err(ApprovalError::UnknownRole {
role: sig.approver_role.clone(),
});
}
let expected = compute_signature(
&plan.operations_hash,
&sig.approver_id,
&sig.reason,
&config.signing_key,
);
if expected != sig.signature {
return Err(ApprovalError::BadSignature {
approver_id: sig.approver_id.clone(),
});
}
}
if (signatures.len() as u32) < config.quorum_size {
return Err(ApprovalError::InsufficientQuorum {
required: config.quorum_size,
got: signatures.len() as u32,
});
}
Ok(())
}
impl ApprovedPlan {
pub fn create(
plan: ExportedPlan,
signatures: Vec<ApprovalSignature>,
config: &ApprovalConfig,
now_unix_ms: i64,
) -> Result<Self, ApprovalError> {
validate_signature_policy(&plan, &signatures, config)?;
let seal = compute_seal(&plan, &signatures);
let expires_at_unix_ms = now_unix_ms + (config.expiry.as_millis() as i64);
Ok(Self {
plan,
signatures,
expires_at_unix_ms,
seal,
})
}
pub fn freshness(&self, now_unix_ms: i64) -> ApprovalFreshness {
if now_unix_ms >= self.expires_at_unix_ms {
ApprovalFreshness::Expired {
expired_at_unix_ms: self.expires_at_unix_ms,
now_unix_ms,
}
} else {
let remaining = (self.expires_at_unix_ms - now_unix_ms) / 1000;
ApprovalFreshness::Valid {
remaining_secs: remaining.max(0) as u64,
}
}
}
pub fn verify(&self, config: &ApprovalConfig) -> Result<(), ApprovalError> {
let expected_seal = compute_seal(&self.plan, &self.signatures);
if expected_seal != self.seal {
return Err(ApprovalError::SealMismatch);
}
validate_signature_policy(&self.plan, &self.signatures, config)
}
pub fn ready_to_apply(
&self,
config: &ApprovalConfig,
current_manifest: &CatalogManifest,
current_changes: &[ChangeOperation],
now_unix_ms: i64,
) -> Result<PlanMatchResult, ApprovalError> {
self.verify(config)?;
if let ApprovalFreshness::Expired {
expired_at_unix_ms,
now_unix_ms,
} = self.freshness(now_unix_ms)
{
return Err(ApprovalError::Expired {
expired_at_unix_ms,
now_unix_ms,
});
}
Ok(plan_matches_current_diff(
&self.plan,
current_manifest,
current_changes,
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generation::CatalogManifest;
use crate::migration::diff::{ChangeKind, ChangeOperation, ChangeSafety};
fn sample_op(kind: ChangeKind, schema: &str, table: &str) -> ChangeOperation {
use sha2::{Digest, Sha256};
let fp = format!(
"{:x}",
Sha256::digest(format!("{kind:?}{schema}{table}").as_bytes())
);
ChangeOperation {
kind,
safety: ChangeSafety::SafeAuto,
schema: schema.to_string(),
table: table.to_string(),
fingerprint: fp,
..Default::default()
}
}
#[test]
fn plan_matches_identical_state() {
let manifest = CatalogManifest {
checksum_sha256: "abc123".to_string(),
..CatalogManifest::default()
};
let changes = vec![sample_op(ChangeKind::AddColumn, "ocr", "cases")];
let approved = build_exported_plan(&manifest, &changes);
let result = plan_matches_current_diff(&approved, &manifest, &changes);
assert_eq!(result, PlanMatchResult::Matches);
}
#[test]
fn plan_detects_manifest_change() {
let old_manifest = CatalogManifest {
checksum_sha256: "old-checksum".to_string(),
..Default::default()
};
let new_manifest = CatalogManifest {
checksum_sha256: "new-checksum".to_string(),
..Default::default()
};
let approved = build_exported_plan(&old_manifest, &[]);
let result = plan_matches_current_diff(&approved, &new_manifest, &[]);
assert!(matches!(result, PlanMatchResult::ManifestChanged { .. }));
}
#[test]
fn op_fingerprint_is_stable() {
let fp1 = op_fingerprint("AddColumn", "ocr", "cases", "tenant_id", "", "SafeAuto");
let fp2 = op_fingerprint("AddColumn", "ocr", "cases", "tenant_id", "", "SafeAuto");
assert_eq!(fp1, fp2);
assert!(fp1.starts_with("sha256:"));
}
fn approval_config(quorum: u32, roles: Vec<&str>) -> ApprovalConfig {
ApprovalConfig {
quorum_size: quorum,
allowed_roles: roles.into_iter().map(str::to_string).collect(),
expiry: std::time::Duration::from_secs(3600),
signing_key: b"test-signing-key-32-bytes-..........".to_vec(),
}
}
fn sample_plan() -> ExportedPlan {
let m = CatalogManifest {
checksum_sha256: "abc123".to_string(),
..Default::default()
};
let changes = vec![sample_op(ChangeKind::AddColumn, "ocr", "cases")];
build_exported_plan(&m, &changes)
}
fn signed(
plan: &ExportedPlan,
approver_id: &str,
role: &str,
reason: &str,
config: &ApprovalConfig,
) -> ApprovalSignature {
let sig = compute_signature(
&plan.operations_hash,
approver_id,
reason,
&config.signing_key,
);
ApprovalSignature {
approver_id: approver_id.to_string(),
approver_role: role.to_string(),
approved_at_unix_ms: 1_000,
reason: reason.to_string(),
signature: sig,
}
}
#[test]
fn approval_config_requires_signed_plan_for_key_or_quorum() {
let base = ApprovalConfig {
quorum_size: 1,
allowed_roles: Vec::new(),
expiry: std::time::Duration::from_secs(3600),
signing_key: Vec::new(),
};
assert!(!base.requires_signed_plan());
assert!(
ApprovalConfig {
signing_key: b"configured-key".to_vec(),
..base.clone()
}
.requires_signed_plan()
);
assert!(
ApprovalConfig {
quorum_size: 2,
..base
}
.requires_signed_plan()
);
}
#[test]
fn approval_single_signer_happy_path() {
let cfg = approval_config(1, vec!["sre"]);
let plan = sample_plan();
let sig = signed(
&plan,
"alice@org",
"sre",
"rolling out audit log column",
&cfg,
);
let approved = ApprovedPlan::create(plan, vec![sig], &cfg, 0).expect("create");
assert!(approved.seal.starts_with("sha256:"));
assert_eq!(approved.expires_at_unix_ms, 3_600_000);
assert!(approved.verify(&cfg).is_ok());
}
#[test]
fn approval_rejects_empty_reason() {
let cfg = approval_config(1, vec![]);
let plan = sample_plan();
let sig = signed(&plan, "alice", "sre", "", &cfg);
let err = ApprovedPlan::create(plan, vec![sig], &cfg, 0).unwrap_err();
assert!(matches!(err, ApprovalError::EmptyReason { .. }));
}
#[test]
fn approval_rejects_duplicate_approver() {
let cfg = approval_config(2, vec![]);
let plan = sample_plan();
let sig_a = signed(&plan, "alice", "sre", "first vote", &cfg);
let sig_a_again = signed(&plan, "alice", "sre", "second vote", &cfg);
let err = ApprovedPlan::create(plan, vec![sig_a, sig_a_again], &cfg, 0).unwrap_err();
assert!(matches!(err, ApprovalError::DuplicateApprover { .. }));
}
#[test]
fn approval_rejects_unknown_role() {
let cfg = approval_config(1, vec!["sre", "tech_lead"]);
let plan = sample_plan();
let sig = signed(&plan, "alice", "intern", "looks fine", &cfg);
let err = ApprovedPlan::create(plan, vec![sig], &cfg, 0).unwrap_err();
assert!(matches!(err, ApprovalError::UnknownRole { .. }));
}
#[test]
fn approval_accepts_any_role_when_allowlist_empty() {
let cfg = approval_config(1, vec![]);
let plan = sample_plan();
let sig = signed(&plan, "alice", "anything-goes", "dev env", &cfg);
assert!(ApprovedPlan::create(plan, vec![sig], &cfg, 0).is_ok());
}
#[test]
fn approval_rejects_insufficient_quorum() {
let cfg = approval_config(2, vec!["sre", "dba"]);
let plan = sample_plan();
let sig = signed(&plan, "alice", "sre", "single vote", &cfg);
let err = ApprovedPlan::create(plan, vec![sig], &cfg, 0).unwrap_err();
match err {
ApprovalError::InsufficientQuorum { required, got } => {
assert_eq!(required, 2);
assert_eq!(got, 1);
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn approval_accepts_full_quorum() {
let cfg = approval_config(2, vec!["sre", "dba"]);
let plan = sample_plan();
let sig_a = signed(&plan, "alice", "sre", "shipping this", &cfg);
let sig_b = signed(&plan, "bob", "dba", "schema looks fine", &cfg);
assert!(ApprovedPlan::create(plan, vec![sig_a, sig_b], &cfg, 0).is_ok());
}
#[test]
fn approval_verify_rejects_loaded_plan_below_current_quorum() {
let cfg_one = approval_config(1, vec![]);
let plan = sample_plan();
let sig = signed(&plan, "alice", "sre", "single signer", &cfg_one);
let approved = ApprovedPlan::create(plan, vec![sig], &cfg_one, 0).unwrap();
let cfg_two = ApprovalConfig {
quorum_size: 2,
..cfg_one
};
let err = approved.verify(&cfg_two).unwrap_err();
assert!(matches!(err, ApprovalError::InsufficientQuorum { .. }));
}
#[test]
fn approval_detects_signature_tampering() {
let cfg = approval_config(1, vec![]);
let plan = sample_plan();
let mut sig = signed(&plan, "alice", "sre", "ok", &cfg);
sig.signature = "hmac-sha256:0000".to_string(); let err = ApprovedPlan::create(plan, vec![sig], &cfg, 0).unwrap_err();
assert!(matches!(err, ApprovalError::BadSignature { .. }));
}
#[test]
fn approval_detects_seal_tampering_post_create() {
let cfg = approval_config(1, vec![]);
let plan = sample_plan();
let sig = signed(&plan, "alice", "sre", "ok", &cfg);
let mut approved = ApprovedPlan::create(plan, vec![sig], &cfg, 0).unwrap();
approved.signatures[0].reason = "I lied about my reason".to_string();
let err = approved.verify(&cfg).unwrap_err();
assert!(matches!(err, ApprovalError::SealMismatch));
}
#[test]
fn approval_freshness_returns_remaining_secs() {
let cfg = approval_config(1, vec![]);
let plan = sample_plan();
let sig = signed(&plan, "alice", "sre", "ok", &cfg);
let approved = ApprovedPlan::create(plan, vec![sig], &cfg, 0).unwrap();
match approved.freshness(1_000) {
ApprovalFreshness::Valid { remaining_secs } => assert_eq!(remaining_secs, 3_599),
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn approval_freshness_returns_expired_past_deadline() {
let cfg = approval_config(1, vec![]);
let plan = sample_plan();
let sig = signed(&plan, "alice", "sre", "ok", &cfg);
let approved = ApprovedPlan::create(plan, vec![sig], &cfg, 0).unwrap();
match approved.freshness(approved.expires_at_unix_ms + 1) {
ApprovalFreshness::Expired {
expired_at_unix_ms,
now_unix_ms,
} => {
assert_eq!(expired_at_unix_ms, 3_600_000);
assert_eq!(now_unix_ms, 3_600_001);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn ready_to_apply_succeeds_when_everything_matches() {
let cfg = approval_config(1, vec![]);
let manifest = CatalogManifest {
checksum_sha256: "abc123".to_string(),
..Default::default()
};
let changes = vec![sample_op(ChangeKind::AddColumn, "ocr", "cases")];
let plan = build_exported_plan(&manifest, &changes);
let sig = signed(&plan, "alice", "sre", "ok", &cfg);
let approved = ApprovedPlan::create(plan, vec![sig], &cfg, 0).unwrap();
let result = approved
.ready_to_apply(&cfg, &manifest, &changes, approved.expires_at_unix_ms - 1)
.unwrap();
assert_eq!(result, PlanMatchResult::Matches);
}
#[test]
fn ready_to_apply_blocks_when_manifest_changed_since_approval() {
let cfg = approval_config(1, vec![]);
let old_manifest = CatalogManifest {
checksum_sha256: "old".to_string(),
..Default::default()
};
let new_manifest = CatalogManifest {
checksum_sha256: "new".to_string(),
..Default::default()
};
let changes = vec![sample_op(ChangeKind::AddColumn, "ocr", "cases")];
let plan = build_exported_plan(&old_manifest, &changes);
let sig = signed(&plan, "alice", "sre", "ok", &cfg);
let approved = ApprovedPlan::create(plan, vec![sig], &cfg, 0).unwrap();
let result = approved
.ready_to_apply(&cfg, &new_manifest, &changes, 100)
.unwrap();
assert!(matches!(result, PlanMatchResult::ManifestChanged { .. }));
}
#[test]
fn ready_to_apply_rejects_expired_approval() {
let cfg = approval_config(1, vec![]);
let manifest = CatalogManifest {
checksum_sha256: "abc".to_string(),
..Default::default()
};
let changes = vec![sample_op(ChangeKind::AddColumn, "x", "y")];
let plan = build_exported_plan(&manifest, &changes);
let sig = signed(&plan, "alice", "sre", "ok", &cfg);
let approved = ApprovedPlan::create(plan, vec![sig], &cfg, 0).unwrap();
let err = approved
.ready_to_apply(&cfg, &manifest, &changes, approved.expires_at_unix_ms + 1)
.unwrap_err();
assert!(matches!(err, ApprovalError::Expired { .. }));
}
#[test]
fn ready_to_apply_rejects_under_rotated_key() {
let cfg_a = approval_config(1, vec![]);
let manifest = CatalogManifest {
checksum_sha256: "abc".to_string(),
..Default::default()
};
let changes = vec![sample_op(ChangeKind::AddColumn, "x", "y")];
let plan = build_exported_plan(&manifest, &changes);
let sig = signed(&plan, "alice", "sre", "ok", &cfg_a);
let approved = ApprovedPlan::create(plan, vec![sig], &cfg_a, 0).unwrap();
let cfg_b = ApprovalConfig {
signing_key: b"rotated-key-........................".to_vec(),
..approval_config(1, vec![])
};
let err = approved
.ready_to_apply(&cfg_b, &manifest, &changes, 100)
.unwrap_err();
assert!(matches!(err, ApprovalError::BadSignature { .. }));
}
#[test]
fn compute_signature_is_deterministic_and_keyed() {
let key = b"k";
let sig_a = compute_signature("hash1", "alice", "reason", key);
let sig_b = compute_signature("hash1", "alice", "reason", key);
assert_eq!(sig_a, sig_b);
let sig_diff_key = compute_signature("hash1", "alice", "reason", b"different");
assert_ne!(sig_a, sig_diff_key);
let sig_diff_msg = compute_signature("hash2", "alice", "reason", key);
assert_ne!(sig_a, sig_diff_msg);
}
#[test]
fn rfc3339_now_returns_iso8601_format() {
let now = rfc3339_now();
assert!(chrono::DateTime::parse_from_rfc3339(&now).is_ok());
}
}