1use 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#[derive(Debug, Clone)]
38pub struct SqliteAdminAuditWriter {
39 pool: SqlitePool,
40}
41
42impl SqliteAdminAuditWriter {
43 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 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 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 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 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 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 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 pub async fn tail(&self, filter: &AuditTailFilter) -> anyhow::Result<AuditTailPage> {
184 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 let effective_limit = if filter.limit == 0 {
212 50
213 } else {
214 filter.limit.min(500)
215 };
216
217 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 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 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 #[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
380pub 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
420pub 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#[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 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 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 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 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 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 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 for row in &rows {
786 assert!(row.started_at_ms >= now_ms - 3 * 1000);
787 }
788 }
789
790 #[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 writer
802 .append(sample_row("agent-creator", now_ms - i * 1000))
803 .await;
804 }
805
806 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 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 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)); assert_eq!(combined, expected);
844 }
845}