1use std::collections::HashMap;
15use std::fmt;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum AuditAction {
20 Create,
22 Update,
24 Delete,
26 BulkDelete,
28 Export,
30 Import,
32}
33
34impl fmt::Display for AuditAction {
35 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36 match self {
37 AuditAction::Create => write!(f, "CREATE"),
38 AuditAction::Update => write!(f, "UPDATE"),
39 AuditAction::Delete => write!(f, "DELETE"),
40 AuditAction::BulkDelete => write!(f, "BULK_DELETE"),
41 AuditAction::Export => write!(f, "EXPORT"),
42 AuditAction::Import => write!(f, "IMPORT"),
43 }
44 }
45}
46
47#[derive(Debug, Clone)]
49pub struct AuditEntry {
50 pub timestamp: String,
52 pub user_id: String,
54 pub action: AuditAction,
56 pub model_name: String,
58 pub record_id: Option<String>,
60 pub changed_fields: Option<Vec<String>>,
62 pub success: bool,
64 pub affected_count: Option<u64>,
66}
67
68impl fmt::Display for AuditEntry {
69 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70 write!(
71 f,
72 "[ADMIN_AUDIT] {} user={} action={} model={}",
73 self.timestamp, self.user_id, self.action, self.model_name,
74 )?;
75
76 if let Some(ref id) = self.record_id {
77 write!(f, " record_id={}", id)?;
78 }
79
80 if let Some(ref fields) = self.changed_fields {
81 write!(f, " changed_fields=[{}]", fields.join(", "))?;
82 }
83
84 if let Some(count) = self.affected_count {
85 write!(f, " affected={}", count)?;
86 }
87
88 write!(f, " success={}", self.success)
89 }
90}
91
92pub fn log_create(
114 user_id: &str,
115 model_name: &str,
116 data: &HashMap<String, serde_json::Value>,
117 success: bool,
118) {
119 let entry = AuditEntry {
120 timestamp: chrono::Utc::now().to_rfc3339(),
121 user_id: user_id.to_string(),
122 action: AuditAction::Create,
123 model_name: model_name.to_string(),
124 record_id: None,
125 changed_fields: Some(data.keys().cloned().collect()),
126 success,
127 affected_count: if success { Some(1) } else { None },
128 };
129
130 emit_audit_log(&entry);
131}
132
133pub fn log_update(
157 user_id: &str,
158 model_name: &str,
159 record_id: &str,
160 data: &HashMap<String, serde_json::Value>,
161 success: bool,
162) {
163 let entry = AuditEntry {
164 timestamp: chrono::Utc::now().to_rfc3339(),
165 user_id: user_id.to_string(),
166 action: AuditAction::Update,
167 model_name: model_name.to_string(),
168 record_id: Some(record_id.to_string()),
169 changed_fields: Some(data.keys().cloned().collect()),
170 success,
171 affected_count: if success { Some(1) } else { None },
172 };
173
174 emit_audit_log(&entry);
175}
176
177pub fn log_delete(user_id: &str, model_name: &str, record_id: &str, success: bool) {
194 let entry = AuditEntry {
195 timestamp: chrono::Utc::now().to_rfc3339(),
196 user_id: user_id.to_string(),
197 action: AuditAction::Delete,
198 model_name: model_name.to_string(),
199 record_id: Some(record_id.to_string()),
200 changed_fields: None,
201 success,
202 affected_count: if success { Some(1) } else { None },
203 };
204
205 emit_audit_log(&entry);
206}
207
208pub fn log_bulk_delete(
226 user_id: &str,
227 model_name: &str,
228 record_ids: &[String],
229 affected: u64,
230 success: bool,
231) {
232 let entry = AuditEntry {
233 timestamp: chrono::Utc::now().to_rfc3339(),
234 user_id: user_id.to_string(),
235 action: AuditAction::BulkDelete,
236 model_name: model_name.to_string(),
237 record_id: Some(
238 serde_json::to_string(&record_ids).unwrap_or_else(|_| record_ids.join(",")),
239 ),
240 changed_fields: None,
241 success,
242 affected_count: Some(affected),
243 };
244
245 emit_audit_log(&entry);
246}
247
248#[cfg(server)]
252fn emit_audit_log(entry: &AuditEntry) {
253 if entry.success {
254 tracing::info!("{}", entry);
255 } else {
256 tracing::warn!("{}", entry);
257 }
258}
259
260#[cfg(client)]
262fn emit_audit_log(_entry: &AuditEntry) {}
263
264#[cfg(all(test, server))]
265mod tests {
266 use super::*;
267 use rstest::rstest;
268
269 #[rstest]
274 fn test_audit_action_create_display() {
275 assert_eq!(AuditAction::Create.to_string(), "CREATE");
277 }
278
279 #[rstest]
280 fn test_audit_action_update_display() {
281 assert_eq!(AuditAction::Update.to_string(), "UPDATE");
283 }
284
285 #[rstest]
286 fn test_audit_action_delete_display() {
287 assert_eq!(AuditAction::Delete.to_string(), "DELETE");
289 }
290
291 #[rstest]
292 fn test_audit_action_bulk_delete_display() {
293 assert_eq!(AuditAction::BulkDelete.to_string(), "BULK_DELETE");
295 }
296
297 #[rstest]
298 fn test_audit_action_export_display() {
299 assert_eq!(AuditAction::Export.to_string(), "EXPORT");
301 }
302
303 #[rstest]
304 fn test_audit_action_import_display() {
305 assert_eq!(AuditAction::Import.to_string(), "IMPORT");
307 }
308
309 #[rstest]
314 fn test_audit_entry_display_create() {
315 let entry = AuditEntry {
317 timestamp: "2024-01-01T00:00:00Z".to_string(),
318 user_id: "user-42".to_string(),
319 action: AuditAction::Create,
320 model_name: "User".to_string(),
321 record_id: None,
322 changed_fields: Some(vec!["name".to_string(), "email".to_string()]),
323 success: true,
324 affected_count: Some(1),
325 };
326
327 let output = entry.to_string();
329
330 assert!(output.contains("[ADMIN_AUDIT]"));
332 assert!(output.contains("user=user-42"));
333 assert!(output.contains("action=CREATE"));
334 assert!(output.contains("model=User"));
335 assert!(output.contains("changed_fields=[name, email]"));
336 assert!(output.contains("success=true"));
337 }
338
339 #[rstest]
340 fn test_audit_entry_display_delete() {
341 let entry = AuditEntry {
343 timestamp: "2024-01-01T00:00:00Z".to_string(),
344 user_id: "admin-1".to_string(),
345 action: AuditAction::Delete,
346 model_name: "Post".to_string(),
347 record_id: Some("123".to_string()),
348 changed_fields: None,
349 success: true,
350 affected_count: Some(1),
351 };
352
353 let output = entry.to_string();
355
356 assert!(output.contains("action=DELETE"));
358 assert!(output.contains("model=Post"));
359 assert!(output.contains("record_id=123"));
360 assert!(output.contains("affected=1"));
361 }
362
363 #[rstest]
364 fn test_audit_entry_display_bulk_delete() {
365 let entry = AuditEntry {
367 timestamp: "2024-01-01T00:00:00Z".to_string(),
368 user_id: "admin-1".to_string(),
369 action: AuditAction::BulkDelete,
370 model_name: "Comment".to_string(),
371 record_id: Some("[\"1\",\"2\",\"3\"]".to_string()),
372 changed_fields: None,
373 success: true,
374 affected_count: Some(3),
375 };
376
377 let output = entry.to_string();
379
380 assert!(output.contains("action=BULK_DELETE"));
382 assert!(output.contains("record_id=[\"1\",\"2\",\"3\"]"));
383 assert!(output.contains("affected=3"));
384 }
385
386 #[rstest]
387 fn test_audit_entry_display_failed_operation() {
388 let entry = AuditEntry {
390 timestamp: "2024-01-01T00:00:00Z".to_string(),
391 user_id: "user-99".to_string(),
392 action: AuditAction::Update,
393 model_name: "User".to_string(),
394 record_id: Some("456".to_string()),
395 changed_fields: Some(vec!["password".to_string()]),
396 success: false,
397 affected_count: None,
398 };
399
400 let output = entry.to_string();
402
403 assert!(output.contains("success=false"));
405 assert!(output.contains("action=UPDATE"));
406 }
407
408 #[rstest]
413 fn test_log_create_constructs_correct_entry() {
414 let mut data = HashMap::new();
416 data.insert("name".to_string(), serde_json::json!("Alice"));
417 data.insert("email".to_string(), serde_json::json!("alice@example.com"));
418
419 log_create("user-42", "User", &data, true);
421 }
422
423 #[rstest]
424 fn test_log_update_constructs_correct_entry() {
425 let mut data = HashMap::new();
427 data.insert("email".to_string(), serde_json::json!("new@example.com"));
428
429 log_update("user-42", "User", "123", &data, true);
431 }
432
433 #[rstest]
434 fn test_log_delete_constructs_correct_entry() {
435 log_delete("user-42", "User", "123", true);
437 }
438
439 #[rstest]
440 fn test_log_bulk_delete_constructs_correct_entry() {
441 let ids = vec!["1".to_string(), "2".to_string(), "3".to_string()];
443
444 let entry = AuditEntry {
447 timestamp: chrono::Utc::now().to_rfc3339(),
448 user_id: "user-42".to_string(),
449 action: AuditAction::BulkDelete,
450 model_name: "User".to_string(),
451 record_id: Some(serde_json::to_string(&ids).unwrap_or_else(|_| ids.join(","))),
452 changed_fields: None,
453 success: true,
454 affected_count: Some(3),
455 };
456
457 assert_eq!(entry.record_id, Some("[\"1\",\"2\",\"3\"]".to_string()));
459 assert_eq!(entry.action, AuditAction::BulkDelete);
460 assert!(entry.success);
461 }
462
463 #[rstest]
464 fn test_log_create_with_failure() {
465 let data = HashMap::new();
467
468 log_create("user-42", "User", &data, false);
470 }
471
472 #[rstest]
477 fn test_audit_action_equality() {
478 assert_eq!(AuditAction::Create, AuditAction::Create);
480 assert_ne!(AuditAction::Create, AuditAction::Delete);
481 }
482
483 #[rstest]
484 fn test_audit_action_clone() {
485 let action = AuditAction::Update;
487
488 let cloned = action;
490
491 assert_eq!(action, cloned);
493 }
494}