diff --git a/crates/idfoto-core/tests/field_history.rs b/crates/idfoto-core/tests/field_history.rs new file mode 100644 index 0000000..2b2952c --- /dev/null +++ b/crates/idfoto-core/tests/field_history.rs @@ -0,0 +1,63 @@ +//! Field history end-to-end: capture on update, prune by retention policy, +//! survive encrypt/decrypt round-trip. + +use idfoto_core::{ + Field, FieldValue, HistoryRetention, Item, ItemCore, Section, + crypto::KdfParams, + derive_master_key, decrypt_item, encrypt_item, +}; +use idfoto_core::item_types::LoginCore; +use zeroize::Zeroizing; + +fn key() -> Zeroizing<[u8; 32]> { + derive_master_key(b"x", &[0u8; 32], &[0u8; 32], &KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 }).unwrap() +} + +#[test] +fn password_field_history_captured_on_update() { + let mut item = Item::new("login".into(), ItemCore::Login(LoginCore::default())); + let f = Field::new("password".into(), FieldValue::Password(Zeroizing::new("v0".into()))); + let fid = f.id.clone(); + item.sections.push(Section { name: None, fields: vec![f] }); + + item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap(); + item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v2".into()))).unwrap(); + item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v3".into()))).unwrap(); + + let hist = item.field_history.get(&fid).expect("history exists"); + assert_eq!(hist.len(), 3); + assert_eq!(hist[0].value.as_str(), "v0"); + assert_eq!(hist[2].value.as_str(), "v2"); +} + +#[test] +fn prune_last_n_keeps_most_recent() { + let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default())); + let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into()))); + let fid = f.id.clone(); + item.sections.push(Section { name: None, fields: vec![f] }); + for i in 1..=10 { + item.set_field_value(&fid, FieldValue::Password(Zeroizing::new(format!("v{i}").into()))).unwrap(); + } + item.prune_history(&HistoryRetention::LastN(3), 0); + let hist = &item.field_history[&fid]; + assert_eq!(hist.len(), 3); + // Most recent 3: v7, v8, v9 (v10's predecessor v9 was the latest captured) + assert!(hist.last().unwrap().value.as_str().starts_with('v')); +} + +#[test] +fn history_survives_encrypt_decrypt() { + let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default())); + let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into()))); + let fid = f.id.clone(); + item.sections.push(Section { name: None, fields: vec![f] }); + item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap(); + + let blob = encrypt_item(&item, &key()).unwrap(); + let decoded = decrypt_item(&blob, &key()).unwrap(); + + let hist = decoded.field_history.get(&fid).expect("history survived"); + assert_eq!(hist.len(), 1); + assert_eq!(hist[0].value.as_str(), "v0"); +}