use std::sync::{Arc, Mutex};
use apcore_cli::security::audit::AuditLogger;
use serde_json::json;
use tempfile::tempdir;
use tracing::Level;
use tracing_subscriber::layer::{Context, Layer, SubscriberExt};
use tracing_subscriber::Registry;
#[test]
fn test_audit_logger_disabled_no_file_written() {
let logger = AuditLogger::new(Some(std::path::PathBuf::from("/dev/null")));
let dir = tempdir().unwrap(); let unrelated_path = dir.path().join("should_not_exist.jsonl");
drop(logger);
assert!(
!unrelated_path.exists(),
"no file should be created in unrelated dir"
);
}
#[test]
fn test_audit_logger_writes_jsonl_record() {
let dir = tempdir().unwrap(); let log_path = dir.path().join("audit.jsonl");
let logger = AuditLogger::new(Some(log_path.clone()));
logger.log_execution("math.add", &json!({"a": 1}), "success", 0, 5);
let raw = std::fs::read_to_string(&log_path).expect("log file must exist after log_execution");
let entry: serde_json::Value =
serde_json::from_str(raw.trim()).expect("log line must be valid JSON");
assert_eq!(entry["module_id"], "math.add");
assert_eq!(entry["status"], "success");
assert_eq!(entry["exit_code"], 0);
assert_eq!(entry["duration_ms"], 5);
}
#[test]
fn test_audit_logger_appends_multiple_records() {
let dir = tempdir().unwrap(); let log_path = dir.path().join("audit.jsonl");
let logger = AuditLogger::new(Some(log_path.clone()));
for i in 0..3 {
logger.log_execution("math.add", &json!({"a": i}), "success", 0, i as u64);
}
let raw = std::fs::read_to_string(&log_path).expect("log file must exist");
let lines: Vec<&str> = raw.lines().collect();
assert_eq!(lines.len(), 3, "expected 3 log lines, got {}", lines.len());
}
#[test]
fn test_audit_logger_record_has_required_fields() {
let dir = tempdir().unwrap(); let log_path = dir.path().join("audit.jsonl");
let logger = AuditLogger::new(Some(log_path.clone()));
logger.log_execution("math.add", &json!({"a": 1}), "success", 0, 10);
let raw = std::fs::read_to_string(&log_path).expect("log file must exist");
let entry: serde_json::Value =
serde_json::from_str(raw.trim()).expect("log line must be valid JSON");
assert!(
entry["timestamp"].as_str().unwrap().ends_with('Z'),
"timestamp must be ISO 8601 UTC"
);
assert!(entry["user"].is_string(), "user field must be a string");
assert_eq!(entry["module_id"], "math.add");
assert!(
entry.get("input_salt").is_none(),
"input_salt must not be present in entry"
);
assert!(
entry["input_hash"]
.as_str()
.map(|s| s.len() == 64)
.unwrap_or(false),
"input_hash must be a 64-char hex SHA-256"
);
assert_eq!(entry["status"], "success");
assert!(entry["exit_code"].is_number(), "exit_code must be a number");
assert!(
entry["duration_ms"].is_number(),
"duration_ms must be a number"
);
}
#[derive(Default)]
struct WarnCaptureLayer {
events: Arc<Mutex<Vec<String>>>,
}
impl WarnCaptureLayer {
fn handle(&self) -> Arc<Mutex<Vec<String>>> {
Arc::clone(&self.events)
}
}
struct StringVisitor<'a>(&'a mut String);
impl tracing::field::Visit for StringVisitor<'_> {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
use std::fmt::Write as _;
let _ = write!(self.0, "{value:?}");
}
}
}
impl<S> Layer<S> for WarnCaptureLayer
where
S: tracing::Subscriber,
{
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
if *event.metadata().level() != Level::WARN {
return;
}
let mut buf = String::new();
event.record(&mut StringVisitor(&mut buf));
if let Ok(mut guard) = self.events.lock() {
guard.push(buf);
}
}
}
#[cfg(unix)]
#[test]
fn test_audit_logger_write_failure_warning_is_deduped() {
use std::os::unix::fs::PermissionsExt;
let layer = WarnCaptureLayer::default();
let captured = layer.handle();
let subscriber = Registry::default().with(layer);
let _guard = tracing::subscriber::set_default(subscriber);
let dir = tempdir().expect("tempdir");
let unwritable = dir.path().join("unwritable");
std::fs::create_dir(&unwritable).expect("mkdir");
std::fs::set_permissions(&unwritable, std::fs::Permissions::from_mode(0o500))
.expect("chmod 0500");
let log_path = unwritable.join("nested").join("audit.jsonl");
let logger = AuditLogger::new(Some(log_path));
logger.log_execution("m1", &json!({}), "success", 0, 0);
logger.log_execution("m2", &json!({}), "success", 0, 0);
logger.log_execution("m3", &json!({}), "success", 0, 0);
let _ = std::fs::set_permissions(&unwritable, std::fs::Permissions::from_mode(0o700));
let warns: Vec<String> = captured.lock().unwrap().clone();
let audit_warns: Vec<&String> = warns
.iter()
.filter(|m| m.contains("Could not write audit log"))
.collect();
assert_eq!(
audit_warns.len(),
1,
"expected exactly one write-failure warning across three failed log_execution() calls; got {}: {:?}",
audit_warns.len(),
warns
);
}