use adminx_core::audit::{set_auditor, AuditEntry, Auditor};
use adminx_core::prelude::*;
use adminx_core::storage::{CreateOutcome, ListPage, QueryOptions, StorageError};
use async_trait::async_trait;
use serde_json::{json, Map, Value};
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct Recorder {
entries: Arc<Mutex<Vec<AuditEntry>>>,
}
#[async_trait]
impl Auditor for Recorder {
async fn record(&self, entry: AuditEntry) -> Result<(), StorageError> {
self.entries.lock().unwrap().push(entry);
Ok(())
}
}
fn stored_row() -> Value {
json!({"id": 1, "name": "row", "views": 5})
}
struct Mock;
#[async_trait]
impl Storage for Mock {
async fn list(&self, _t: &str, _o: &QueryOptions) -> Result<ListPage, StorageError> {
Ok(ListPage { rows: vec![stored_row()], total: 1 })
}
async fn get(&self, _t: &str, _pk: &str, _id: &str) -> Result<Option<Value>, StorageError> {
Ok(Some(stored_row()))
}
async fn create(&self, _t: &str, _d: Map<String, Value>) -> Result<CreateOutcome, StorageError> {
Ok(CreateOutcome { last_insert_id: Some("77".into()) })
}
async fn update(&self, _t: &str, _pk: &str, _i: &str, _d: Map<String, Value>) -> Result<u64, StorageError> {
Ok(1)
}
async fn delete(&self, _t: &str, _pk: &str, _i: &str, _s: bool) -> Result<u64, StorageError> {
Ok(1)
}
async fn health(&self) -> bool {
true
}
}
#[derive(Clone)]
struct Widget;
#[async_trait]
impl Resource for Widget {
fn resource_name(&self) -> &'static str {
"Widgets"
}
fn base_path(&self) -> &'static str {
"widgets"
}
fn table_name(&self) -> &'static str {
"widgets"
}
fn clone_box(&self) -> Box<dyn Resource> {
Box::new(self.clone())
}
fn permit_keys(&self) -> Vec<&'static str> {
vec!["name", "views"]
}
}
#[tokio::test]
async fn crud_records_who_changed_what() {
let entries = Arc::new(Mutex::new(Vec::new()));
set_auditor(Box::new(Recorder { entries: entries.clone() }));
set_storage(Box::new(Mock));
configure_auth(AuthConfig {
jwt_secret: "test-secret-key-that-is-long-enough-32b".into(),
token_ttl_secs: 3600,
admin_table: "adminx_users".into(),
secure_cookie: false,
});
let ctx = ReqCtx::new().with_mount("/adminx").with_claims(Claims {
sub: "9".into(),
email: "admin@example.com".into(),
role: "admin".into(),
roles: vec!["admin".into()],
mfa: "ok".into(),
});
let w = Widget;
let drain = || -> Vec<AuditEntry> {
let mut e = entries.lock().unwrap();
let seen = e.clone();
e.clear();
seen
};
w.create(&ctx, json!({"name": "fresh"})).await;
let seen = drain();
assert_eq!(seen.len(), 1, "create should record exactly one entry");
let e = &seen[0];
assert_eq!(e.item_type, "widgets", "item_type is the resource base_path");
assert_eq!(e.item_id, "77", "item_id comes from last_insert_id");
assert_eq!(e.event.as_str(), "create");
assert_eq!(e.whodunnit.as_deref(), Some("9"));
assert_eq!(e.whodunnit_email.as_deref(), Some("admin@example.com"));
assert_eq!(e.changes["name"], json!([Value::Null, "fresh"]));
w.update(&ctx, "1", json!({"name": "changed", "views": 5})).await;
let seen = drain();
assert_eq!(seen.len(), 1);
let e = &seen[0];
assert_eq!(e.item_id, "1");
assert_eq!(e.event.as_str(), "update");
assert_eq!(e.changes["name"], json!(["row", "changed"]));
assert_eq!(
e.changes.len(),
1,
"`views` was submitted unchanged and must not appear: {:?}",
e.changes
);
w.update(&ctx, "1", json!({"name": "row", "views": 5})).await;
assert!(drain().is_empty(), "a no-op save should record nothing");
w.delete(&ctx, "1").await;
let seen = drain();
assert_eq!(seen.len(), 1);
let e = &seen[0];
assert_eq!(e.event.as_str(), "delete");
assert_eq!(e.changes["name"], json!(["row", Value::Null]));
assert_eq!(
e.changes["views"],
json!([5, Value::Null]),
"delete captures every stored column, not just the writable ones"
);
w.list(&ctx).await;
w.get(&ctx, "1").await;
w.view_page(&ctx, "1").await;
assert!(drain().is_empty(), "reads must not produce audit entries");
}
fn _assert_clone(e: AuditEntry) -> AuditEntry {
e.clone()
}