Files
dpsapi/audit_store_test.go
T
bryanpedini 88d7d6d2b9 feat(core): add configuration, logging and sqlite persistence
DB_PATH, HTTP_ADDR, HTTP_PORT and LOG_LEVEL parsing with validation; slog text logger with a TRACE level and token redaction; embedded idempotent schema on CGO-free SQLite (WAL, busy timeout); token store (create, list, deactivate, authenticate) and append-only audit store with ordered, limited and windowed queries; shared unit test helpers.
2026-10-06 10:52:25 +02:00

374 lines
13 KiB
Go

// 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.<i>
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)
}
}