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) } }