use std::collections::HashMap;
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuditAction {
Create,
Update,
Delete,
BulkDelete,
Export,
Import,
}
impl fmt::Display for AuditAction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AuditAction::Create => write!(f, "CREATE"),
AuditAction::Update => write!(f, "UPDATE"),
AuditAction::Delete => write!(f, "DELETE"),
AuditAction::BulkDelete => write!(f, "BULK_DELETE"),
AuditAction::Export => write!(f, "EXPORT"),
AuditAction::Import => write!(f, "IMPORT"),
}
}
}
#[derive(Debug, Clone)]
pub struct AuditEntry {
pub timestamp: String,
pub user_id: String,
pub action: AuditAction,
pub model_name: String,
pub record_id: Option<String>,
pub changed_fields: Option<Vec<String>>,
pub success: bool,
pub affected_count: Option<u64>,
}
impl fmt::Display for AuditEntry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"[ADMIN_AUDIT] {} user={} action={} model={}",
self.timestamp, self.user_id, self.action, self.model_name,
)?;
if let Some(ref id) = self.record_id {
write!(f, " record_id={}", id)?;
}
if let Some(ref fields) = self.changed_fields {
write!(f, " changed_fields=[{}]", fields.join(", "))?;
}
if let Some(count) = self.affected_count {
write!(f, " affected={}", count)?;
}
write!(f, " success={}", self.success)
}
}
pub fn log_create(
user_id: &str,
model_name: &str,
data: &HashMap<String, serde_json::Value>,
success: bool,
) {
let entry = AuditEntry {
timestamp: chrono::Utc::now().to_rfc3339(),
user_id: user_id.to_string(),
action: AuditAction::Create,
model_name: model_name.to_string(),
record_id: None,
changed_fields: Some(data.keys().cloned().collect()),
success,
affected_count: if success { Some(1) } else { None },
};
emit_audit_log(&entry);
}
pub fn log_update(
user_id: &str,
model_name: &str,
record_id: &str,
data: &HashMap<String, serde_json::Value>,
success: bool,
) {
let entry = AuditEntry {
timestamp: chrono::Utc::now().to_rfc3339(),
user_id: user_id.to_string(),
action: AuditAction::Update,
model_name: model_name.to_string(),
record_id: Some(record_id.to_string()),
changed_fields: Some(data.keys().cloned().collect()),
success,
affected_count: if success { Some(1) } else { None },
};
emit_audit_log(&entry);
}
pub fn log_delete(user_id: &str, model_name: &str, record_id: &str, success: bool) {
let entry = AuditEntry {
timestamp: chrono::Utc::now().to_rfc3339(),
user_id: user_id.to_string(),
action: AuditAction::Delete,
model_name: model_name.to_string(),
record_id: Some(record_id.to_string()),
changed_fields: None,
success,
affected_count: if success { Some(1) } else { None },
};
emit_audit_log(&entry);
}
pub fn log_bulk_delete(
user_id: &str,
model_name: &str,
record_ids: &[String],
affected: u64,
success: bool,
) {
let entry = AuditEntry {
timestamp: chrono::Utc::now().to_rfc3339(),
user_id: user_id.to_string(),
action: AuditAction::BulkDelete,
model_name: model_name.to_string(),
record_id: Some(
serde_json::to_string(&record_ids).unwrap_or_else(|_| record_ids.join(",")),
),
changed_fields: None,
success,
affected_count: Some(affected),
};
emit_audit_log(&entry);
}
#[cfg(server)]
fn emit_audit_log(entry: &AuditEntry) {
if entry.success {
tracing::info!("{}", entry);
} else {
tracing::warn!("{}", entry);
}
}
#[cfg(client)]
fn emit_audit_log(_entry: &AuditEntry) {}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
fn test_audit_action_create_display() {
assert_eq!(AuditAction::Create.to_string(), "CREATE");
}
#[rstest]
fn test_audit_action_update_display() {
assert_eq!(AuditAction::Update.to_string(), "UPDATE");
}
#[rstest]
fn test_audit_action_delete_display() {
assert_eq!(AuditAction::Delete.to_string(), "DELETE");
}
#[rstest]
fn test_audit_action_bulk_delete_display() {
assert_eq!(AuditAction::BulkDelete.to_string(), "BULK_DELETE");
}
#[rstest]
fn test_audit_action_export_display() {
assert_eq!(AuditAction::Export.to_string(), "EXPORT");
}
#[rstest]
fn test_audit_action_import_display() {
assert_eq!(AuditAction::Import.to_string(), "IMPORT");
}
#[rstest]
fn test_audit_entry_display_create() {
let entry = AuditEntry {
timestamp: "2024-01-01T00:00:00Z".to_string(),
user_id: "user-42".to_string(),
action: AuditAction::Create,
model_name: "User".to_string(),
record_id: None,
changed_fields: Some(vec!["name".to_string(), "email".to_string()]),
success: true,
affected_count: Some(1),
};
let output = entry.to_string();
assert!(output.contains("[ADMIN_AUDIT]"));
assert!(output.contains("user=user-42"));
assert!(output.contains("action=CREATE"));
assert!(output.contains("model=User"));
assert!(output.contains("changed_fields=[name, email]"));
assert!(output.contains("success=true"));
}
#[rstest]
fn test_audit_entry_display_delete() {
let entry = AuditEntry {
timestamp: "2024-01-01T00:00:00Z".to_string(),
user_id: "admin-1".to_string(),
action: AuditAction::Delete,
model_name: "Post".to_string(),
record_id: Some("123".to_string()),
changed_fields: None,
success: true,
affected_count: Some(1),
};
let output = entry.to_string();
assert!(output.contains("action=DELETE"));
assert!(output.contains("model=Post"));
assert!(output.contains("record_id=123"));
assert!(output.contains("affected=1"));
}
#[rstest]
fn test_audit_entry_display_bulk_delete() {
let entry = AuditEntry {
timestamp: "2024-01-01T00:00:00Z".to_string(),
user_id: "admin-1".to_string(),
action: AuditAction::BulkDelete,
model_name: "Comment".to_string(),
record_id: Some("[\"1\",\"2\",\"3\"]".to_string()),
changed_fields: None,
success: true,
affected_count: Some(3),
};
let output = entry.to_string();
assert!(output.contains("action=BULK_DELETE"));
assert!(output.contains("record_id=[\"1\",\"2\",\"3\"]"));
assert!(output.contains("affected=3"));
}
#[rstest]
fn test_audit_entry_display_failed_operation() {
let entry = AuditEntry {
timestamp: "2024-01-01T00:00:00Z".to_string(),
user_id: "user-99".to_string(),
action: AuditAction::Update,
model_name: "User".to_string(),
record_id: Some("456".to_string()),
changed_fields: Some(vec!["password".to_string()]),
success: false,
affected_count: None,
};
let output = entry.to_string();
assert!(output.contains("success=false"));
assert!(output.contains("action=UPDATE"));
}
#[rstest]
fn test_log_create_constructs_correct_entry() {
let mut data = HashMap::new();
data.insert("name".to_string(), serde_json::json!("Alice"));
data.insert("email".to_string(), serde_json::json!("alice@example.com"));
log_create("user-42", "User", &data, true);
}
#[rstest]
fn test_log_update_constructs_correct_entry() {
let mut data = HashMap::new();
data.insert("email".to_string(), serde_json::json!("new@example.com"));
log_update("user-42", "User", "123", &data, true);
}
#[rstest]
fn test_log_delete_constructs_correct_entry() {
log_delete("user-42", "User", "123", true);
}
#[rstest]
fn test_log_bulk_delete_constructs_correct_entry() {
let ids = vec!["1".to_string(), "2".to_string(), "3".to_string()];
let entry = AuditEntry {
timestamp: chrono::Utc::now().to_rfc3339(),
user_id: "user-42".to_string(),
action: AuditAction::BulkDelete,
model_name: "User".to_string(),
record_id: Some(serde_json::to_string(&ids).unwrap_or_else(|_| ids.join(","))),
changed_fields: None,
success: true,
affected_count: Some(3),
};
assert_eq!(entry.record_id, Some("[\"1\",\"2\",\"3\"]".to_string()));
assert_eq!(entry.action, AuditAction::BulkDelete);
assert!(entry.success);
}
#[rstest]
fn test_log_create_with_failure() {
let data = HashMap::new();
log_create("user-42", "User", &data, false);
}
#[rstest]
fn test_audit_action_equality() {
assert_eq!(AuditAction::Create, AuditAction::Create);
assert_ne!(AuditAction::Create, AuditAction::Delete);
}
#[rstest]
fn test_audit_action_clone() {
let action = AuditAction::Update;
let cloned = action;
assert_eq!(action, cloned);
}
}