// SPDX-License-Identifier: GPL-2.0-or-later // Copyright (C) 2026 Bryan Joshua Pedini package main import ( "context" "database/sql" "fmt" "log/slog" "math" "strings" "testing" "time" sqlite3 "modernc.org/sqlite/lib" ) type opRow struct { token, origin, operation, time string } func readOperations(t *testing.T, store *AuditStore) []opRow { t.Helper() rows, err := store.db.Query(`SELECT token, origin, operation, time FROM operations ORDER BY rowid`) if err != nil { t.Fatal(err) } defer rows.Close() var out []opRow for rows.Next() { var r opRow if err := rows.Scan(&r.token, &r.origin, &r.operation, &r.time); err != nil { t.Fatal(err) } out = append(out, r) } return out } func newTestAudit(t *testing.T, now func() time.Time) *AuditStore { t.Helper() db := openTestDB(t) mustExec(t, db, `INSERT INTO tokens VALUES ('tok', '', 1, '2026-01-01T00:00:00Z', NULL)`) mustExec(t, db, `INSERT INTO tokens VALUES ('old', '', 0, '2026-01-01T00:00:00Z', '2026-01-02T00:00:00Z')`) return NewAuditStore(db, traceDiscard(), now) } func TestRecordOperationExactValues(t *testing.T) { t.Parallel() local := time.FixedZone("CET", 3600) store := newTestAudit(t, func() time.Time { return time.Date(2026, 10, 5, 13, 14, 15, 160000000, local) }) if err := store.RecordOperation(context.Background(), "tok", "203.0.113.7", operationGetContainers); err != nil { t.Fatal(err) } got := readOperations(t, store) want := []opRow{{"tok", "203.0.113.7", "GET containers", "2026-10-05T12:14:15.16Z"}} if len(got) != 1 || got[0] != want[0] { t.Fatalf("got %+v, want %+v", got, want) } if !strings.HasSuffix(got[0].time, "Z") { t.Fatal("time not UTC") } if _, err := time.Parse(time.RFC3339Nano, got[0].time); err != nil { t.Fatal(err) } } func TestRecordOperationMultipleRowsRetained(t *testing.T) { t.Parallel() store := newTestAudit(t, newFakeClock(t0, time.Second).Now) inputs := []opRow{ {"tok", "127.0.0.1", operationGetContainers, ""}, {"tok", "::1", operationGetContainers, ""}, {"old", "10.0.0.1", operationGetContainers, ""}, // inactive tokens still satisfy the foreign key {"tok", "127.0.0.1", operationGetContainers, ""}, } for _, in := range inputs { if err := store.RecordOperation(context.Background(), in.token, in.origin, in.operation); err != nil { t.Fatal(err) } } got := readOperations(t, store) if len(got) != len(inputs) { t.Fatalf("rows = %d", len(got)) } for i, r := range got { wantTime := formatTime(t0.Add(time.Duration(i) * time.Second)) if r.token != inputs[i].token || r.origin != inputs[i].origin || r.operation != "GET containers" || r.time != wantTime { t.Errorf("row %d = %+v (want time %s)", i, r, wantTime) } } } func TestRecordOperationForeignKey(t *testing.T) { t.Parallel() store := newTestAudit(t, time.Now) for _, tok := range []string{"missing", "TOK", "tok ", ""} { err := store.RecordOperation(context.Background(), tok, "127.0.0.1", operationGetContainers) if !isSQLiteCode(err, sqlite3.SQLITE_CONSTRAINT_FOREIGNKEY) || !strings.Contains(err.Error(), "insert operation") { t.Errorf("token %q: expected foreign key error, got %v", tok, err) } } if n := len(readOperations(t, store)); n != 0 { t.Fatalf("rows = %d", n) } } // insertOperations inserts raw operations rows (token, time) in the given order, origin 10.0.0. func insertOperations(t *testing.T, db *sql.DB, rows ...[2]string) { t.Helper() for i, r := range rows { mustExec(t, db, `INSERT INTO operations (token, origin, operation, time) VALUES (?, ?, ?, ?)`, r[0], fmt.Sprintf("10.0.0.%d", i+1), operationGetContainers, r[1]) } } // opOrigins renders the origins of ops (which identify the inserted rows) for compact assertions func opOrigins(ops []Operation) string { var out []string for _, op := range ops { out = append(out, strings.TrimPrefix(op.Origin, "10.0.0.")) } return strings.Join(out, ",") } func listOps(t *testing.T, store *AuditStore, q OperationQuery) []Operation { t.Helper() ops, err := store.ListOperations(context.Background(), q) if err != nil { t.Fatal(err) } return ops } func TestListOperationsEmpty(t *testing.T) { t.Parallel() store := newTestAudit(t, time.Now) since := t0 for _, q := range []OperationQuery{{}, {Limit: 10}, {Since: &since}, {Limit: 1, Since: &since}} { ops := listOps(t, store, q) if ops == nil || len(ops) != 0 { t.Fatalf("%+v: expected empty non-nil slice, got %#v", q, ops) } } } // orderingRows are inserted out of chronological order, with fraction lengths that break lexical ordering, // non-UTC offsets and equal instants written differently (ties are broken by insertion order) var orderingRows = [][2]string{ {"tok", "2026-01-01T00:00:00.1Z"}, // 1 {"tok", "2026-01-01T00:00:00Z"}, // 2 {"old", "2026-01-01T00:00:00.12Z"}, // 3 {"tok", "2026-01-01T00:00:00.5Z"}, // 4 {"tok", "2026-01-01T01:00:00.5+01:00"}, // 5: same instant as 4 {"old", "2025-12-31T23:59:59.999999999Z"}, // 6 {"tok", "2026-01-01T00:00:00.500000000Z"}, // 7: same instant as 4 {"tok", "2026-01-01T00:00:00.000000001Z"}, // 8 {"tok", "2026-01-01T00:00:01Z"}, // 9 {"tok", "2026-01-01T00:00:00.99999999Z"}, // 10 {"tok", "2025-12-31T19:00:00.25-05:00"}, // 11: 2026-01-01T00:00:00.25Z {"old", "2026-01-01T00:00:00.100000000Z"}, // 12: same instant as 1 {"tok", "2026-01-01T00:00:00.0000000009Z"}, // 13: sub-nanosecond digits are truncated by Go: ties with 2 {"tok", "2026-01-01T00:00:00.123456789012Z"}, // 14: 2026-01-01T00:00:00.123456789Z } // orderingSorted is the expected (time, rowid) order of orderingRows const orderingSorted = "6,2,13,8,1,12,3,14,11,4,5,7,10,9" func TestListOperationsOrdering(t *testing.T) { t.Parallel() store := newTestAudit(t, time.Now) insertOperations(t, store.db, orderingRows...) ops := listOps(t, store, OperationQuery{}) if got := opOrigins(ops); got != orderingSorted { t.Fatalf("order = %s, want %s", got, orderingSorted) } // Values are returned exactly as stored, with the parsed time in UTC for _, op := range ops { i := int(op.RowID) - 1 if op.Token != orderingRows[i][0] || op.TimeText != orderingRows[i][1] || op.Operation != "GET containers" || op.Origin != fmt.Sprintf("10.0.0.%d", i+1) || op.Time.Location() != time.UTC { t.Fatalf("row %d = %+v", op.RowID, op) } } } func TestListOperationsLimit(t *testing.T) { t.Parallel() store := newTestAudit(t, time.Now) insertOperations(t, store.db, orderingRows...) sorted := strings.Split(orderingSorted, ",") for _, limit := range []int{1, 2, 3, 5, 13, 14, 15, 1000, math.MaxInt} { want := sorted if limit < len(sorted) { want = sorted[len(sorted)-limit:] } if got := opOrigins(listOps(t, store, OperationQuery{Limit: limit})); got != strings.Join(want, ",") { t.Errorf("limit %d: got %s, want %s", limit, got, strings.Join(want, ",")) } } } func TestListOperationsSince(t *testing.T) { t.Parallel() store := newTestAudit(t, time.Now) insertOperations(t, store.db, orderingRows...) at := func(s string) *time.Time { v, err := time.Parse(time.RFC3339Nano, s) if err != nil { t.Fatal(err) } return &v } cases := []struct { since *time.Time limit int want string }{ // The boundary is inclusive, including for the same instant written with another offset or fraction length {at("2026-01-01T00:00:00.5Z"), 0, "4,5,7,10,9"}, {at("2026-01-01T00:00:00.500000001Z"), 0, "10,9"}, {at("2026-01-01T00:00:00.499999999Z"), 0, "4,5,7,10,9"}, {at("2026-01-01T03:00:00.5+03:00"), 0, "4,5,7,10,9"}, {at("2026-01-01T00:00:01Z"), 0, "9"}, {at("2026-01-01T00:00:01.000000001Z"), 0, ""}, {at("2000-01-01T00:00:00Z"), 0, orderingSorted}, // Since and limit combine: the latest rows among those in the window {at("2026-01-01T00:00:00.5Z"), 2, "10,9"}, {at("2026-01-01T00:00:00.5Z"), 100, "4,5,7,10,9"}, } for _, c := range cases { if got := opOrigins(listOps(t, store, OperationQuery{Since: c.since, Limit: c.limit})); got != c.want { t.Errorf("since %s limit %d: got %q, want %q", c.since, c.limit, got, c.want) } } } func TestListOperationsMany(t *testing.T) { t.Parallel() store := newTestAudit(t, newFakeClock(t0, time.Millisecond).Now) for i := 0; i < 300; i++ { if err := store.RecordOperation(context.Background(), "tok", fmt.Sprintf("10.0.%d.%d", i/256, i%256), operationGetContainers); err != nil { t.Fatal(err) } } all := listOps(t, store, OperationQuery{}) last := listOps(t, store, OperationQuery{Limit: 10}) if len(all) != 300 || len(last) != 10 { t.Fatalf("len = %d, %d", len(all), len(last)) } for i, op := range all { if op.RowID != int64(i+1) || op.TimeText != formatTime(t0.Add(time.Duration(i)*time.Millisecond)) { t.Fatalf("row %d = %+v", i, op) } } for i, op := range last { if op.RowID != all[290+i].RowID || op.TimeText != all[290+i].TimeText { t.Fatalf("latest %d = %+v, want %+v", i, op, all[290+i]) } } } func TestListOperationsErrors(t *testing.T) { t.Parallel() // Query error: closed database store := newTestAudit(t, time.Now) store.db.Close() if _, err := store.ListOperations(context.Background(), OperationQuery{}); err == nil || !strings.Contains(err.Error(), "query operations") { t.Fatalf("got %v", err) } // Unparseable stored times (rows written by something else) are errors, never silently misordered for _, bad := range []string{"garbage", "", "2026-01-01 00:00:00", "2026-01-01T00:00:00", "1767225600"} { store := newTestAudit(t, time.Now) insertOperations(t, store.db, [2]string{"tok", "2026-01-01T00:00:00Z"}, [2]string{"tok", bad}) for _, q := range []OperationQuery{{}, {Limit: 1}} { if _, err := store.ListOperations(context.Background(), q); err == nil || !strings.HasPrefix(err.Error(), "read operation row: parse stored timestamp") { t.Errorf("time %q: got %v", bad, err) } } } // Scan error: a NULL value in a table pre-created without constraints _, db := newLegacySchemaStore(t, `CREATE TABLE operations (token TEXT, origin TEXT, operation TEXT, time TEXT)`) mustExec(t, db, `INSERT INTO operations VALUES (NULL, '127.0.0.1', 'GET containers', '2026-01-01T00:00:00Z')`) store = NewAuditStore(db, traceDiscard(), time.Now) if _, err := store.ListOperations(context.Background(), OperationQuery{}); err == nil || !strings.Contains(err.Error(), "read operation row") { t.Fatalf("scan: got %v", err) } } func TestListOperationsIterationError(t *testing.T) { t.Parallel() db := openTestDB(t) mustExec(t, db, `INSERT INTO tokens VALUES ('tok', '', 1, '2026-01-01T00:00:00Z', NULL)`) for i := 0; i < 500; i++ { mustExec(t, db, `INSERT INTO operations VALUES ('tok', '127.0.0.1', 'GET containers', '2026-01-01T00:00:00Z')`) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() store := NewAuditStore(db, slog.New(cancelOnRecord{msg: "row read", cancel: cancel}), time.Now) if _, err := store.ListOperations(ctx, OperationQuery{}); err == nil || !strings.Contains(err.Error(), "iterate operation rows") { t.Fatalf("got %v", err) } } func TestListOperationsReadOnly(t *testing.T) { t.Parallel() store := newTestAudit(t, time.Now) insertOperations(t, store.db, orderingRows...) for _, table := range []string{"tokens", "operations"} { for _, op := range []string{"INSERT", "UPDATE", "DELETE"} { mustExec(t, store.db, fmt.Sprintf(`CREATE TRIGGER ro_%s_%s BEFORE %s ON %s BEGIN SELECT RAISE(ABORT, 'read-only'); END`, op, table, op, table)) } } since := t0.AddDate(-1, 0, 0) for _, q := range []OperationQuery{{}, {Limit: 3}, {Since: &since}} { listOps(t, store, q) } } func TestListOperationsTraceRedaction(t *testing.T) { t.Parallel() log, buf := newTestLogger(LevelTrace) db := openTestDB(t) const secret = "AUDIT_LIST_SECRET_0123456789abcdefghijklmno" mustExec(t, db, `INSERT INTO tokens VALUES (?, '', 1, '2026-01-01T00:00:00Z', NULL)`, secret) mustExec(t, db, `INSERT INTO operations VALUES (?, '192.0.2.1', 'GET containers', '2026-10-05T12:00:00.5Z')`, secret) store := NewAuditStore(db, log, time.Now) ops := listOps(t, store, OperationQuery{Limit: 1}) if len(ops) != 1 || ops[0].Token != secret { t.Fatalf("got %+v", ops) } out := buf.String() if strings.Contains(out, secret) || strings.Contains(out, secret[:12]) { t.Fatalf("secret leaked:\n%s", out) } for _, want := range []string{ `level=TRACE msg="function entry" func=AuditStore.ListOperations`, `level=TRACE msg="row read" table=operations rowid=1 token_id=` + tokenID(secret) + ` origin=192.0.2.1 operation="GET containers" time=2026-10-05T12:00:00.5Z`, `level=TRACE msg="function exit" func=AuditStore.ListOperations`, } { if !strings.Contains(out, want) { t.Errorf("missing %q in:\n%s", want, out) } } } func TestRecordOperationTraceRedaction(t *testing.T) { t.Parallel() log, buf := newTestLogger(LevelTrace) db := openTestDB(t) const secret = "AUDIT_SECRET_0123456789abcdefghijklmnopqrst" mustExec(t, db, `INSERT INTO tokens VALUES (?, '', 1, 'x', NULL)`, secret) store := NewAuditStore(db, log, func() time.Time { return t0 }) if err := store.RecordOperation(context.Background(), secret, "192.0.2.1", operationGetContainers); err != nil { t.Fatal(err) } out := buf.String() if strings.Contains(out, secret) { t.Fatalf("secret leaked:\n%s", out) } want := `msg="row inserted" table=operations token_id=` + tokenID(secret) + ` origin=192.0.2.1 operation="GET containers" time=2026-10-05T12:00:00.123456789Z` if !strings.Contains(out, want) { t.Fatalf("missing %q in:\n%s", want, out) } }