use std::sync::Once;
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use http_body_util::BodyExt;
use serde_json::Value;
use sloc_web::make_test_router_with_key;
use tower::ServiceExt;
async fn fail_auth() {
let resp = make_test_router_with_key("the-real-key")
.oneshot(
Request::get("/api-docs")
.header(header::ACCEPT, "application/json")
.header(header::AUTHORIZATION, "Bearer definitely-wrong-key")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_ne!(resp.status(), StatusCode::OK);
let _ = resp.into_body().collect().await;
}
fn records(log: &std::path::Path) -> Vec<Value> {
std::fs::read_to_string(log)
.expect("audit log readable")
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| serde_json::from_str(l).expect("line is JSON"))
.collect()
}
fn mac(rec: &Value) -> String {
rec.get("mac")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned()
}
#[tokio::test]
async fn failed_auth_appends_a_chained_record_seeded_from_the_existing_log() {
static INIT: Once = Once::new();
let dir = std::env::temp_dir().join(format!("sloc-audit-chain-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let log = dir.join("audit.jsonl");
let seed_mac = "seedmac0000000000000000000000000000000000000000000000000000000001";
std::fs::write(
&log,
format!("{{\"ts\":\"2026-07-20T00:00:00+00:00\",\"event\":\"seed\",\"outcome\":\"success\",\"mac\":\"{seed_mac}\"}}\n"),
)
.unwrap();
INIT.call_once(|| {
unsafe { std::env::set_var("SLOC_AUDIT_LOG", log.to_string_lossy().to_string()) };
unsafe { std::env::set_var("SLOC_AUDIT_HMAC_KEY", "chain-test-key") };
});
fail_auth().await;
let after_first = records(&log);
assert!(after_first.len() >= 2, "first record appended");
assert_eq!(
after_first[1].get("prev").and_then(Value::as_str),
Some(seed_mac),
"new record's prev must link to the recovered chain tip"
);
assert!(
!mac(&after_first[1]).is_empty(),
"record carries a computed MAC"
);
fail_auth().await;
let after_second = records(&log);
assert_eq!(after_second.len(), 3, "second record appended");
assert_eq!(
after_second[2].get("prev").and_then(Value::as_str),
Some(mac(&after_second[1]).as_str()),
"second record must chain off the in-memory tip"
);
let _ = std::fs::remove_dir_all(&dir);
}