Skip to main content

nexo_core/agent/admin_rpc/
audit_sqlite.rs

1//! SQLite-backed admin audit writer.
2//!
3//! Persists [`AdminAuditRow`] across daemon restarts. Append is
4//! fail-tolerant (errors log-warn, never propagate to dispatch).
5//! Boot-time `sweep_retention()` enforces age + cap limits via
6//! the env-var-driven INVENTORY toggles
7//! `NEXO_MICROAPP_ADMIN_AUDIT_RETENTION_DAYS` (default 90) and
8//! `NEXO_MICROAPP_ADMIN_AUDIT_MAX_ROWS` (default 100_000).
9//!
10//! DDL is inline + idempotent (`CREATE TABLE IF NOT EXISTS`) so
11//! the writer can `open()` against a brand-new path or an
12//! already-populated one. Mirrors the `runMigrations()`
13//! forward-only pattern.
14
15use std::path::Path;
16use std::str::FromStr;
17
18use async_trait::async_trait;
19use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
20use sqlx::SqlitePool;
21
22use super::audit::{
23    AdminAuditReader, AdminAuditResult, AdminAuditRow, AdminAuditWriter, AuditTailFilter,
24    AuditTailPage,
25};
26
27// `AuditTailFilter` lives in
28// `nexo-tool-meta::admin::audit`. The orphan rule prevents adding
29// `impl AuditTailFilter { fn new() }` here. Callers that previously
30// used `AuditTailFilter::new()` should construct via
31// `AuditTailFilter::default()` and override `limit` explicitly
32// (or leave 0 → the SqliteAuditWriter clamps to 50 server-side).
33
34/// SQLite-backed `AdminAuditWriter`. Production daemons construct
35/// one at boot and feed it to
36/// `AdminRpcDispatcher::with_audit_writer`.
37#[derive(Debug, Clone)]
38pub struct SqliteAdminAuditWriter {
39    pool: SqlitePool,
40}
41
42impl SqliteAdminAuditWriter {
43    /// Open or create the audit DB at `path`. Idempotent — the
44    /// inline DDL runs on every boot. Pool kept small (2 conns)
45    /// since audit writes are infrequent and append-only.
46    pub async fn open(path: &Path) -> anyhow::Result<Self> {
47        if let Some(parent) = path.parent() {
48            std::fs::create_dir_all(parent).ok();
49        }
50        let path_str = path.display().to_string();
51        let opts = SqliteConnectOptions::from_str(&format!("sqlite://{path_str}"))?
52            .create_if_missing(true);
53        let pool = SqlitePoolOptions::new()
54            .max_connections(2)
55            .connect_with(opts)
56            .await?;
57        sqlx::query("PRAGMA journal_mode=WAL")
58            .execute(&pool)
59            .await
60            .ok();
61        Self::run_ddl(&pool).await?;
62        sqlx::query(
63            "CREATE INDEX IF NOT EXISTS idx_microapp_admin_audit_microapp
64                ON microapp_admin_audit(microapp_id, started_at_ms DESC)",
65        )
66        .execute(&pool)
67        .await?;
68        sqlx::query(
69            "CREATE INDEX IF NOT EXISTS idx_microapp_admin_audit_method
70                ON microapp_admin_audit(method, started_at_ms DESC)",
71        )
72        .execute(&pool)
73        .await?;
74        sqlx::query(
75            "CREATE INDEX IF NOT EXISTS idx_microapp_admin_audit_tenant
76                ON microapp_admin_audit(tenant_id, started_at_ms DESC)",
77        )
78        .execute(&pool)
79        .await?;
80        Ok(Self { pool })
81    }
82
83    /// In-memory variant for tests. No filesystem path; pool drops
84    /// content on `Self::Drop`.
85    pub async fn open_memory() -> anyhow::Result<Self> {
86        let opts = SqliteConnectOptions::from_str("sqlite::memory:")?;
87        let pool = SqlitePoolOptions::new()
88            .max_connections(2)
89            .connect_with(opts)
90            .await?;
91        Self::run_ddl(&pool).await?;
92        Ok(Self { pool })
93    }
94
95    /// DDL bootstrap. Creates the audit table from scratch with
96    /// the full current schema; for older DBs that were created
97    /// without `tenant_id`, ALTER adds the column idempotently
98    /// (the "duplicate column name" error is the green path on
99    /// already-migrated DBs and is suppressed).
100    async fn run_ddl(pool: &SqlitePool) -> anyhow::Result<()> {
101        sqlx::query(
102            "CREATE TABLE IF NOT EXISTS microapp_admin_audit (
103                id INTEGER PRIMARY KEY AUTOINCREMENT,
104                microapp_id TEXT NOT NULL,
105                method TEXT NOT NULL,
106                capability TEXT NOT NULL,
107                args_hash TEXT NOT NULL,
108                started_at_ms INTEGER NOT NULL,
109                result TEXT NOT NULL CHECK(result IN ('ok','error','denied')),
110                error_code INTEGER,
111                duration_ms INTEGER NOT NULL,
112                tenant_id TEXT
113            )",
114        )
115        .execute(pool)
116        .await?;
117        // Forward-only migration for DBs created before `tenant_id`
118        // was added. SQLite raises "duplicate column name" if the column
119        // already exists — that's the success case.
120        if let Err(e) = sqlx::query("ALTER TABLE microapp_admin_audit ADD COLUMN tenant_id TEXT")
121            .execute(pool)
122            .await
123        {
124            let msg = e.to_string();
125            if !msg.contains("duplicate column") {
126                return Err(e.into());
127            }
128        }
129        Ok(())
130    }
131
132    /// Boot-time retention sweep. Deletes rows older than
133    /// `retention_days` AND drops the oldest beyond `max_rows`.
134    /// Returns total rows deleted. Errors propagate — caller
135    /// (boot supervisor) logs them; admin dispatch never depends
136    /// on this fn.
137    pub async fn sweep_retention(
138        &self,
139        retention_days: u64,
140        max_rows: usize,
141    ) -> anyhow::Result<usize> {
142        let mut deleted = 0usize;
143
144        // 1. Time-based delete.
145        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
146        let cutoff_ms = now_ms.saturating_sub(retention_days * 86_400 * 1000);
147        let res = sqlx::query("DELETE FROM microapp_admin_audit WHERE started_at_ms < ?")
148            .bind(cutoff_ms as i64)
149            .execute(&self.pool)
150            .await?;
151        deleted += res.rows_affected() as usize;
152
153        // 2. Cap-based delete.
154        let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM microapp_admin_audit")
155            .fetch_one(&self.pool)
156            .await?;
157        if (total as usize) > max_rows {
158            let excess = (total as usize) - max_rows;
159            let res = sqlx::query(
160                "DELETE FROM microapp_admin_audit WHERE id IN (
161                    SELECT id FROM microapp_admin_audit
162                    ORDER BY started_at_ms ASC LIMIT ?
163                )",
164            )
165            .bind(excess as i64)
166            .execute(&self.pool)
167            .await?;
168            deleted += res.rows_affected() as usize;
169        }
170        Ok(deleted)
171    }
172
173    /// Tail recent audit rows
174    /// with filter + pagination support. Returns an
175    /// [`AuditTailPage`] with `entries`, `total`, `has_more`, and
176    /// `next_offset` so callers (CLI tail formatter + the
177    /// `nexo/admin/microapp_audit/tail` admin RPC) can render
178    /// "showing N of M" UI labels and offer "load more" controls.
179    ///
180    /// Filter combinator → SQL: every Some-field appends an `AND`
181    /// clause. Result is ordered newest-first by `started_at_ms`.
182    /// `limit = 0` defaults to 50; clamped to `[1, 500]`.
183    pub async fn tail(&self, filter: &AuditTailFilter) -> anyhow::Result<AuditTailPage> {
184        // Build the WHERE clause once + reuse for both the
185        // SELECT (paginated rows) and the COUNT (total matching).
186        let mut where_sql = String::from("WHERE 1=1");
187        let mut str_binds: Vec<String> = Vec::new();
188        if let Some(id) = &filter.microapp_id {
189            where_sql.push_str(" AND microapp_id = ?");
190            str_binds.push(id.clone());
191        }
192        if let Some(method) = &filter.method {
193            where_sql.push_str(" AND method = ?");
194            str_binds.push(method.clone());
195        }
196        if let Some(result) = &filter.result {
197            where_sql.push_str(" AND result = ?");
198            str_binds.push(result.as_str().to_string());
199        }
200        if let Some(tenant) = &filter.tenant_id {
201            where_sql.push_str(" AND tenant_id = ?");
202            str_binds.push(tenant.clone());
203        }
204        let mut int_binds: Vec<i64> = Vec::new();
205        if let Some(since_ms) = filter.since_ms {
206            where_sql.push_str(" AND started_at_ms >= ?");
207            int_binds.push(since_ms as i64);
208        }
209
210        // Server-side clamp: empty filter → 50, max 500.
211        let effective_limit = if filter.limit == 0 {
212            50
213        } else {
214            filter.limit.min(500)
215        };
216
217        // Total count for "showing N of M"
218        // UI label. Same WHERE clause as the SELECT below; kept
219        // as a separate query so the LIMIT/OFFSET don't taint the
220        // count.
221        let count_sql = format!("SELECT COUNT(*) FROM microapp_admin_audit {}", where_sql);
222        let mut count_q = sqlx::query_scalar::<_, i64>(&count_sql);
223        for b in &str_binds {
224            count_q = count_q.bind(b);
225        }
226        for b in &int_binds {
227            count_q = count_q.bind(*b);
228        }
229        let total: i64 = count_q.fetch_one(&self.pool).await?;
230        let total = total.max(0) as u64;
231
232        // Page of rows.
233        let select_sql = format!(
234            "SELECT microapp_id, method, capability, args_hash, started_at_ms, \
235             result, error_code, duration_ms, tenant_id \
236             FROM microapp_admin_audit {} \
237             ORDER BY started_at_ms DESC LIMIT ? OFFSET ?",
238            where_sql
239        );
240        let mut select_q = sqlx::query_as::<
241            _,
242            (
243                String,
244                String,
245                String,
246                String,
247                i64,
248                String,
249                Option<i32>,
250                i64,
251                Option<String>,
252            ),
253        >(&select_sql);
254        for b in &str_binds {
255            select_q = select_q.bind(b);
256        }
257        for b in &int_binds {
258            select_q = select_q.bind(*b);
259        }
260        select_q = select_q.bind(effective_limit as i64);
261        select_q = select_q.bind(filter.offset as i64);
262
263        let rows = select_q.fetch_all(&self.pool).await?;
264        let entries: Vec<AdminAuditRow> = rows
265            .into_iter()
266            .map(
267                |(
268                    microapp_id,
269                    method,
270                    capability,
271                    args_hash,
272                    started_at_ms,
273                    result,
274                    _err,
275                    duration_ms,
276                    tenant_id,
277                )| AdminAuditRow {
278                    microapp_id,
279                    method,
280                    capability,
281                    args_hash,
282                    started_at_ms: started_at_ms as u64,
283                    result: AdminAuditResult::from_str(&result),
284                    duration_ms: duration_ms as u64,
285                    tenant_id,
286                },
287            )
288            .collect();
289
290        let next_offset_value = filter.offset.saturating_add(entries.len());
291        let has_more = (next_offset_value as u64) < total;
292        let next_offset = if has_more {
293            Some(next_offset_value)
294        } else {
295            None
296        };
297
298        Ok(AuditTailPage {
299            entries,
300            total,
301            has_more,
302            next_offset,
303        })
304    }
305
306    /// Convenience tail bound to a single tenant.
307    /// Equivalent to `tail()` with `tenant_id = Some(tenant)`,
308    /// `since_ms`, and `limit` set; other filters left empty.
309    /// Used by the `nexo/admin/audit/tail_for_tenant` CLI/RPC
310    /// subcommand and by SaaS billing pipelines.
311    pub async fn tail_for_tenant(
312        &self,
313        tenant_id: &str,
314        since_ms: Option<u64>,
315        limit: usize,
316    ) -> anyhow::Result<Vec<AdminAuditRow>> {
317        let page = self
318            .tail(&AuditTailFilter {
319                tenant_id: Some(tenant_id.to_string()),
320                since_ms,
321                limit: limit.max(1),
322                ..Default::default()
323            })
324            .await?;
325        Ok(page.entries)
326    }
327
328    /// Test-only — read all rows.
329    #[cfg(test)]
330    pub(crate) async fn all_rows(&self) -> anyhow::Result<Vec<AdminAuditRow>> {
331        let rows: Vec<(
332            String,
333            String,
334            String,
335            String,
336            i64,
337            String,
338            Option<i32>,
339            i64,
340            Option<String>,
341        )> = sqlx::query_as(
342            "SELECT microapp_id, method, capability, args_hash, started_at_ms, \
343             result, error_code, duration_ms, tenant_id FROM microapp_admin_audit \
344             ORDER BY started_at_ms ASC",
345        )
346        .fetch_all(&self.pool)
347        .await?;
348        Ok(rows
349            .into_iter()
350            .map(
351                |(
352                    microapp_id,
353                    method,
354                    capability,
355                    args_hash,
356                    started_at_ms,
357                    result,
358                    _err,
359                    duration_ms,
360                    tenant_id,
361                )| AdminAuditRow {
362                    microapp_id,
363                    method,
364                    capability,
365                    args_hash,
366                    started_at_ms: started_at_ms as u64,
367                    result: match result.as_str() {
368                        "ok" => AdminAuditResult::Ok,
369                        "denied" => AdminAuditResult::Denied,
370                        _ => AdminAuditResult::Error,
371                    },
372                    duration_ms: duration_ms as u64,
373                    tenant_id,
374                },
375            )
376            .collect())
377    }
378}
379
380/// Render audit rows as a fixed-width text
381/// table for the `nexo microapp admin audit tail` CLI.
382/// Columns: `started_at` (ISO-8601 UTC) · `microapp` · `method` ·
383/// `result` · `dur_ms` · `args_hash[..8]`. Uses a stable column
384/// order so operators can grep / awk the output.
385pub fn format_rows_as_table(rows: &[AdminAuditRow]) -> String {
386    use std::fmt::Write;
387    let mut out = String::new();
388    writeln!(
389        out,
390        "{:<24}  {:<20}  {:<40}  {:<7}  {:>7}  {:<10}",
391        "started_at", "microapp", "method", "result", "dur_ms", "args[..8]",
392    )
393    .ok();
394    writeln!(
395        out,
396        "{}",
397        "-".repeat(24 + 2 + 20 + 2 + 40 + 2 + 7 + 2 + 7 + 2 + 10)
398    )
399    .ok();
400    for row in rows {
401        let ts = chrono::DateTime::<chrono::Utc>::from_timestamp_millis(row.started_at_ms as i64)
402            .map(|d| d.format("%Y-%m-%dT%H:%M:%SZ").to_string())
403            .unwrap_or_else(|| row.started_at_ms.to_string());
404        let hash_short: String = row.args_hash.chars().take(8).collect();
405        writeln!(
406            out,
407            "{:<24}  {:<20.20}  {:<40.40}  {:<7}  {:>7}  {:<10}",
408            ts,
409            row.microapp_id,
410            row.method,
411            row.result.as_str(),
412            row.duration_ms,
413            hash_short,
414        )
415        .ok();
416    }
417    out
418}
419
420/// Render audit rows as a JSON array for
421/// machine-readable consumption (`--format json`). Pretty-prints
422/// for human review; pipelines that need NDJSON can stream
423/// `serde_json::to_string(&row)` per row instead.
424pub fn format_rows_as_json(rows: &[AdminAuditRow]) -> String {
425    serde_json::to_string_pretty(rows).unwrap_or_else(|_| "[]".into())
426}
427
428#[async_trait]
429impl AdminAuditWriter for SqliteAdminAuditWriter {
430    async fn append(&self, row: AdminAuditRow) {
431        let result_str = row.result.as_str();
432        let res = sqlx::query(
433            "INSERT INTO microapp_admin_audit
434                (microapp_id, method, capability, args_hash, started_at_ms,
435                 result, error_code, duration_ms, tenant_id)
436             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
437        )
438        .bind(&row.microapp_id)
439        .bind(&row.method)
440        .bind(&row.capability)
441        .bind(&row.args_hash)
442        .bind(row.started_at_ms as i64)
443        .bind(result_str)
444        .bind::<Option<i32>>(None)
445        .bind(row.duration_ms as i64)
446        .bind(row.tenant_id.as_deref())
447        .execute(&self.pool)
448        .await;
449        if let Err(e) = res {
450            tracing::warn!(
451                microapp_id = %row.microapp_id,
452                method = %row.method,
453                error = %e,
454                "admin audit append failed; row dropped",
455            );
456        }
457    }
458}
459
460// Read-side trait so the dispatcher can
461// wire the same SQLite-backed writer for both writes (audit
462// append) and reads (admin RPC tail) without two separate
463// connection pools.
464#[async_trait]
465impl AdminAuditReader for SqliteAdminAuditWriter {
466    async fn tail(&self, filter: &AuditTailFilter) -> anyhow::Result<AuditTailPage> {
467        SqliteAdminAuditWriter::tail(self, filter).await
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    fn sample_row(microapp_id: &str, started_at_ms: u64) -> AdminAuditRow {
476        AdminAuditRow {
477            microapp_id: microapp_id.into(),
478            method: "nexo/admin/agents/list".into(),
479            capability: "agents_crud".into(),
480            args_hash: "abc".into(),
481            started_at_ms,
482            result: AdminAuditResult::Ok,
483            duration_ms: 5,
484            tenant_id: None,
485        }
486    }
487
488    fn sample_row_with_tenant(
489        microapp_id: &str,
490        started_at_ms: u64,
491        tenant_id: &str,
492    ) -> AdminAuditRow {
493        AdminAuditRow {
494            tenant_id: Some(tenant_id.into()),
495            ..sample_row(microapp_id, started_at_ms)
496        }
497    }
498
499    #[tokio::test]
500    async fn sqlite_writer_creates_table_idempotent() {
501        let dir = tempfile::tempdir().unwrap();
502        let path = dir.path().join("audit.db");
503        let _w1 = SqliteAdminAuditWriter::open(&path).await.unwrap();
504        // Re-open same path — DDL should not error.
505        let _w2 = SqliteAdminAuditWriter::open(&path).await.unwrap();
506    }
507
508    #[tokio::test]
509    async fn sqlite_writer_appends_row_and_reads_back() {
510        let writer = SqliteAdminAuditWriter::open_memory().await.unwrap();
511        writer.append(sample_row("agent-creator", 1_000_000)).await;
512        let rows = writer.all_rows().await.unwrap();
513        assert_eq!(rows.len(), 1);
514        assert_eq!(rows[0].microapp_id, "agent-creator");
515        assert_eq!(rows[0].method, "nexo/admin/agents/list");
516        assert_eq!(rows[0].args_hash, "abc");
517    }
518
519    #[tokio::test]
520    async fn sqlite_writer_swallows_errors_on_closed_pool() {
521        let writer = SqliteAdminAuditWriter::open_memory().await.unwrap();
522        writer.pool.close().await;
523        // Should NOT panic — the warn-log path swallows.
524        writer.append(sample_row("a", 1)).await;
525    }
526
527    #[tokio::test]
528    async fn sweep_retention_deletes_old_rows_by_age() {
529        let writer = SqliteAdminAuditWriter::open_memory().await.unwrap();
530        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
531        let day_ms: u64 = 86_400 * 1000;
532        // 100d: older than 60d retention → deleted
533        // 30d: within retention → kept
534        // 1d: within retention → kept
535        writer.append(sample_row("a", now_ms - 100 * day_ms)).await;
536        writer.append(sample_row("a", now_ms - 30 * day_ms)).await;
537        writer.append(sample_row("a", now_ms - day_ms)).await;
538        let deleted = writer.sweep_retention(60, 1_000_000).await.unwrap();
539        assert_eq!(deleted, 1, "only the 100-day-old row should age out");
540        let rows = writer.all_rows().await.unwrap();
541        assert_eq!(rows.len(), 2);
542    }
543
544    #[tokio::test]
545    async fn tail_filters_by_microapp_id_and_orders_desc() {
546        let writer = SqliteAdminAuditWriter::open_memory().await.unwrap();
547        writer.append(sample_row("a", 1_000)).await;
548        writer.append(sample_row("b", 2_000)).await;
549        writer.append(sample_row("a", 3_000)).await;
550        let rows = writer
551            .tail(&AuditTailFilter {
552                microapp_id: Some("a".into()),
553                limit: 50,
554                ..Default::default()
555            })
556            .await
557            .unwrap()
558            .entries;
559        assert_eq!(rows.len(), 2);
560        assert_eq!(rows[0].started_at_ms, 3_000, "newest first");
561        assert_eq!(rows[1].started_at_ms, 1_000);
562    }
563
564    #[tokio::test]
565    async fn tail_filters_by_method_and_result_and_since() {
566        let writer = SqliteAdminAuditWriter::open_memory().await.unwrap();
567        let mut row1 = sample_row("a", 1_000);
568        row1.method = "nexo/admin/agents/list".into();
569        row1.result = AdminAuditResult::Ok;
570        let mut row2 = sample_row("a", 2_000);
571        row2.method = "nexo/admin/agents/list".into();
572        row2.result = AdminAuditResult::Denied;
573        let mut row3 = sample_row("a", 3_000);
574        row3.method = "nexo/admin/credentials/register".into();
575        row3.result = AdminAuditResult::Ok;
576        writer.append(row1).await;
577        writer.append(row2).await;
578        writer.append(row3).await;
579
580        let rows = writer
581            .tail(&AuditTailFilter {
582                method: Some("nexo/admin/agents/list".into()),
583                result: Some(AdminAuditResult::Denied),
584                limit: 50,
585                ..Default::default()
586            })
587            .await
588            .unwrap()
589            .entries;
590        assert_eq!(rows.len(), 1);
591        assert_eq!(rows[0].started_at_ms, 2_000);
592
593        let recent = writer
594            .tail(&AuditTailFilter {
595                since_ms: Some(2_500),
596                limit: 50,
597                ..Default::default()
598            })
599            .await
600            .unwrap()
601            .entries;
602        assert_eq!(recent.len(), 1);
603        assert_eq!(recent[0].started_at_ms, 3_000);
604    }
605
606    #[tokio::test]
607    async fn tail_respects_limit() {
608        let writer = SqliteAdminAuditWriter::open_memory().await.unwrap();
609        for i in 0..10 {
610            writer.append(sample_row("a", i * 100)).await;
611        }
612        let rows = writer
613            .tail(&AuditTailFilter {
614                limit: 3,
615                ..Default::default()
616            })
617            .await
618            .unwrap()
619            .entries;
620        assert_eq!(rows.len(), 3);
621    }
622
623    #[test]
624    fn format_table_includes_header_and_rows() {
625        let rows = vec![AdminAuditRow {
626            microapp_id: "agent-creator".into(),
627            method: "nexo/admin/agents/list".into(),
628            capability: "agents_crud".into(),
629            args_hash: "abcdef0123456789".into(),
630            started_at_ms: 1_700_000_000_000,
631            result: AdminAuditResult::Ok,
632            duration_ms: 12,
633            tenant_id: None,
634        }];
635        let out = format_rows_as_table(&rows);
636        assert!(out.contains("started_at"), "header present");
637        assert!(out.contains("microapp"));
638        assert!(out.contains("agent-creator"));
639        assert!(out.contains("nexo/admin/agents/list"));
640        assert!(out.contains("ok"));
641        assert!(out.contains("abcdef01"), "hash truncated to 8 chars");
642        assert!(!out.contains("0123456789"), "full hash should NOT appear");
643    }
644
645    #[test]
646    fn format_json_round_trips() {
647        let rows = vec![AdminAuditRow {
648            microapp_id: "a".into(),
649            method: "nexo/admin/echo".into(),
650            capability: "echo".into(),
651            args_hash: "h".into(),
652            started_at_ms: 42,
653            result: AdminAuditResult::Denied,
654            duration_ms: 1,
655            tenant_id: None,
656        }];
657        let json = format_rows_as_json(&rows);
658        let back: Vec<AdminAuditRow> = serde_json::from_str(&json).unwrap();
659        assert_eq!(back, rows);
660    }
661
662    #[tokio::test]
663    async fn tenant_id_round_trips_through_insert_and_read() {
664        let writer = SqliteAdminAuditWriter::open_memory().await.unwrap();
665        writer
666            .append(sample_row_with_tenant("a", 1_000, "acme"))
667            .await;
668        writer.append(sample_row("a", 2_000)).await;
669        let rows = writer.all_rows().await.unwrap();
670        assert_eq!(rows.len(), 2);
671        let tenant_row = rows.iter().find(|r| r.started_at_ms == 1_000).unwrap();
672        assert_eq!(tenant_row.tenant_id.as_deref(), Some("acme"));
673        let null_row = rows.iter().find(|r| r.started_at_ms == 2_000).unwrap();
674        assert_eq!(null_row.tenant_id, None);
675    }
676
677    #[tokio::test]
678    async fn tail_filters_by_tenant_id_excludes_null_rows() {
679        let writer = SqliteAdminAuditWriter::open_memory().await.unwrap();
680        writer
681            .append(sample_row_with_tenant("a", 1_000, "acme"))
682            .await;
683        writer
684            .append(sample_row_with_tenant("a", 2_000, "globex"))
685            .await;
686        writer.append(sample_row("a", 3_000)).await;
687        let rows = writer
688            .tail(&AuditTailFilter {
689                tenant_id: Some("acme".into()),
690                limit: 50,
691                ..Default::default()
692            })
693            .await
694            .unwrap()
695            .entries;
696        assert_eq!(rows.len(), 1);
697        assert_eq!(rows[0].tenant_id.as_deref(), Some("acme"));
698        assert_eq!(rows[0].started_at_ms, 1_000);
699    }
700
701    #[tokio::test]
702    async fn tail_for_tenant_convenience_matches_explicit_filter() {
703        let writer = SqliteAdminAuditWriter::open_memory().await.unwrap();
704        writer
705            .append(sample_row_with_tenant("a", 1_000, "acme"))
706            .await;
707        writer
708            .append(sample_row_with_tenant("a", 2_000, "acme"))
709            .await;
710        writer
711            .append(sample_row_with_tenant("a", 3_000, "globex"))
712            .await;
713        let rows = writer.tail_for_tenant("acme", None, 50).await.unwrap();
714        assert_eq!(rows.len(), 2);
715        // newest first
716        assert_eq!(rows[0].started_at_ms, 2_000);
717        assert_eq!(rows[1].started_at_ms, 1_000);
718        for r in &rows {
719            assert_eq!(r.tenant_id.as_deref(), Some("acme"));
720        }
721    }
722
723    #[tokio::test]
724    async fn tail_for_tenant_combines_with_since_ms() {
725        let writer = SqliteAdminAuditWriter::open_memory().await.unwrap();
726        writer
727            .append(sample_row_with_tenant("a", 1_000, "acme"))
728            .await;
729        writer
730            .append(sample_row_with_tenant("a", 5_000, "acme"))
731            .await;
732        let rows = writer
733            .tail_for_tenant("acme", Some(2_500), 50)
734            .await
735            .unwrap();
736        assert_eq!(rows.len(), 1);
737        assert_eq!(rows[0].started_at_ms, 5_000);
738    }
739
740    #[tokio::test]
741    async fn tail_for_tenant_clamps_limit_floor_to_one() {
742        let writer = SqliteAdminAuditWriter::open_memory().await.unwrap();
743        writer
744            .append(sample_row_with_tenant("a", 1_000, "acme"))
745            .await;
746        let rows = writer.tail_for_tenant("acme", None, 0).await.unwrap();
747        assert_eq!(
748            rows.len(),
749            1,
750            "limit=0 should be clamped to 1, not return empty"
751        );
752    }
753
754    #[tokio::test]
755    async fn ddl_idempotent_when_tenant_id_already_present() {
756        // Open + close + re-open the same DB to exercise the
757        // ALTER TABLE duplicate-column suppression path.
758        let dir = tempfile::tempdir().unwrap();
759        let path = dir.path().join("audit.db");
760        let w1 = SqliteAdminAuditWriter::open(&path).await.unwrap();
761        w1.append(sample_row_with_tenant("a", 1, "acme")).await;
762        drop(w1);
763        let w2 = SqliteAdminAuditWriter::open(&path).await.unwrap();
764        let rows = w2.all_rows().await.unwrap();
765        assert_eq!(rows.len(), 1);
766        assert_eq!(rows[0].tenant_id.as_deref(), Some("acme"));
767    }
768
769    #[tokio::test]
770    async fn sweep_retention_caps_to_max_rows_drops_oldest() {
771        let writer = SqliteAdminAuditWriter::open_memory().await.unwrap();
772        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
773        for i in 0..10u64 {
774            writer
775                .append(sample_row("a", now_ms - (10 - i) * 1000))
776                .await;
777        }
778        // Retention generous (none deleted by age); max_rows=3 →
779        // 7 oldest deleted.
780        let deleted = writer.sweep_retention(365, 3).await.unwrap();
781        assert_eq!(deleted, 7);
782        let rows = writer.all_rows().await.unwrap();
783        assert_eq!(rows.len(), 3);
784        // Most recent retained.
785        for row in &rows {
786            assert!(row.started_at_ms >= now_ms - 3 * 1000);
787        }
788    }
789
790    /// Pagination contract: 75 rows
791    /// total, page-size 50, two pages should return 50 + 25 with
792    /// the right `has_more` / `next_offset` markers.
793    #[tokio::test]
794    async fn tail_returns_paged_response_with_total_and_has_more() {
795        let writer = SqliteAdminAuditWriter::open_memory().await.unwrap();
796        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
797        for i in 0..75u64 {
798            // Subtract `i * 1000` so older rows have smaller
799            // `started_at_ms` and the DESC order is
800            // deterministic.
801            writer
802                .append(sample_row("agent-creator", now_ms - i * 1000))
803                .await;
804        }
805
806        // Page 1.
807        let page1 = writer
808            .tail(&AuditTailFilter {
809                limit: 50,
810                offset: 0,
811                ..Default::default()
812            })
813            .await
814            .unwrap();
815        assert_eq!(page1.entries.len(), 50);
816        assert_eq!(page1.total, 75);
817        assert!(page1.has_more);
818        assert_eq!(page1.next_offset, Some(50));
819
820        // Page 2.
821        let page2 = writer
822            .tail(&AuditTailFilter {
823                limit: 50,
824                offset: 50,
825                ..Default::default()
826            })
827            .await
828            .unwrap();
829        assert_eq!(page2.entries.len(), 25);
830        assert_eq!(page2.total, 75);
831        assert!(!page2.has_more);
832        assert_eq!(page2.next_offset, None);
833
834        // Combined entries should reconstruct the full set.
835        let combined: Vec<u64> = page1
836            .entries
837            .iter()
838            .chain(page2.entries.iter())
839            .map(|r| r.started_at_ms)
840            .collect();
841        let mut expected: Vec<u64> = (0..75u64).map(|i| now_ms - i * 1000).collect();
842        expected.sort_by(|a, b| b.cmp(a)); // DESC
843        assert_eq!(combined, expected);
844    }
845}