181 lines
5.1 KiB
Go
181 lines
5.1 KiB
Go
|
|
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)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|