package emission_test import ( "os" "path/filepath" "sort" "testing" "gopkg.in/yaml.v3" "github.com/netkingdom/flex-auth/internal/emission" "github.com/netkingdom/flex-auth/pkg/api" ) func deny(id, tenant string) api.DecisionEnvelope { return api.DecisionEnvelope{ ID: id, Effect: api.DecisionEffectDeny, Resource: api.ResourceRef{ID: "t1", Type: "tenant", System: "tenant-engine"}, Binding: &api.DecisionBinding{Tenant: tenant}, } } func readEvents(t *testing.T, dir string) []map[string]any { t.Helper() data, err := os.ReadFile(filepath.Join(dir, "outbox.jsonl")) if err != nil { t.Fatal(err) } var events []map[string]any for _, line := range splitLines(data) { var e struct { Event map[string]any `json:"event"` } if err := yaml.Unmarshal(line, &e); err != nil { t.Fatal(err) } events = append(events, e.Event) } return events } func splitLines(data []byte) [][]byte { var lines [][]byte start := 0 for i, b := range data { if b == '\n' { lines = append(lines, data[start:i]) start = i + 1 } } return lines } func TestCommittedEventsCarryAuditCoreRequiredFields(t *testing.T) { dir := t.TempDir() outbox, err := emission.Open(dir, "flex-auth.tenant-engine") if err != nil { t.Fatal(err) } // Identical checks share a content-digest decision id. if err := outbox.AppendBatch([]api.DecisionEnvelope{deny("decision:aa", ""), deny("decision:aa", "tenant:acme")}); err != nil { t.Fatal(err) } outbox.Close() events := readEvents(t, dir) if len(events) != 2 { t.Fatalf("events = %d; want 2", len(events)) } // audit-core normalize() rejects an event missing any of these. for _, event := range events { for _, key := range []string{"id", "type", "source", "subject", "tenant", "correlation_id", "occurred_at", "data"} { if value, ok := event[key]; !ok || value == "" || value == nil { t.Fatalf("event missing %q: %v", key, event) } } if event["type"] != "flex-auth.decision.deny" || event["correlation_id"] != "decision:aa" { t.Fatalf("event = %v", event) } } if events[0]["id"] == events[1]["id"] { t.Fatal("two decisions share an event id; audit-core idempotency would merge them") } if events[0]["tenant"] != emission.PlatformTenant || events[1]["tenant"] != "tenant:acme" { t.Fatalf("tenants = %v, %v", events[0]["tenant"], events[1]["tenant"]) } } func TestCountsSurviveRestartAndTornTailIsDropped(t *testing.T) { dir := t.TempDir() outbox, err := emission.Open(dir, "flex-auth.test") if err != nil { t.Fatal(err) } if err := outbox.Append(deny("decision:1", "")); err != nil { t.Fatal(err) } outbox.Close() // A crash mid-write leaves a line with no newline. f, _ := os.OpenFile(filepath.Join(dir, "outbox.jsonl"), os.O_APPEND|os.O_WRONLY, 0) f.WriteString(`{"seq":2,"event":{"type":"flex-auth.decision.de`) f.Close() outbox, err = emission.Open(dir, "flex-auth.test") if err != nil { t.Fatalf("reopen with torn tail: %v", err) } if err := outbox.Append(deny("decision:3", "")); err != nil { t.Fatal(err) } outbox.Close() outbox, err = emission.Open(dir, "flex-auth.test") if err != nil { t.Fatalf("reopen after append past torn tail: %v", err) } defer outbox.Close() if got := outbox.Status().Committed["flex-auth.decision.deny"]; got != 2 { t.Fatalf("committed deny = %d; want 2 (torn write never counted)", got) } } func TestReleasedUncommittedIsReportedPerClass(t *testing.T) { outbox, err := emission.Open(t.TempDir(), "flex-auth.test") if err != nil { t.Fatal(err) } defer outbox.Close() outbox.NoteReleasedUncommitted(api.DecisionEffectRedact) status := outbox.Status() if status.ReleasedUncommitted["flex-auth.decision.redact"] != 1 { t.Fatalf("status = %+v", status) } if len(status.Committed) != len(api.DecisionEffects()) { t.Fatalf("status must report every class, zero included: %+v", status.Committed) } } // cadence.yaml must classify exactly the effect vocabulary, so a new effect // cannot ship unclassified (FLEX-WP-0031-T03 gate). func TestCadenceClassifiesEveryEffect(t *testing.T) { data, err := os.ReadFile(filepath.Join("..", "..", "cadence.yaml")) if err != nil { t.Fatal(err) } var cadence struct { Classes map[string]struct { Action string `yaml:"action"` EvidenceClass string `yaml:"evidence_class"` } `yaml:"classes"` } if err := yaml.Unmarshal(data, &cadence); err != nil { t.Fatal(err) } var declared, want []string for name, class := range cadence.Classes { if class.EvidenceClass != "load-bearing" { continue } declared = append(declared, name) if class.Action != emission.Class(api.DecisionEffect(name)) { t.Errorf("class %q action = %q; want %q", name, class.Action, emission.Class(api.DecisionEffect(name))) } } for _, effect := range api.DecisionEffects() { want = append(want, string(effect)) } sort.Strings(declared) sort.Strings(want) if len(declared) != len(want) { t.Fatalf("cadence.yaml load-bearing classes %v; want the effect vocabulary %v", declared, want) } for i := range want { if declared[i] != want[i] { t.Fatalf("cadence.yaml load-bearing classes %v; want %v", declared, want) } } }