Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions crates/buzz-audit/src/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,50 @@ mod tests {
fn unknown_action_returns_err() {
assert!("totally_bogus".parse::<AuditAction>().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);
}
}
}
139 changes: 139 additions & 0 deletions crates/buzz-audit/src/hash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
);
}
}
50 changes: 50 additions & 0 deletions crates/buzz-audit/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down