1use adminx_core::audit::{AuditEntry, Auditor};
8use adminx_core::storage::{storage, FilterClause, FilterOp, QueryOptions, StorageError};
9use async_trait::async_trait;
10use serde_json::{Map, Value};
11
12pub const TABLE: &str = "adminx_audit_versions";
15
16pub struct StorageAuditor {
18 strict: bool,
19}
20
21impl StorageAuditor {
22 pub fn new(strict: bool) -> Self {
23 Self { strict }
24 }
25
26 fn row(entry: &AuditEntry) -> Map<String, Value> {
33 let mut row = Map::new();
34 row.insert("item_type".into(), Value::String(entry.item_type.clone()));
35 row.insert("item_id".into(), Value::String(entry.item_id.clone()));
36 row.insert(
37 "event".into(),
38 Value::String(entry.event.as_str().to_string()),
39 );
40 row.insert(
41 "whodunnit".into(),
42 entry
43 .whodunnit
44 .clone()
45 .map(Value::String)
46 .unwrap_or(Value::Null),
47 );
48 row.insert(
49 "whodunnit_email".into(),
50 entry
51 .whodunnit_email
52 .clone()
53 .map(Value::String)
54 .unwrap_or(Value::Null),
55 );
56 row.insert(
57 "changes".into(),
58 Value::String(
59 serde_json::to_string(&Value::Object(entry.changes.clone()))
60 .unwrap_or_else(|_| "{}".into()),
61 ),
62 );
63 row.insert(
66 "created_at".into(),
67 Value::String(chrono::Utc::now().to_rfc3339()),
68 );
69 row
70 }
71}
72
73#[async_trait]
74impl Auditor for StorageAuditor {
75 async fn record(&self, entry: AuditEntry) -> Result<(), StorageError> {
76 storage().create(TABLE, Self::row(&entry)).await?;
77 Ok(())
78 }
79
80 async fn history(
83 &self,
84 item_type: &str,
85 item_id: &str,
86 limit: u64,
87 ) -> Result<Vec<Value>, StorageError> {
88 let opts = QueryOptions {
89 page: 1,
90 per_page: limit,
91 sort_by: Some("id".to_string()),
92 sort_desc: true,
93 filters: vec![
94 FilterClause {
95 field: "item_type".into(),
96 op: FilterOp::Eq,
97 value: item_type.to_string(),
98 },
99 FilterClause {
100 field: "item_id".into(),
101 op: FilterOp::Eq,
102 value: item_id.to_string(),
103 },
104 ],
105 };
106 Ok(storage().list(TABLE, &opts).await?.rows)
107 }
108
109 fn strict(&self) -> bool {
110 self.strict
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use adminx_core::audit::Event;
118 use adminx_core::request::{Claims, ReqCtx};
119 use serde_json::json;
120
121 fn entry() -> AuditEntry {
122 let ctx = ReqCtx::new().with_claims(Claims {
123 sub: "7".into(),
124 email: "admin@example.com".into(),
125 ..Default::default()
126 });
127 let mut changes = Map::new();
128 changes.insert("title".into(), json!(["Old", "New"]));
129 AuditEntry::new(&ctx, "posts", "42", Event::Update, changes)
130 }
131
132 #[test]
133 fn row_carries_the_actor_and_the_target() {
134 let row = StorageAuditor::row(&entry());
135 assert_eq!(row["item_type"], json!("posts"));
136 assert_eq!(row["item_id"], json!("42"));
137 assert_eq!(row["event"], json!("update"));
138 assert_eq!(row["whodunnit"], json!("7"));
139 assert_eq!(row["whodunnit_email"], json!("admin@example.com"));
140 }
141
142 #[test]
143 fn changes_serialize_to_a_json_string() {
144 let row = StorageAuditor::row(&entry());
145 let text = row["changes"].as_str().expect("changes must be a string");
146 let parsed: Value = serde_json::from_str(text).expect("must be valid JSON");
147 assert_eq!(parsed["title"], json!(["Old", "New"]));
148 }
149
150 #[test]
151 fn an_anonymous_actor_is_null_not_empty() {
152 let ctx = ReqCtx::new();
155 let e = AuditEntry::new(&ctx, "posts", "1", Event::Create, Map::new());
156 let row = StorageAuditor::row(&e);
157 assert_eq!(row["whodunnit"], Value::Null);
158 assert_eq!(row["whodunnit_email"], Value::Null);
159 }
160
161 #[test]
162 fn created_at_is_rfc3339() {
163 let row = StorageAuditor::row(&entry());
164 let at = row["created_at"].as_str().unwrap();
165 assert!(
166 chrono::DateTime::parse_from_rfc3339(at).is_ok(),
167 "not RFC3339: {at}"
168 );
169 }
170}