use std::sync::Mutex;
use crate::domain::ports::{AuditEvent, AuditSink};
#[derive(Default)]
pub struct InMemoryAuditSink {
events: Mutex<Vec<AuditEvent>>,
}
impl InMemoryAuditSink {
pub fn new() -> Self {
Self::default()
}
pub fn events(&self) -> Vec<AuditEvent> {
self.events.lock().expect("mutex poisoned").clone()
}
pub fn drain(&self) -> Vec<AuditEvent> {
let mut guard = self.events.lock().expect("mutex poisoned");
std::mem::take(&mut *guard)
}
}
impl AuditSink for InMemoryAuditSink {
fn record(&self, event: AuditEvent) {
self.events.lock().expect("mutex poisoned").push(event);
}
}
pub struct TracingAuditSink;
impl AuditSink for TracingAuditSink {
fn record(&self, event: AuditEvent) {
let action = format!("{:?}", event.action);
let outcome = format!("{:?}", event.outcome).to_lowercase();
let actor = event.actor.as_deref().unwrap_or("-");
let reason = event.reason.as_deref().unwrap_or("");
tracing::info!(
audit.timestamp = %event.timestamp.to_rfc3339(),
audit.actor = %actor,
audit.subject = %event.subject,
audit.action = %action,
audit.outcome = %outcome,
audit.reason = %reason,
"audit event"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::ports::{AuditAction, AuditOutcome};
#[test]
fn in_memory_sink_records_events() {
let sink = InMemoryAuditSink::new();
let ev = AuditEvent::success(Some("user-1".into()), "jti-abc", AuditAction::TokenIssued);
sink.record(ev);
let events = sink.events();
assert_eq!(events.len(), 1);
assert_eq!(events[0].action, AuditAction::TokenIssued);
assert_eq!(events[0].outcome, AuditOutcome::Success);
assert_eq!(events[0].subject, "jti-abc");
assert_eq!(events[0].actor.as_deref(), Some("user-1"));
assert!(events[0].reason.is_none());
}
#[test]
fn in_memory_sink_records_failure_with_reason() {
let sink = InMemoryAuditSink::new();
let ev = AuditEvent::failure(None, "sub-xyz", AuditAction::TokenRejected, "token expired");
sink.record(ev);
let events = sink.events();
assert_eq!(events[0].outcome, AuditOutcome::Failure);
assert_eq!(events[0].reason.as_deref(), Some("token expired"));
assert!(events[0].actor.is_none());
}
#[test]
fn in_memory_sink_drain_clears_events() {
let sink = InMemoryAuditSink::new();
sink.record(AuditEvent::success(None, "s", AuditAction::VaultRead));
sink.record(AuditEvent::success(None, "s", AuditAction::VaultWrite));
let drained = sink.drain();
assert_eq!(drained.len(), 2);
assert!(sink.events().is_empty(), "drain must clear the buffer");
}
#[test]
fn in_memory_sink_multiple_events_in_order() {
let sink = InMemoryAuditSink::new();
let actions =
[AuditAction::TokenIssued, AuditAction::TokenValidated, AuditAction::TokenRevoked];
for action in actions.iter() {
sink.record(AuditEvent::success(None, "s", action.clone()));
}
let events = sink.events();
assert_eq!(events.len(), 3);
assert_eq!(events[0].action, AuditAction::TokenIssued);
assert_eq!(events[1].action, AuditAction::TokenValidated);
assert_eq!(events[2].action, AuditAction::TokenRevoked);
}
#[test]
fn tracing_sink_does_not_panic() {
let sink = TracingAuditSink;
sink.record(AuditEvent::success(Some("svc".into()), "vault-key", AuditAction::VaultWrite));
sink.record(AuditEvent::failure(
None,
"jti-fail",
AuditAction::TokenRejected,
"bad signature",
));
}
}