NextMyDay/internal/core/audit/audit_test.go
Claus Lohmar d11e14295b feat(core): add the store, identity plumbing and the door (SPEC-003/004)
- store: SQLite (WAL, foreign keys ON, CGO-free driver) + the embedded
  migration runner; each module's migrations are name-prefixed (D-012)
- audit: the append-only audit log; the actor threads via context (R-1)
- users: the user_id/GUID/email anchors (D-013)
- config: the YAML settings file (D-035) + validation
- httpx: cookies · loopback detection · trusted-proxy XFF · CSRF · security
  headers · redaction
- middleware: correlation id · audit · rate limiting
- mail: the invite/recovery mailer seam
- server: the core DOOR — the resolution switch, the dispatch registry
  (RegisterModule), the role gates, /setup · enroll/login · recovery flows,
  the reserved `static` workflow, and the loopback-only bootstrap (D-025)
2026-10-14 10:53:51 +00:00

155 lines
4.2 KiB
Go

package audit_test
import (
"context"
"database/sql"
"path/filepath"
"testing"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
)
func newDB(t *testing.T) *sql.DB {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
return db.DB
}
func TestRecordWritesRow(t *testing.T) {
db := newDB(t)
log := audit.New(db)
log.Record(context.Background(), audit.Event{
ActorUserID: 7,
Action: "login",
Subject: "identity:3",
Detail: "totp",
ClientIP: "203.0.113.9",
Location: "GB",
CorrelationID: "corr-1",
})
var (
actor sql.NullInt64
action string
subject string
detail string
ip string
loc string
corr string
)
err := db.QueryRow(
`SELECT actor_user_id, action, subject, detail, client_ip, location, correlation_id FROM audit_log`,
).Scan(&actor, &action, &subject, &detail, &ip, &loc, &corr)
if err != nil {
t.Fatalf("query audit_log: %v", err)
}
if !actor.Valid || actor.Int64 != 7 {
t.Errorf("actor_user_id = %+v, want 7", actor)
}
if action != "login" || subject != "identity:3" || detail != "totp" {
t.Errorf("row = (%s,%s,%s)", action, subject, detail)
}
if ip != "203.0.113.9" || loc != "GB" || corr != "corr-1" {
t.Errorf("context = (%s,%s,%s)", ip, loc, corr)
}
}
func TestRecordAnonymousActorIsNull(t *testing.T) {
db := newDB(t)
log := audit.New(db)
log.Record(context.Background(), audit.Event{Action: "bootstrap"})
var actor sql.NullInt64
if err := db.QueryRow(`SELECT actor_user_id FROM audit_log`).Scan(&actor); err != nil {
t.Fatalf("query: %v", err)
}
if actor.Valid {
t.Errorf("actor_user_id = %d, want NULL for an anonymous event", actor.Int64)
}
}
// TestMetaRoundTrip pins the R-1 context seam: WithMeta/MetaFrom are lossless.
func TestMetaRoundTrip(t *testing.T) {
ctx := context.Background()
if _, ok := audit.MetaFrom(ctx); ok {
t.Fatal("MetaFrom(background) reported a meta, want none")
}
want := audit.Meta{ActorUserID: 42, ClientIP: "203.0.113.5", CorrelationID: "corr-9"}
got, ok := audit.MetaFrom(audit.WithMeta(ctx, want))
if !ok {
t.Fatal("MetaFrom(withMeta) reported none")
}
if got != want {
t.Errorf("MetaFrom = %+v, want %+v", got, want)
}
}
// TestRecordMergesContextMeta proves the door's Meta reaches the audit row
// without the service threading it by hand (R-1); explicit event values win.
func TestRecordMergesContextMeta(t *testing.T) {
db := newDB(t)
log := audit.New(db)
ctx := audit.WithMeta(context.Background(), audit.Meta{
ActorUserID: 55,
ClientIP: "198.51.100.7",
CorrelationID: "corr-abc",
})
// The event carries no context fields — all come from the Meta.
log.Record(ctx, audit.Event{Action: "login"})
// This event overrides the actor explicitly — the event value must win.
log.Record(ctx, audit.Event{Action: "identity.revoke", ActorUserID: 9})
rows, err := db.Query(`SELECT action, actor_user_id, client_ip, correlation_id FROM audit_log ORDER BY id`)
if err != nil {
t.Fatalf("query audit_log: %v", err)
}
defer rows.Close()
type row struct {
action string
actor sql.NullInt64
ip sql.NullString
corr sql.NullString
}
var got []row
for rows.Next() {
var r row
if err := rows.Scan(&r.action, &r.actor, &r.ip, &r.corr); err != nil {
t.Fatalf("scan: %v", err)
}
got = append(got, r)
}
if err := rows.Err(); err != nil {
t.Fatalf("rows: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d rows, want 2", len(got))
}
if !got[0].actor.Valid || got[0].actor.Int64 != 55 {
t.Errorf("row 0 actor = %+v, want 55 (from Meta)", got[0].actor)
}
if got[0].ip.String != "198.51.100.7" || got[0].corr.String != "corr-abc" {
t.Errorf("row 0 context = (%q,%q), want the Meta values", got[0].ip.String, got[0].corr.String)
}
if !got[1].actor.Valid || got[1].actor.Int64 != 9 {
t.Errorf("row 1 actor = %+v, want 9 (explicit event value wins)", got[1].actor)
}
if got[1].corr.String != "corr-abc" {
t.Errorf("row 1 correlation = %q, want corr-abc", got[1].corr.String)
}
}