Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/audit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,4 @@ pub use middleware::{AuditAgent, AuditMiddlewareState, AuditResponseContext};
pub use sink::AuditSink;
#[cfg(feature = "cloudwatch")]
pub use sinks::CloudWatchLogsSink;
pub use sinks::{DatabaseSink, FileSink, NullSink};
pub use sinks::{DatabaseSink, FileSink, InMemoryAuditSink, NullSink};
111 changes: 111 additions & 0 deletions crates/audit/src/sinks/memory.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
//! In-memory audit sink for tests.
//!
//! Buffers every recorded [`AuditEvent`] in a `Vec` behind a `Mutex` so test
//! code can assert on emitted events. Not intended for production use.

use std::sync::{Arc, Mutex};

use async_trait::async_trait;

use crate::fhir_model::AuditEvent;
use crate::sink::AuditSink;

/// Test-only sink that retains every recorded [`AuditEvent`] in memory.
///
/// Clones share the same underlying buffer (events recorded through any clone
/// are visible to all of them), which makes it convenient to register the sink
/// in an [`AppState`] while still holding a handle for assertions.
#[derive(Clone, Default)]
pub struct InMemoryAuditSink {
events: Arc<Mutex<Vec<AuditEvent>>>,
}

impl InMemoryAuditSink {
/// Creates a new, empty in-memory sink.
pub fn new() -> Self {
Self::default()
}

/// Returns a snapshot of every event recorded so far.
pub fn events(&self) -> Vec<AuditEvent> {
self.events
.lock()
.expect("audit sink mutex poisoned")
.clone()
}

/// Returns the number of events currently buffered.
pub fn len(&self) -> usize {
self.events.lock().expect("audit sink mutex poisoned").len()
}

/// Returns `true` when no events have been recorded.
pub fn is_empty(&self) -> bool {
self.len() == 0
}

/// Removes all buffered events.
pub fn clear(&self) {
self.events
.lock()
.expect("audit sink mutex poisoned")
.clear();
}
}

#[async_trait]
impl AuditSink for InMemoryAuditSink {
async fn record(&self, event: AuditEvent) {
self.events
.lock()
.expect("audit sink mutex poisoned")
.push(event);
}

async fn flush(&self) {}

fn name(&self) -> &str {
"memory"
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::builder::AuditEventBuilder;

#[tokio::test]
async fn test_record_stores_event() {
let sink = InMemoryAuditSink::new();
let event = AuditEventBuilder::new("Device/hfs")
.detail("audit-operation", "test")
.build();
sink.record(event).await;
assert_eq!(sink.len(), 1);
}

#[tokio::test]
async fn test_clones_share_buffer() {
let sink = InMemoryAuditSink::new();
let clone = sink.clone();
clone
.record(AuditEventBuilder::new("Device/hfs").build())
.await;
assert_eq!(sink.len(), 1);
assert_eq!(clone.len(), 1);
}

#[tokio::test]
async fn test_clear_resets_buffer() {
let sink = InMemoryAuditSink::new();
sink.record(AuditEventBuilder::new("Device/hfs").build())
.await;
sink.clear();
assert!(sink.is_empty());
}

#[test]
fn test_name() {
assert_eq!(InMemoryAuditSink::new().name(), "memory");
}
}
2 changes: 2 additions & 0 deletions crates/audit/src/sinks/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
pub mod cloudwatch;
pub mod database;
pub mod file;
pub mod memory;
pub mod null;

#[cfg(feature = "cloudwatch")]
pub use cloudwatch::CloudWatchLogsSink;
pub use database::DatabaseSink;
pub use file::FileSink;
pub use memory::InMemoryAuditSink;
pub use null::NullSink;
Loading