use std::io::{BufWriter, Write};
use std::path::Path;
use rusqlite::params;
use serde::Serialize;
use crate::db::Database;
use crate::error::Result;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum AuditAction {
Approve,
Unapprove,
Defer,
Ignore,
Unignore,
Remove,
Scan,
Undo,
#[allow(dead_code)]
ConfigChange,
}
impl AuditAction {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Approve => "approve",
Self::Unapprove => "unapprove",
Self::Defer => "defer",
Self::Ignore => "ignore",
Self::Unignore => "unignore",
Self::Remove => "remove",
Self::Scan => "scan",
Self::Undo => "undo",
Self::ConfigChange => "config_change",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuditActorSource {
Tui,
Daemon,
Scanner,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuditExportFormat {
Jsonl,
Csv,
}
impl AuditExportFormat {
#[must_use]
pub const fn next(self) -> Self {
match self {
Self::Jsonl => Self::Csv,
Self::Csv => Self::Jsonl,
}
}
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Jsonl => "JSONL",
Self::Csv => "CSV",
}
}
}
impl AuditActorSource {
const fn as_str(self) -> &'static str {
match self {
Self::Tui => "tui",
Self::Daemon => "daemon",
Self::Scanner => "scanner",
}
}
}
#[derive(Debug)]
pub struct AuditEvent<'a> {
pub user: &'a str,
pub actor_source: AuditActorSource,
pub action: AuditAction,
pub target_path: Option<&'a Path>,
pub details: Option<&'a str>,
pub entry_id: Option<i64>,
pub root_id: Option<i64>,
pub status_before: Option<&'a str>,
pub status_after: Option<&'a str>,
pub outcome: Option<&'a str>,
}
pub struct AuditService<'a> {
db: &'a Database,
}
impl<'a> AuditService<'a> {
pub fn new(db: &'a Database) -> Self {
Self { db }
}
#[cfg(test)]
pub fn record(
&self,
user: &str,
action: AuditAction,
target_path: Option<&Path>,
details: Option<&str>,
entry_id: Option<i64>,
) -> Result<()> {
let target_path_str = target_path.map(|p| p.to_string_lossy());
self.db.conn().execute(
"INSERT INTO audit_log (user, action, target_path, details, entry_id)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![
user,
action.as_str(),
target_path_str.as_deref(),
details,
entry_id
],
)?;
Ok(())
}
pub fn record_event(&self, event: &AuditEvent<'_>) -> Result<()> {
let target_path_str = event.target_path.map(|p| p.to_string_lossy());
self.db.conn().execute(
"INSERT INTO audit_log (user, action, target_path, details, entry_id, actor_source, root_id, outcome, status_before, status_after)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![
event.user,
event.action.as_str(),
target_path_str.as_deref(),
event.details,
event.entry_id,
event.actor_source.as_str(),
event.root_id,
event.outcome,
event.status_before,
event.status_after,
],
)?;
let target_path = event.target_path.map(|p| p.display().to_string());
if matches!(event.outcome, Some("blocked" | "failed")) {
tracing::warn!(
target: "stagecrew::audit",
audit_action = event.action.as_str(),
audit_user = event.user,
audit_actor_source = event.actor_source.as_str(),
audit_target_path = target_path.as_deref(),
audit_entry_id = event.entry_id,
audit_root_id = event.root_id,
audit_status_before = event.status_before,
audit_status_after = event.status_after,
audit_outcome = event.outcome,
audit_details = event.details,
"audit_event"
);
} else {
tracing::info!(
target: "stagecrew::audit",
audit_action = event.action.as_str(),
audit_user = event.user,
audit_actor_source = event.actor_source.as_str(),
audit_target_path = target_path.as_deref(),
audit_entry_id = event.entry_id,
audit_root_id = event.root_id,
audit_status_before = event.status_before,
audit_status_after = event.status_after,
audit_outcome = event.outcome,
audit_details = event.details,
"audit_event"
);
}
Ok(())
}
#[must_use]
pub fn current_user() -> String {
std::env::var("USER")
.or_else(|_| std::env::var("LOGNAME"))
.unwrap_or_else(|_| "unknown".to_string())
}
pub fn list_recent(&self, limit: usize) -> Result<Vec<AuditEntry>> {
let mut stmt = self.db.conn().prepare(
"SELECT id, timestamp, user, action, target_path, details, entry_id,
actor_source, root_id, outcome, status_before, status_after
FROM audit_log
ORDER BY timestamp DESC
LIMIT ?1",
)?;
#[allow(clippy::cast_possible_wrap)]
let entries = stmt
.query_map(params![limit as i64], |row| {
Ok(AuditEntry {
id: row.get(0)?,
timestamp: row.get(1)?,
user: row.get(2)?,
action: row.get(3)?,
target_path: row.get(4)?,
details: row.get(5)?,
entry_id: row.get(6)?,
actor_source: row.get(7)?,
root_id: row.get(8)?,
outcome: row.get(9)?,
status_before: row.get(10)?,
status_after: row.get(11)?,
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(entries)
}
#[allow(dead_code)]
pub fn list_by_path(&self, path: &Path) -> Result<Vec<AuditEntry>> {
let path_str = path.to_string_lossy();
let mut stmt = self.db.conn().prepare(
"SELECT id, timestamp, user, action, target_path, details, entry_id,
actor_source, root_id, outcome, status_before, status_after
FROM audit_log
WHERE target_path = ?1
ORDER BY timestamp DESC",
)?;
let entries = stmt
.query_map(params![&*path_str], |row| {
Ok(AuditEntry {
id: row.get(0)?,
timestamp: row.get(1)?,
user: row.get(2)?,
action: row.get(3)?,
target_path: row.get(4)?,
details: row.get(5)?,
entry_id: row.get(6)?,
actor_source: row.get(7)?,
root_id: row.get(8)?,
outcome: row.get(9)?,
status_before: row.get(10)?,
status_after: row.get(11)?,
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(entries)
}
pub fn export_recent_to_path(
&self,
limit: usize,
format: AuditExportFormat,
path: &Path,
) -> Result<usize> {
let entries = self.list_recent(limit)?;
match format {
AuditExportFormat::Jsonl => {
let file = std::fs::File::create(path)?;
let mut writer = BufWriter::new(file);
for entry in &entries {
write_jsonl_entry(&mut writer, entry)?;
}
writer.flush()?;
}
AuditExportFormat::Csv => {
let file = std::fs::File::create(path)?;
let mut writer = csv::Writer::from_writer(BufWriter::new(file));
for entry in &entries {
write_csv_entry(&mut writer, entry)?;
}
writer.flush().map_err(|e| {
crate::error::Error::Config(format!("failed to flush CSV export: {e}"))
})?;
}
}
Ok(entries.len())
}
}
fn write_jsonl_entry(writer: &mut impl Write, entry: &AuditEntry) -> Result<()> {
serde_json::to_writer(&mut *writer, &AuditExportRow::from(entry))
.map_err(|e| crate::error::Error::Config(format!("failed to serialize JSONL row: {e}")))?;
writeln!(writer)?;
Ok(())
}
fn write_csv_entry(
writer: &mut csv::Writer<BufWriter<std::fs::File>>,
entry: &AuditEntry,
) -> Result<()> {
writer
.serialize(AuditExportRow::from(entry))
.map_err(|e| crate::error::Error::Config(format!("failed to serialize CSV row: {e}")))?;
Ok(())
}
#[derive(Serialize)]
struct AuditExportRow<'a> {
id: i64,
timestamp: i64,
user: &'a str,
action: &'a str,
target_path: Option<&'a str>,
details: Option<&'a str>,
entry_id: Option<i64>,
actor_source: Option<&'a str>,
root_id: Option<i64>,
outcome: Option<&'a str>,
status_before: Option<&'a str>,
status_after: Option<&'a str>,
}
impl<'a> From<&'a AuditEntry> for AuditExportRow<'a> {
fn from(entry: &'a AuditEntry) -> Self {
Self {
id: entry.id,
timestamp: entry.timestamp,
user: entry.user.as_str(),
action: entry.action.as_str(),
target_path: entry.target_path.as_deref(),
details: entry.details.as_deref(),
entry_id: entry.entry_id,
actor_source: entry.actor_source.as_deref(),
root_id: entry.root_id,
outcome: entry.outcome.as_deref(),
status_before: entry.status_before.as_deref(),
status_after: entry.status_after.as_deref(),
}
}
}
#[derive(Debug)]
#[non_exhaustive]
#[allow(dead_code)]
pub struct AuditEntry {
pub id: i64,
pub timestamp: i64,
pub user: String,
pub action: String,
pub target_path: Option<String>,
pub details: Option<String>,
pub entry_id: Option<i64>,
pub actor_source: Option<String>,
pub root_id: Option<i64>,
pub outcome: Option<String>,
pub status_before: Option<String>,
pub status_after: Option<String>,
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
use crate::db::Database;
use tempfile::TempDir;
use tracing_test::traced_test;
fn temp_database() -> (Database, TempDir) {
let temp_dir = TempDir::with_prefix("stagecrew-audit-test-").expect(
"failed to create temp directory for audit test - check disk space and permissions",
);
let db_path = temp_dir.path().join("test.db");
let db = Database::open(&db_path)
.expect("failed to open test database - check permissions and disk space");
(db, temp_dir)
}
#[test]
fn audit_service_records_entry() {
let (db, _temp_dir) = temp_database();
let audit = AuditService::new(&db);
audit
.record(
"alice",
AuditAction::Approve,
Some(Path::new("/data/test")),
None,
None,
)
.expect(
"failed to record audit entry to database - connection may be lost or disk full",
);
let entries = audit
.list_recent(10)
.expect("failed to query recent audit entries - database connection may be lost");
assert_eq!(entries.len(), 1, "Expected 1 audit entry");
assert_eq!(entries[0].user, "alice");
assert_eq!(entries[0].action, "approve");
assert_eq!(entries[0].target_path, Some("/data/test".to_string()));
assert!(entries[0].details.is_none());
assert!(entries[0].entry_id.is_none());
}
#[test]
fn audit_service_records_entry_without_target_path() {
let (db, _temp_dir) = temp_database();
let audit = AuditService::new(&db);
audit
.record(
"system",
AuditAction::ConfigChange,
None,
Some("Changed expiration to 60 days"),
None,
)
.expect(
"failed to record audit entry to database - connection may be lost or disk full",
);
let entries = audit
.list_recent(10)
.expect("failed to query recent audit entries - database connection may be lost");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].user, "system");
assert_eq!(entries[0].action, "config_change");
assert!(
entries[0].target_path.is_none(),
"Expected no target path for system-wide action"
);
assert_eq!(
entries[0].details,
Some("Changed expiration to 60 days".to_string())
);
}
#[test]
fn audit_service_records_all_fields() {
let (db, _temp_dir) = temp_database();
let audit = AuditService::new(&db);
let root_id = db
.insert_root(Path::new("/data"))
.expect("failed to insert root to database - connection may be lost or disk full");
let entry_id = db
.upsert_entry(
root_id,
Path::new("/data/important"),
Path::new("/data"),
false,
1024,
Some(1_700_000_000),
)
.expect("failed to insert entry to database - connection may be lost or disk full");
audit
.record(
"bob",
AuditAction::Defer,
Some(Path::new("/data/important")),
Some("Deferred for 30 days"),
Some(entry_id),
)
.expect(
"failed to record audit entry to database - connection may be lost or disk full",
);
let entries = audit
.list_recent(10)
.expect("failed to query recent audit entries - database connection may be lost");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].user, "bob");
assert_eq!(entries[0].action, "defer");
assert_eq!(entries[0].target_path, Some("/data/important".to_string()));
assert_eq!(entries[0].details, Some("Deferred for 30 days".to_string()));
assert_eq!(entries[0].entry_id, Some(entry_id));
}
#[test]
fn audit_service_records_all_action_types() {
let (db, _temp_dir) = temp_database();
let audit = AuditService::new(&db);
let actions = [
(AuditAction::Approve, "approve"),
(AuditAction::Unapprove, "unapprove"),
(AuditAction::Defer, "defer"),
(AuditAction::Ignore, "ignore"),
(AuditAction::Remove, "remove"),
(AuditAction::Scan, "scan"),
(AuditAction::Undo, "undo"),
(AuditAction::ConfigChange, "config_change"),
];
for (action, _expected_str) in &actions {
audit
.record("user", *action, Some(Path::new("/test")), None, None)
.expect(
"failed to record audit entry to database - connection may be lost or disk full",
);
}
let entries = audit
.list_recent(10)
.expect("failed to query recent audit entries - database connection may be lost");
assert_eq!(
entries.len(),
actions.len(),
"Expected {} entries",
actions.len()
);
for (i, (_, expected_str)) in actions.iter().enumerate().rev() {
let entry_idx = actions.len() - 1 - i;
assert_eq!(entries[entry_idx].action, *expected_str);
}
}
#[test]
fn audit_service_list_recent_on_empty_db() {
let (db, _temp_dir) = temp_database();
let audit = AuditService::new(&db);
let entries = audit
.list_recent(10)
.expect("failed to query recent audit entries - database connection may be lost");
assert!(entries.is_empty(), "Expected empty list for empty database");
}
#[test]
fn audit_service_list_recent_with_zero_limit() {
let (db, _temp_dir) = temp_database();
let audit = AuditService::new(&db);
audit
.record(
"user",
AuditAction::Scan,
Some(Path::new("/test")),
None,
None,
)
.expect(
"failed to record audit entry to database - connection may be lost or disk full",
);
let entries = audit
.list_recent(0)
.expect("failed to query recent audit entries - database connection may be lost");
assert!(entries.is_empty(), "Expected empty list when limit is zero");
}
#[test]
fn audit_service_list_recent_respects_limit() {
let (db, _temp_dir) = temp_database();
let audit = AuditService::new(&db);
for i in 0..10 {
let p = format!("/path/{i}");
audit
.record(
"user",
AuditAction::Scan,
Some(Path::new(&p)),
None,
None,
)
.expect("failed to record audit entry to database - connection may be lost or disk full");
}
let entries = audit
.list_recent(5)
.expect("failed to query recent audit entries - database connection may be lost");
assert_eq!(entries.len(), 5, "Expected limit of 5 to be respected");
assert_eq!(entries[0].target_path, Some("/path/9".to_string()));
assert_eq!(entries[4].target_path, Some("/path/5".to_string()));
}
#[test]
fn audit_service_list_recent_orders_by_timestamp_desc() {
let (db, _temp_dir) = temp_database();
let audit = AuditService::new(&db);
audit
.record(
"user",
AuditAction::Scan,
Some(Path::new("/first")),
None,
None,
)
.expect(
"failed to record audit entry to database - connection may be lost or disk full",
);
std::thread::sleep(std::time::Duration::from_millis(10));
audit
.record(
"user",
AuditAction::Approve,
Some(Path::new("/second")),
None,
None,
)
.expect(
"failed to record audit entry to database - connection may be lost or disk full",
);
std::thread::sleep(std::time::Duration::from_millis(10));
audit
.record(
"user",
AuditAction::Remove,
Some(Path::new("/third")),
None,
None,
)
.expect(
"failed to record audit entry to database - connection may be lost or disk full",
);
let entries = audit
.list_recent(10)
.expect("failed to query recent audit entries - database connection may be lost");
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].target_path, Some("/third".to_string()));
assert_eq!(entries[1].target_path, Some("/second".to_string()));
assert_eq!(entries[2].target_path, Some("/first".to_string()));
assert!(entries[0].timestamp >= entries[1].timestamp);
assert!(entries[1].timestamp >= entries[2].timestamp);
}
#[test]
fn audit_service_list_by_path_filters_correctly() {
let (db, _temp_dir) = temp_database();
let audit = AuditService::new(&db);
audit
.record(
"alice",
AuditAction::Scan,
Some(Path::new("/data/project1")),
None,
None,
)
.expect(
"failed to record audit entry to database - connection may be lost or disk full",
);
audit
.record(
"bob",
AuditAction::Approve,
Some(Path::new("/data/project2")),
None,
None,
)
.expect(
"failed to record audit entry to database - connection may be lost or disk full",
);
audit
.record(
"charlie",
AuditAction::Remove,
Some(Path::new("/data/project1")),
None,
None,
)
.expect(
"failed to record audit entry to database - connection may be lost or disk full",
);
audit
.record(
"dave",
AuditAction::Defer,
Some(Path::new("/data/project1")),
None,
None,
)
.expect(
"failed to record audit entry to database - connection may be lost or disk full",
);
let entries = audit
.list_by_path(Path::new("/data/project1"))
.expect("failed to query audit entries by path - database connection may be lost");
assert_eq!(entries.len(), 3, "Expected 3 entries for /data/project1");
for entry in &entries {
assert_eq!(entry.target_path, Some("/data/project1".to_string()));
}
assert_eq!(entries[0].user, "dave");
assert_eq!(entries[1].user, "charlie");
assert_eq!(entries[2].user, "alice");
}
#[test]
fn audit_service_list_by_path_returns_empty_for_nonexistent() {
let (db, _temp_dir) = temp_database();
let audit = AuditService::new(&db);
audit
.record(
"user",
AuditAction::Scan,
Some(Path::new("/data/exists")),
None,
None,
)
.expect(
"failed to record audit entry to database - connection may be lost or disk full",
);
let entries = audit
.list_by_path(Path::new("/data/nonexistent"))
.expect("failed to query audit entries by path - database connection may be lost");
assert!(
entries.is_empty(),
"Expected no entries for nonexistent path"
);
}
#[test]
fn audit_service_current_user_reads_environment() {
let original_user = std::env::var("USER").ok();
let original_logname = std::env::var("LOGNAME").ok();
unsafe {
std::env::set_var("USER", "testuser");
std::env::set_var("LOGNAME", "fallback");
assert_eq!(AuditService::current_user(), "testuser");
std::env::remove_var("USER");
assert_eq!(AuditService::current_user(), "fallback");
std::env::remove_var("LOGNAME");
assert_eq!(AuditService::current_user(), "unknown");
if let Some(val) = original_user {
std::env::set_var("USER", val);
}
if let Some(val) = original_logname {
std::env::set_var("LOGNAME", val);
}
}
}
#[test]
fn audit_action_as_str_matches_schema_check_constraint() {
assert_eq!(AuditAction::Approve.as_str(), "approve");
assert_eq!(AuditAction::Unapprove.as_str(), "unapprove");
assert_eq!(AuditAction::Defer.as_str(), "defer");
assert_eq!(AuditAction::Ignore.as_str(), "ignore");
assert_eq!(AuditAction::Unignore.as_str(), "unignore");
assert_eq!(AuditAction::Remove.as_str(), "remove");
assert_eq!(AuditAction::Scan.as_str(), "scan");
assert_eq!(AuditAction::Undo.as_str(), "undo");
assert_eq!(AuditAction::ConfigChange.as_str(), "config_change");
}
fn seed_audit_entries(audit: &AuditService<'_>) {
let root_path = Path::new("/data/project");
audit
.record("alice", AuditAction::Approve, Some(root_path), None, None)
.expect("failed to seed approve entry");
audit
.record(
"bob",
AuditAction::Defer,
Some(Path::new("/data/project/file with spaces.txt")),
Some("Deferred for 30 days"),
None,
)
.expect("failed to seed defer entry");
audit
.record("charlie", AuditAction::Remove, None, None, None)
.expect("failed to seed remove entry");
}
#[test]
fn export_jsonl_produces_valid_json_lines() {
let (db, temp_dir) = temp_database();
let audit = AuditService::new(&db);
seed_audit_entries(&audit);
let export_path = temp_dir.path().join("audit.jsonl");
let count = audit
.export_recent_to_path(100, AuditExportFormat::Jsonl, &export_path)
.expect("JSONL export should succeed");
assert_eq!(count, 3);
let contents = std::fs::read_to_string(&export_path).expect("should read export file");
let lines: Vec<&str> = contents.lines().collect();
assert_eq!(lines.len(), 3, "should have one JSON line per entry");
for line in &lines {
let parsed: serde_json::Value =
serde_json::from_str(line).expect("each line should be valid JSON");
assert!(parsed.get("id").is_some(), "should have id field");
assert!(parsed.get("timestamp").is_some(), "should have timestamp");
assert!(parsed.get("user").is_some(), "should have user field");
assert!(parsed.get("action").is_some(), "should have action field");
}
let first: serde_json::Value =
serde_json::from_str(lines[0]).expect("first line should parse");
assert_eq!(
first["action"], "remove",
"most recent entry should be first"
);
assert!(first["target_path"].is_null(), "remove entry had no path");
}
#[test]
fn export_csv_produces_valid_csv_with_header() {
let (db, temp_dir) = temp_database();
let audit = AuditService::new(&db);
seed_audit_entries(&audit);
let export_path = temp_dir.path().join("audit.csv");
let count = audit
.export_recent_to_path(100, AuditExportFormat::Csv, &export_path)
.expect("CSV export should succeed");
assert_eq!(count, 3);
let contents = std::fs::read_to_string(&export_path).expect("should read export file");
let lines: Vec<&str> = contents.lines().collect();
assert_eq!(lines.len(), 4, "should have header + 3 data rows");
assert_eq!(
lines[0],
"id,timestamp,user,action,target_path,details,entry_id,actor_source,root_id,outcome,status_before,status_after",
"first line should be CSV header"
);
}
#[test]
fn export_csv_handles_special_characters_in_fields() {
let (db, temp_dir) = temp_database();
let audit = AuditService::new(&db);
audit
.record(
"user",
AuditAction::Defer,
Some(Path::new("/data/file,with\"quotes.txt")),
Some("details with, commas and \"quotes\""),
None,
)
.expect("should record entry with special chars");
let export_path = temp_dir.path().join("special.csv");
audit
.export_recent_to_path(100, AuditExportFormat::Csv, &export_path)
.expect("CSV export with special chars should succeed");
let mut reader = csv::Reader::from_path(&export_path).expect("should open CSV");
let records: Vec<csv::StringRecord> = reader
.records()
.collect::<std::result::Result<Vec<_>, _>>()
.expect("should parse all CSV records");
assert_eq!(records.len(), 1);
assert_eq!(&records[0][3], "defer");
assert!(
records[0][4].contains("quotes"),
"path with special chars should round-trip through CSV"
);
}
#[test]
fn export_empty_audit_log_produces_valid_output() {
let (db, temp_dir) = temp_database();
let audit = AuditService::new(&db);
let jsonl_path = temp_dir.path().join("empty.jsonl");
let count = audit
.export_recent_to_path(100, AuditExportFormat::Jsonl, &jsonl_path)
.expect("empty JSONL export should succeed");
assert_eq!(count, 0);
let contents = std::fs::read_to_string(&jsonl_path).expect("should read file");
assert!(contents.is_empty(), "empty JSONL should produce empty file");
let csv_path = temp_dir.path().join("empty.csv");
let count = audit
.export_recent_to_path(100, AuditExportFormat::Csv, &csv_path)
.expect("empty CSV export should succeed");
assert_eq!(count, 0);
let contents = std::fs::read_to_string(&csv_path).expect("should read file");
assert!(
contents.lines().count() <= 1,
"empty CSV should have at most a header row"
);
}
#[test]
fn export_jsonl_preserves_null_optional_fields() {
let (db, temp_dir) = temp_database();
let audit = AuditService::new(&db);
audit
.record("user", AuditAction::Scan, None, None, None)
.expect("should record entry with null optionals");
let export_path = temp_dir.path().join("nulls.jsonl");
audit
.export_recent_to_path(100, AuditExportFormat::Jsonl, &export_path)
.expect("JSONL export should succeed");
let contents = std::fs::read_to_string(&export_path).expect("should read file");
let parsed: serde_json::Value =
serde_json::from_str(contents.trim()).expect("should parse JSON");
assert!(parsed["target_path"].is_null());
assert!(parsed["details"].is_null());
assert!(parsed["entry_id"].is_null());
}
#[test]
fn export_format_next_cycles_correctly() {
assert_eq!(AuditExportFormat::Jsonl.next(), AuditExportFormat::Csv);
assert_eq!(AuditExportFormat::Csv.next(), AuditExportFormat::Jsonl);
}
fn seed_root_and_entry(db: &Database) -> (i64, i64) {
let root_id = db
.insert_root(Path::new("/data"))
.expect("failed to insert root");
let entry_id = db
.upsert_entry(
root_id,
Path::new("/data/file.txt"),
Path::new("/data"),
false,
1024,
Some(1_700_000_000),
)
.expect("failed to insert entry");
(root_id, entry_id)
}
#[test]
#[traced_test]
fn record_event_writes_db_row_and_emits_tracing_event() {
let (db, _temp_dir) = temp_database();
let (root_id, entry_id) = seed_root_and_entry(&db);
let audit = AuditService::new(&db);
audit
.record_event(&AuditEvent {
user: "testuser",
actor_source: AuditActorSource::Tui,
action: AuditAction::Approve,
target_path: Some(Path::new("/data/file.txt")),
details: Some("approved for removal"),
entry_id: Some(entry_id),
root_id: Some(root_id),
status_before: Some("tracked"),
status_after: Some("approved"),
outcome: Some("approved"),
})
.expect("record_event should succeed");
let entries = audit.list_recent(10).expect("should list entries");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].user, "testuser");
assert_eq!(entries[0].action, "approve");
assert_eq!(entries[0].target_path, Some("/data/file.txt".to_string()));
assert_eq!(entries[0].details, Some("approved for removal".to_string()));
assert_eq!(entries[0].entry_id, Some(entry_id));
assert_eq!(entries[0].actor_source, Some("tui".to_string()));
assert_eq!(entries[0].root_id, Some(root_id));
assert_eq!(entries[0].outcome, Some("approved".to_string()));
assert_eq!(entries[0].status_before, Some("tracked".to_string()));
assert_eq!(entries[0].status_after, Some("approved".to_string()));
assert!(logs_contain("audit_event"));
assert!(logs_contain("audit_action=\"approve\""));
assert!(logs_contain("audit_user=\"testuser\""));
assert!(logs_contain("audit_actor_source=\"tui\""));
assert!(logs_contain("audit_status_before=\"tracked\""));
assert!(logs_contain("audit_status_after=\"approved\""));
assert!(logs_contain("audit_outcome=\"approved\""));
}
#[test]
#[traced_test]
fn record_event_blocked_outcome_emits_warn_level() {
let (db, _temp_dir) = temp_database();
let (root_id, entry_id) = seed_root_and_entry(&db);
let audit = AuditService::new(&db);
audit
.record_event(&AuditEvent {
user: "daemon",
actor_source: AuditActorSource::Daemon,
action: AuditAction::Remove,
target_path: Some(Path::new("/data/file.txt")),
details: Some("Blocked: permission denied"),
entry_id: Some(entry_id),
root_id: Some(root_id),
status_before: Some("approved"),
status_after: Some("blocked"),
outcome: Some("blocked"),
})
.expect("record_event with blocked outcome should succeed");
let entries = audit.list_recent(10).expect("should list entries");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].action, "remove");
assert_eq!(
entries[0].details,
Some("Blocked: permission denied".to_string())
);
assert!(logs_contain("WARN"));
assert!(logs_contain("audit_event"));
assert!(logs_contain("audit_action=\"remove\""));
assert!(logs_contain("audit_actor_source=\"daemon\""));
assert!(logs_contain("audit_outcome=\"blocked\""));
}
#[test]
#[traced_test]
fn record_event_scanner_transition_emits_tracing_event() {
let (db, _temp_dir) = temp_database();
let (_root_id, entry_id) = seed_root_and_entry(&db);
let audit = AuditService::new(&db);
audit
.record_event(&AuditEvent {
user: "scanner",
actor_source: AuditActorSource::Scanner,
action: AuditAction::Scan,
target_path: Some(Path::new("/data/file.txt")),
details: Some("Expired, pending approval for removal"),
entry_id: Some(entry_id),
root_id: None,
status_before: Some("tracked"),
status_after: Some("pending"),
outcome: Some("pending"),
})
.expect("record_event for scanner transition should succeed");
let entries = audit.list_recent(10).expect("should list entries");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].action, "scan");
assert_eq!(entries[0].target_path, Some("/data/file.txt".to_string()));
assert!(logs_contain("audit_event"));
assert!(logs_contain("audit_action=\"scan\""));
assert!(logs_contain("audit_actor_source=\"scanner\""));
assert!(logs_contain("audit_status_before=\"tracked\""));
assert!(logs_contain("audit_status_after=\"pending\""));
}
#[test]
fn record_event_blocked_outcome_writes_db_row() {
let (db, _temp_dir) = temp_database();
let (root_id, entry_id) = seed_root_and_entry(&db);
let audit = AuditService::new(&db);
audit
.record_event(&AuditEvent {
user: "daemon",
actor_source: AuditActorSource::Daemon,
action: AuditAction::Remove,
target_path: Some(Path::new("/data/file.txt")),
details: Some("Blocked: permission denied"),
entry_id: Some(entry_id),
root_id: Some(root_id),
status_before: Some("approved"),
status_after: Some("blocked"),
outcome: Some("blocked"),
})
.expect("record_event with blocked outcome should succeed");
let entries = audit.list_recent(10).expect("should list entries");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].action, "remove");
assert_eq!(
entries[0].details,
Some("Blocked: permission denied".to_string())
);
}
#[test]
fn record_event_scanner_transition_writes_db_row() {
let (db, _temp_dir) = temp_database();
let (_root_id, entry_id) = seed_root_and_entry(&db);
let audit = AuditService::new(&db);
audit
.record_event(&AuditEvent {
user: "scanner",
actor_source: AuditActorSource::Scanner,
action: AuditAction::Scan,
target_path: Some(Path::new("/data/file.txt")),
details: Some("Expired, pending approval for removal"),
entry_id: Some(entry_id),
root_id: None,
status_before: Some("tracked"),
status_after: Some("pending"),
outcome: Some("pending"),
})
.expect("record_event for scanner transition should succeed");
let entries = audit.list_recent(10).expect("should list entries");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].action, "scan");
assert_eq!(entries[0].target_path, Some("/data/file.txt".to_string()));
}
}