package drift import ( "context" "errors" "fmt" "os" "path/filepath" "testing" "time" ) type mockTransport struct { execOut []byte execErr error execFn func(ctx context.Context, peer string, cmd string) ([]byte, error) readOut []byte readErr error writes []writeEntry } type writeEntry struct { peer string path string content []byte mode os.FileMode } func (m *mockTransport) Exec(ctx context.Context, peer string, cmd string) ([]byte, error) { if m.execFn != nil { return m.execFn(ctx, peer, cmd) } return m.execOut, m.execErr } func (m *mockTransport) WriteFileIdempotent(ctx context.Context, peer string, path string, content []byte, mode os.FileMode) (bool, error) { m.writes = append(m.writes, writeEntry{peer, path, content, mode}) return true, nil } func (m *mockTransport) ReadFile(ctx context.Context, peer string, path string) ([]byte, error) { return m.readOut, m.readErr } func setupDriftTestEnv(t *testing.T) string { t.Helper() dir := t.TempDir() t.Setenv("ORCA_LEAD_STATE_DIR", dir) return dir } func TestParseEvent(t *testing.T) { raw := []byte(`{ "event_id":"EVT-1", "ts":"2026-01-01T00:00:00Z", "host":"peer1", "path":"/etc/traefik/dynamic/orca.yml", "status":"modified", "new_sha256":"abc", "latest_txn":"T-1234567890abcdef", "expected_sha256":"def", "drift_confirmed":true, "action":"reported", "action_result":"skipped", "operator_id":"op1" }`) e, err := ParseEvent(raw) if err != nil { t.Fatalf("ParseEvent: %v", err) } if e.EventID != "EVT-1" { t.Errorf("EventID = %q, want EVT-1", e.EventID) } if e.Host != "peer1" { t.Errorf("Host = %q, want peer1", e.Host) } if e.Path != "/etc/traefik/dynamic/orca.yml" { t.Errorf("Path = %q", e.Path) } if e.Status != StatusModified { t.Errorf("Status = %q, want modified", e.Status) } if !e.DriftConfirmed { t.Errorf("DriftConfirmed = false, want true") } if e.Action != ActionReported { t.Errorf("Action = %q, want reported", e.Action) } if e.ActionResult != ActionResultSkipped { t.Errorf("ActionResult = %q, want skipped", e.ActionResult) } } func TestParseEventInvalid(t *testing.T) { _, err := ParseEvent([]byte(`{not json`)) if err == nil { t.Fatal("expected error for invalid JSON, got nil") } } func TestMarshalEventRoundTrip(t *testing.T) { e := Event{EventID: "E1", Host: "h", Path: "/p", Status: StatusCreated, DriftConfirmed: true} raw, err := MarshalEvent(e) if err != nil { t.Fatalf("MarshalEvent: %v", err) } out, err := ParseEvent(raw) if err != nil { t.Fatalf("ParseEvent: %v", err) } if out.EventID != e.EventID || out.Host != e.Host || out.Path != e.Path || out.Status != e.Status { t.Errorf("round-trip mismatch: %+v vs %+v", out, e) } } func TestLoadConfigDefault(t *testing.T) { cfg, err := LoadConfig("") if err != nil { t.Fatalf("LoadConfig empty: %v", err) } if !cfg.Polling.Enabled { t.Errorf("Polling.Enabled = false, want true") } if cfg.Polling.DefaultInterval != 60*time.Second { t.Errorf("DefaultInterval = %v, want 60s", cfg.Polling.DefaultInterval) } if len(cfg.Paths.Critical) == 0 { t.Errorf("Critical paths empty") } found := false for _, c := range cfg.Paths.Critical { if c.Pattern == "/etc/traefik/dynamic/orca.yml" { found = true if c.Interval != 5*time.Second { t.Errorf("critical interval = %v, want 5s", c.Interval) } if !c.SystemdPathUnit { t.Errorf("SystemdPathUnit = false, want true") } } } if !found { t.Errorf("traefik critical path missing") } found = false for _, s := range cfg.Paths.Standard { if s.Pattern == "/etc/orca/actual/*" { found = true if s.Interval != 30*time.Second { t.Errorf("standard interval = %v, want 30s", s.Interval) } } } if !found { t.Errorf("/etc/orca/actual/* standard path missing") } if !cfg.Remediate.Auto { t.Errorf("Remediate.Auto = false, want true") } } func TestLoadConfigFile(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "drift.json") cfgJSON := `{ "polling": {"enabled": true, "default_interval": 90000000000, "max_concurrent_peers": 4}, "paths": { "critical": [{"tier":"critical","pattern":"/etc/critical.yml","interval":5000000000,"systemd_path_unit":true}], "standard": [{"tier":"standard","pattern":"/etc/standard.yml","interval":30000000000}], "excluded": ["/run/orca/*"] }, "remediate": {"auto": false, "auto_paths": [], "require_approval": ["/etc/critical.yml"], "notify_on_remediation": false} }` if err := os.WriteFile(path, []byte(cfgJSON), 0o644); err != nil { t.Fatalf("write config: %v", err) } cfg, err := LoadConfig(path) if err != nil { t.Fatalf("LoadConfig: %v", err) } if cfg.Polling.MaxConcurrentPeers != 4 { t.Errorf("MaxConcurrentPeers = %d, want 4", cfg.Polling.MaxConcurrentPeers) } if cfg.Polling.DefaultInterval != 90*time.Second { t.Errorf("DefaultInterval = %v, want 90s", cfg.Polling.DefaultInterval) } if cfg.Remediate.Auto { t.Errorf("Auto = true, want false") } if len(cfg.Paths.Critical) != 1 || cfg.Paths.Critical[0].Pattern != "/etc/critical.yml" { t.Errorf("critical = %+v", cfg.Paths.Critical) } } func TestValidateConfigValid(t *testing.T) { cfg := DefaultConfig() if err := ValidateConfig(cfg); err != nil { t.Fatalf("ValidateConfig(default): %v", err) } } func TestValidateConfigEmptyPattern(t *testing.T) { cfg := DefaultConfig() cfg.Paths.Critical[0].Pattern = "" if err := ValidateConfig(cfg); err == nil { t.Fatal("expected error for empty critical pattern") } } func TestValidateConfigPollingZero(t *testing.T) { cfg := DefaultConfig() cfg.Polling.Enabled = true cfg.Polling.DefaultInterval = 0 if err := ValidateConfig(cfg); err == nil { t.Fatal("expected error for zero default_interval with polling enabled") } } func TestValidateConfigOverlapCriticalExcluded(t *testing.T) { cfg := DefaultConfig() cfg.Paths.Excluded = append(cfg.Paths.Excluded, "/etc/traefik/dynamic/orca.yml") err := ValidateConfig(cfg) if err == nil { t.Fatal("expected overlap error") } if !errors.Is(err, ErrOverlapCritical) { t.Errorf("expected ErrOverlapCritical, got %v", err) } } func TestValidateConfigNil(t *testing.T) { if err := ValidateConfig(nil); err == nil { t.Fatal("expected error for nil config") } } func TestAggregateLocalMissing(t *testing.T) { setupDriftTestEnv(t) d := NewDefaultDetector(&mockTransport{}) events, err := d.Aggregate(context.Background(), "") if err != nil { t.Fatalf("Aggregate missing: %v", err) } if events != nil { t.Errorf("events = %v, want nil", events) } } func TestAggregateLocal(t *testing.T) { setupDriftTestEnv(t) doc := `{"ts":"2026-01-01T00:00:00Z","events":[{"event_id":"E1","host":"p","path":"/etc/traefik/dynamic/orca.yml","status":"modified","drift_confirmed":true}]}` if err := os.WriteFile(AggregatedPath(), []byte(doc), 0o644); err != nil { t.Fatalf("write aggregated: %v", err) } d := NewDefaultDetector(&mockTransport{}) events, err := d.Aggregate(context.Background(), "") if err != nil { t.Fatalf("Aggregate: %v", err) } if len(events) != 1 { t.Fatalf("events len = %d, want 1", len(events)) } if events[0].EventID != "E1" { t.Errorf("EventID = %q, want E1", events[0].EventID) } } func TestAggregateRemote(t *testing.T) { setupDriftTestEnv(t) doc := `{"ts":"2026-01-01T00:00:00Z","events":[{"event_id":"E2","host":"p","path":"/etc/orca/actual/ns-a/x","status":"created","drift_confirmed":true}]}` mt := &mockTransport{readOut: []byte(doc)} d := NewDefaultDetector(mt) events, err := d.Aggregate(context.Background(), "lead:22") if err != nil { t.Fatalf("Aggregate remote: %v", err) } if len(events) != 1 { t.Fatalf("events len = %d, want 1", len(events)) } if events[0].EventID != "E2" { t.Errorf("EventID = %q, want E2", events[0].EventID) } } func TestWatchCancels(t *testing.T) { setupDriftTestEnv(t) doc := `{"ts":"2026-01-01T00:00:00Z","events":[{"event_id":"E1","host":"p","path":"/etc/traefik/dynamic/orca.yml","status":"modified","drift_confirmed":true}]}` if err := os.WriteFile(AggregatedPath(), []byte(doc), 0o644); err != nil { t.Fatalf("write aggregated: %v", err) } d := NewDefaultDetector(&mockTransport{}) d.SetCooldown(50 * time.Millisecond) ctx, cancel := context.WithCancel(context.Background()) defer cancel() gotEvents := 0 for e, err := range d.Watch(ctx, []PathSpec{{Pattern: "/etc/traefik/dynamic/orca.yml", Interval: 10 * time.Millisecond}}) { if err != nil { t.Fatalf("Watch err: %v", err) } gotEvents++ _ = e cancel() break } if gotEvents != 1 { t.Errorf("gotEvents = %d, want 1", gotEvents) } } func TestWatchStreamsAndDedupes(t *testing.T) { setupDriftTestEnv(t) doc := `{"ts":"2026-01-01T00:00:00Z","events":[{"event_id":"E1","host":"p","path":"/etc/traefik/dynamic/orca.yml","status":"modified","drift_confirmed":true}]}` if err := os.WriteFile(AggregatedPath(), []byte(doc), 0o644); err != nil { t.Fatalf("write aggregated: %v", err) } d := NewDefaultDetector(&mockTransport{}) d.SetCooldown(50 * time.Millisecond) ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond) defer cancel() got := 0 for _, err := range d.Watch(ctx, nil) { if err != nil { t.Fatalf("Watch err: %v", err) } got++ } if got != 1 { t.Errorf("got = %d, want 1 (dedup)", got) } } func TestRemediateCooldownOnSuccess(t *testing.T) { setupDriftTestEnv(t) mt := &mockTransport{execOut: []byte("ok")} d := NewDefaultDetector(mt) d.SetCooldown(1 * time.Hour) if err := d.Remediate(context.Background(), "lead:22", "/etc/traefik/dynamic/orca.yml", false); err != nil { t.Fatalf("first Remediate: %v", err) } if err := d.Remediate(context.Background(), "lead:22", "/etc/traefik/dynamic/orca.yml", false); err == nil { t.Fatal("second Remediate should hit cooldown") } else if !errors.Is(err, ErrCooldown) { t.Errorf("expected ErrCooldown, got %v", err) } } func TestRemediateForceBypassesCooldown(t *testing.T) { setupDriftTestEnv(t) mt := &mockTransport{execOut: []byte("ok")} d := NewDefaultDetector(mt) d.SetCooldown(1 * time.Hour) if err := d.Remediate(context.Background(), "lead:22", "/etc/p", false); err != nil { t.Fatalf("first: %v", err) } if err := d.Remediate(context.Background(), "lead:22", "/etc/p", true); err != nil { t.Fatalf("force bypass: %v", err) } } func TestRemediateTransientNoCooldown(t *testing.T) { setupDriftTestEnv(t) mt := &mockTransport{execErr: fmt.Errorf("connection refused")} d := NewDefaultDetector(mt) d.SetCooldown(1 * time.Hour) if err := d.Remediate(context.Background(), "lead:22", "/etc/p", false); err == nil { t.Fatal("expected transient error") } if d.inCooldown("/etc/p") { t.Errorf("cooldown entered after transient failure") } } func TestRemediateRequiresLead(t *testing.T) { setupDriftTestEnv(t) d := NewDefaultDetector(&mockTransport{}) if err := d.Remediate(context.Background(), "", "/etc/p", true); err == nil { t.Fatal("expected error for empty lead") } } func TestAcknowledgeWrites(t *testing.T) { setupDriftTestEnv(t) mt := &mockTransport{} d := NewDefaultDetector(mt) if err := d.Acknowledge(context.Background(), "lead:22", "/etc/traefik/dynamic/orca.yml"); err != nil { t.Fatalf("Acknowledge: %v", err) } if len(mt.writes) != 1 { t.Fatalf("writes = %d, want 1", len(mt.writes)) } w := mt.writes[0] if w.peer != "lead:22" { t.Errorf("peer = %q, want lead:22", w.peer) } if w.path != AcknowledgmentsPath() { t.Errorf("path = %q, want %q", w.path, AcknowledgmentsPath()) } } func TestAcknowledgeRequiresLead(t *testing.T) { setupDriftTestEnv(t) d := NewDefaultDetector(&mockTransport{}) if err := d.Acknowledge(context.Background(), "", "/etc/p"); err == nil { t.Fatal("expected error for empty lead") } } func TestNamespaceScopeClusterWide(t *testing.T) { events := []Event{ {EventID: "E1", Path: "/etc/traefik/dynamic/orca.yml", DriftConfirmed: true}, {EventID: "E2", Path: "/etc/orca/actual/ns-a/x", DriftConfirmed: true}, {EventID: "E3", Path: "/etc/orca/actual/ns-b/y", DriftConfirmed: true, Action: ActionAcknowledged}, } out := NamespaceScope(events, "") if len(out) != 2 { t.Errorf("cluster-wide scope len = %d, want 2 (ack excluded)", len(out)) } } func TestNamespaceScopePerNamespace(t *testing.T) { events := []Event{ {EventID: "E1", Path: "/etc/orca/actual/ns-a/x", DriftConfirmed: true}, {EventID: "E2", Path: "/etc/orca/actual/ns-b/y", DriftConfirmed: true}, {EventID: "E3", Path: "/etc/traefik/dynamic/orca.yml", DriftConfirmed: true}, } nsA := NamespaceScope(events, "ns-a") if len(nsA) != 1 || nsA[0].EventID != "E1" { t.Errorf("ns-a scope = %+v, want only E1", nsA) } nsB := NamespaceScope(events, "ns-b") if len(nsB) != 1 || nsB[0].EventID != "E2" { t.Errorf("ns-b scope = %+v, want only E2", nsB) } } func TestExpandBraces(t *testing.T) { out := expandBraces("/etc/x.{service,timer}") if len(out) != 2 { t.Fatalf("len = %d, want 2", len(out)) } if out[0] != "/etc/x.service" || out[1] != "/etc/x.timer" { t.Errorf("out = %v", out) } } func TestPathGlobMatch(t *testing.T) { if !pathGlobMatch("/etc/orca/actual/*", "/etc/orca/actual/ns-a") { t.Errorf("expected glob match for single segment") } if !pathGlobMatch("/etc/orca/actual/*", "/etc/orca/actual/ns-a/x") { t.Errorf("expected glob match for nested segment (drift /* crosses /)") } if !pathGlobMatch("/etc/systemd/system/orca-collector.{service,timer}", "/etc/systemd/system/orca-collector.service") { t.Errorf("expected brace match") } } func TestNsForPath(t *testing.T) { if got := nsForPath("/etc/orca/actual/ns-a/x"); got != "ns-a" { t.Errorf("nsForPath = %q, want ns-a", got) } if got := nsForPath("/etc/traefik/dynamic/orca.yml"); got != "" { t.Errorf("nsForPath = %q, want empty", got) } }