- 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)
155 lines
4.2 KiB
Go
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)
|
|
}
|
|
}
|