diff --git a/crates/buzz-audit/src/action.rs b/crates/buzz-audit/src/action.rs index be7ccc3545..45534c7755 100644 --- a/crates/buzz-audit/src/action.rs +++ b/crates/buzz-audit/src/action.rs @@ -97,4 +97,50 @@ mod tests { fn unknown_action_returns_err() { assert!("totally_bogus".parse::().is_err()); } + + #[test] + fn serde_json_roundtrip_all_variants() { + // The action is stored as a string column and deserialized from DB + // rows; the serde representation must match as_str/FromStr exactly. + for action in AuditAction::ALL { + let json = serde_json::to_string(action).unwrap(); + let back: AuditAction = serde_json::from_str(&json).unwrap(); + assert_eq!(action, &back); + } + } + + #[test] + fn serde_uses_snake_case() { + // The serde rename_all = "snake_case" must agree with as_str(). + // If someone adds a variant but forgets to update as_str(), or vice + // versa, this catches the drift. + for action in AuditAction::ALL { + let json = serde_json::to_string(action).unwrap(); + // serde produces a quoted string like "\"event_created\"". + let expected = format!("\"{}\"", action.as_str()); + assert_eq!( + json, expected, + "serde representation differs from as_str() for {action:?}" + ); + } + } + + #[test] + fn as_str_values_are_unique() { + // Two variants sharing the same as_str() would be ambiguous on parse + // and on DB read — this is a silent correctness bug. + let strs: Vec<&str> = AuditAction::ALL.iter().map(|a| a.as_str()).collect(); + let unique: std::collections::HashSet<&str> = strs.iter().copied().collect(); + assert_eq!(strs.len(), unique.len(), "duplicate as_str() values"); + } + + #[test] + fn as_str_and_from_str_are_inverses() { + // Round-trip through the string representation used in DB storage. + for action in AuditAction::ALL { + let s = action.as_str(); + let parsed: AuditAction = s.parse().unwrap(); + assert_eq!(&parsed, action); + } + } } diff --git a/crates/buzz-audit/src/hash.rs b/crates/buzz-audit/src/hash.rs index 8d6091a00c..7a09ed8eb0 100644 --- a/crates/buzz-audit/src/hash.rs +++ b/crates/buzz-audit/src/hash.rs @@ -269,4 +269,143 @@ mod tests { let b = serde_json::json!({"a": 2, "m": 3, "z": 1}); assert_eq!(canonical_json(&a).unwrap(), canonical_json(&b).unwrap()); } + + #[test] + fn canonical_json_sorts_nested_object_keys() { + // Key sorting must recurse into nested objects, not just the top level. + let a = serde_json::json!({"outer": {"z": 1, "a": 2}}); + let b = serde_json::json!({"outer": {"a": 2, "z": 1}}); + assert_eq!(canonical_json(&a).unwrap(), canonical_json(&b).unwrap()); + // Verify the nested keys are actually in sorted order in the output. + let rendered = canonical_json(&a).unwrap(); + let a_pos = rendered.find("\"a\"").unwrap(); + let z_pos = rendered.find("\"z\"").unwrap(); + assert!(a_pos < z_pos, "nested keys must be sorted: {rendered}"); + } + + #[test] + fn canonical_json_preserves_array_order() { + // Arrays are order-sensitive by design (they represent sequences, not + // sets). Two arrays with the same elements in different order must + // produce different canonical strings. + let a = serde_json::json!([1, 2, 3]); + let b = serde_json::json!([3, 2, 1]); + assert_ne!(canonical_json(&a).unwrap(), canonical_json(&b).unwrap()); + } + + #[test] + fn canonical_json_handles_empty_and_scalars() { + assert_eq!(canonical_json(&serde_json::json!({})).unwrap(), "{}"); + assert_eq!(canonical_json(&serde_json::json!([])).unwrap(), "[]"); + assert_eq!(canonical_json(&serde_json::json!(null)).unwrap(), "null"); + assert_eq!(canonical_json(&serde_json::json!(42)).unwrap(), "42"); + assert_eq!( + canonical_json(&serde_json::json!("hello")).unwrap(), + "\"hello\"" + ); + } + + #[test] + fn canonical_json_escapes_special_characters() { + // A key or value containing characters that need JSON escaping must + // survive canonicalization unchanged — the hash must not depend on + // whether serde or our manual serializer handles them. + let val = serde_json::json!({"path": "C:\\temp", "quote": "say \"hi\""}); + let rendered = canonical_json(&val).unwrap(); + // Round-trip: parsing the canonical form must reproduce the value. + let parsed: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + assert_eq!(val, parsed); + } + + #[test] + fn compute_hash_distinguishes_none_from_empty_for_object_id() { + // Same presence-tag invariant as actor_pubkey, applied to object_id. + let mut none = sample_entry(); + none.object_id = None; + let mut empty = sample_entry(); + empty.object_id = Some(String::new()); + assert_ne!(compute_hash(&none).unwrap(), compute_hash(&empty).unwrap()); + } + + #[test] + fn compute_hash_distinguishes_none_from_empty_for_detail() { + // `detail` is canonical_json'd; Null vs empty-object must differ. + let mut null_detail = sample_entry(); + null_detail.detail = serde_json::Value::Null; + let mut empty_obj = sample_entry(); + empty_obj.detail = serde_json::json!({}); + assert_ne!( + compute_hash(&null_detail).unwrap(), + compute_hash(&empty_obj).unwrap() + ); + } + + #[test] + fn compute_hash_distinguishes_detail_key_order() { + // The whole point of canonical_json: different key order, same hash. + let mut a = sample_entry(); + a.detail = serde_json::json!({"z": 1, "a": 2}); + let mut b = sample_entry(); + b.detail = serde_json::json!({"a": 2, "z": 1}); + assert_eq!(compute_hash(&a).unwrap(), compute_hash(&b).unwrap()); + } + + #[test] + fn compute_hash_distinguishes_detail_value() { + // Same keys, different values → different hash. + let mut a = sample_entry(); + a.detail = serde_json::json!({"key": "value1"}); + let mut b = sample_entry(); + b.detail = serde_json::json!({"key": "value2"}); + assert_ne!(compute_hash(&a).unwrap(), compute_hash(&b).unwrap()); + } + + #[test] + fn compute_hash_sensitive_to_created_at() { + // The field-sensitivity test covers seq/action/pubkey/etc. but not + // created_at directly. A one-nanosecond difference at storage precision + // (microsecond) must change the hash. + let base = sample_entry(); + let h0 = compute_hash(&base).unwrap(); + + let mut shifted = base.clone(); + shifted.created_at = base.created_at + chrono::Duration::microseconds(1); + assert_ne!(h0, compute_hash(&shifted).unwrap()); + } + + #[test] + fn compute_hash_invariant_under_sub_microsecond_jitter() { + // Truncation to microsecond precision means sub-microsecond jitter + // does not change the hash — the whole reason to_storage_precision exists. + let base = sample_entry(); + let h0 = compute_hash(&base).unwrap(); + + let mut jittered = base.clone(); + // Add 500 nanoseconds — below the microsecond floor, so it truncates away. + jittered.created_at = + to_storage_precision(base.created_at + chrono::Duration::nanoseconds(500)); + assert_eq!(h0, compute_hash(&jittered).unwrap()); + } + + #[test] + fn genesis_hash_is_all_zero() { + // The sentinel for "no previous entry" is documented as all-zero. + // Pin it: changing this constant invalidates every existing genesis entry. + assert_eq!(GENESIS_HASH, [0u8; 32]); + } + + #[test] + fn compute_hash_uses_genesis_for_none_prev() { + // An entry with prev_hash=None must hash identically to one whose + // prev_hash is explicitly the genesis sentinel — the None branch + // substitutes GENESIS_HASH into the hasher. + let mut none_prev = sample_entry(); + none_prev.prev_hash = None; + let mut genesis_prev = sample_entry(); + genesis_prev.prev_hash = Some(GENESIS_HASH.to_vec()); + assert_eq!( + compute_hash(&none_prev).unwrap(), + compute_hash(&genesis_prev).unwrap() + ); + } } diff --git a/crates/buzz-audit/src/service.rs b/crates/buzz-audit/src/service.rs index fa0e0fb443..aaeb9b8346 100644 --- a/crates/buzz-audit/src/service.rs +++ b/crates/buzz-audit/src/service.rs @@ -291,6 +291,56 @@ mod tests { ); } + /// `log_timestamp` must be idempotent under re-truncation: calling + /// `to_storage_precision` on an already-truncated value is a no-op. + /// This is the property that makes the write path safe — the value + /// hashed in memory is byte-identical to the one read back from Postgres. + #[test] + fn log_timestamp_is_at_storage_precision() { + let ts = log_timestamp(); + let re_truncated = to_storage_precision(ts); + assert_eq!( + ts, re_truncated, + "log_timestamp must already be at microsecond precision" + ); + } + + /// The lock key format is `{AUDIT_LOCK_NAMESPACE}{community_id}`. This + /// test pins the namespace prefix and the format so a rename doesn't + /// silently split a community's lock across two keys (which would break + /// chain serialization without any compile error). + #[test] + fn lock_key_format_is_namespaced_community_id() { + let community_id = Uuid::new_v4(); + let lock_key = format!("{AUDIT_LOCK_NAMESPACE}{community_id}"); + assert!( + lock_key.starts_with(AUDIT_LOCK_NAMESPACE), + "lock key must start with the audit namespace" + ); + assert!( + lock_key.ends_with(&community_id.to_string()), + "lock key must end with the community id" + ); + assert!( + !lock_key.is_empty(), + "lock key must not be empty (would cause hashtextextended collisions)" + ); + } + + /// Two different communities must produce different lock keys — the + /// per-community serialization invariant depends on it. + #[test] + fn lock_keys_differ_per_community() { + let a = Uuid::new_v4(); + let b = Uuid::new_v4(); + let key_a = format!("{AUDIT_LOCK_NAMESPACE}{a}"); + let key_b = format!("{AUDIT_LOCK_NAMESPACE}{b}"); + assert_ne!( + key_a, key_b, + "different communities must have different lock keys" + ); + } + /// A `community_id` known to exist in `communities` (FK target). Inserts a /// throwaway community row with a unique host and returns its id. async fn make_community(pool: &PgPool) -> Uuid {