use crate::db::models::AuditEntry; use sha2::{Digest, Sha256}; fn compute_entry_hash(entry: &AuditEntry, prev_hash: &str) -> String { let payload = format!( "{}|{}|{}|{}|{}|{}|{}|{}", prev_hash, entry.id, entry.timestamp, entry.action, entry.entity_type, entry.entity_id, entry.user_id, entry.details ); format!("{:x}", Sha256::digest(payload.as_bytes())) } /// Write an audit event to the audit_log table. pub fn write_audit_event( conn: &rusqlite::Connection, action: &str, entity_type: &str, entity_id: &str, details: &str, ) -> anyhow::Result<()> { let entry = AuditEntry::new( action.to_string(), entity_type.to_string(), entity_id.to_string(), details.to_string(), ); let prev_hash: String = conn .prepare( "SELECT entry_hash FROM audit_log WHERE entry_hash <> '' ORDER BY timestamp DESC, id DESC LIMIT 1", )? .query_row([], |row| row.get(0)) .unwrap_or_default(); let entry_hash = compute_entry_hash(&entry, &prev_hash); conn.execute( "INSERT INTO audit_log (id, timestamp, action, entity_type, entity_id, user_id, details, prev_hash, entry_hash) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", rusqlite::params![ entry.id, entry.timestamp, entry.action, entry.entity_type, entry.entity_id, entry.user_id, entry.details, prev_hash, entry_hash, ], )?; Ok(()) } #[cfg(test)] mod tests { use super::*; fn setup_test_db() -> rusqlite::Connection { let conn = rusqlite::Connection::open_in_memory().unwrap(); conn.execute_batch( "CREATE TABLE audit_log ( id TEXT PRIMARY KEY, timestamp TEXT NOT NULL, action TEXT NOT NULL, entity_type TEXT NOT NULL DEFAULT '', entity_id TEXT NOT NULL DEFAULT '', user_id TEXT NOT NULL DEFAULT 'local', details TEXT NOT NULL DEFAULT '{}', prev_hash TEXT NOT NULL DEFAULT '', entry_hash TEXT NOT NULL DEFAULT '' );", ) .unwrap(); conn } #[test] fn test_write_audit_event_inserts_row() { let conn = setup_test_db(); write_audit_event( &conn, "test_action", "issue", "issue-123", r#"{"key":"val"}"#, ) .expect("should insert"); let count: i64 = conn .prepare("SELECT COUNT(*) FROM audit_log") .unwrap() .query_row([], |row| row.get(0)) .unwrap(); assert_eq!(count, 1); } #[test] fn test_write_audit_event_correct_fields() { let conn = setup_test_db(); write_audit_event(&conn, "create_issue", "issue", "abc-999", "details here") .expect("should insert"); let (action, entity_type, entity_id, user_id): (String, String, String, String) = conn .prepare("SELECT action, entity_type, entity_id, user_id FROM audit_log LIMIT 1") .unwrap() .query_row([], |row| { Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) }) .unwrap(); assert_eq!(action, "create_issue"); assert_eq!(entity_type, "issue"); assert_eq!(entity_id, "abc-999"); assert_eq!(user_id, "local"); } #[test] fn test_write_multiple_events() { let conn = setup_test_db(); for i in 0..5 { write_audit_event( &conn, &format!("action_{i}"), "test", &format!("id_{i}"), "{}", ) .unwrap(); } let count: i64 = conn .prepare("SELECT COUNT(*) FROM audit_log") .unwrap() .query_row([], |row| row.get(0)) .unwrap(); assert_eq!(count, 5); } #[test] fn test_write_audit_event_generates_unique_ids() { let conn = setup_test_db(); write_audit_event(&conn, "a", "t", "1", "{}").unwrap(); write_audit_event(&conn, "b", "t", "2", "{}").unwrap(); let mut stmt = conn.prepare("SELECT id FROM audit_log").unwrap(); let ids: Vec = stmt .query_map([], |row| row.get(0)) .unwrap() .filter_map(|r| r.ok()) .collect(); assert_eq!(ids.len(), 2); assert_ne!(ids[0], ids[1]); } #[test] fn test_write_audit_event_hash_chain_links_entries() { let conn = setup_test_db(); write_audit_event(&conn, "first", "issue", "1", "{}").unwrap(); write_audit_event(&conn, "second", "issue", "2", "{}").unwrap(); let mut stmt = conn .prepare("SELECT prev_hash, entry_hash FROM audit_log ORDER BY timestamp ASC, id ASC") .unwrap(); let rows: Vec<(String, String)> = stmt .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) .unwrap() .collect::, _>>() .unwrap(); assert_eq!(rows.len(), 2); assert_eq!(rows[0].0, ""); assert!(!rows[0].1.is_empty()); assert_eq!(rows[1].0, rows[0].1); assert!(!rows[1].1.is_empty()); } }