package audit_test import ( "context" "errors" "path/filepath" "testing" "github.com/mathiasbq/hyperguild/ingestion/internal/audit" "github.com/mathiasbq/hyperguild/ingestion/internal/capture" "github.com/mathiasbq/hyperguild/ingestion/internal/classification" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // --- fakes --- type fakeCentral struct { down bool pushed []capture.AuditEntry pushErr error } func (f *fakeCentral) Ready(context.Context) error { if f.down { return errors.New("loki down") } return nil } func (f *fakeCentral) Push(_ context.Context, e capture.AuditEntry) error { if f.pushErr != nil { return f.pushErr } f.pushed = append(f.pushed, e) return nil } type fakeNotifier struct{ msgs []string } func (f *fakeNotifier) Notify(_ context.Context, msg string) error { f.msgs = append(f.msgs, msg) return nil } // unwritableBuffer always reports it cannot be written (floor condition). type unwritableBuffer struct{} func (unwritableBuffer) Writable() error { return errors.New("disk full") } func (unwritableBuffer) Append(capture.AuditEntry) error { return errors.New("disk full") } func (unwritableBuffer) Pending() ([]audit.Buffered, error) { return nil, nil } func (unwritableBuffer) Confirm(string) error { return nil } func newFileBuffer(t *testing.T) *audit.FileBuffer { t.Helper() b, err := audit.NewFileBuffer(filepath.Join(t.TempDir(), "audit-buffer.jsonl")) require.NoError(t, err) return b } func entry(principal string) capture.AuditEntry { return capture.AuditEntry{Principal: principal, EffectiveClassification: "internal", Items: []string{"insight:x"}} } // --- Reserve: classification-aware decision --- func TestReserveCentralUpGrantsCentral(t *testing.T) { d := audit.NewDegradingSink(&fakeCentral{}, newFileBuffer(t), &fakeNotifier{}) for _, lvl := range []classification.Level{classification.Public, classification.Internal, classification.Confidential} { out, err := d.Reserve(context.Background(), lvl) require.NoError(t, err) assert.Equal(t, capture.AuditCentral, out) } } func TestReserveConfidentialSinkDownRefuses(t *testing.T) { d := audit.NewDegradingSink(&fakeCentral{down: true}, newFileBuffer(t), &fakeNotifier{}) _, err := d.Reserve(context.Background(), classification.Confidential) require.Error(t, err, "confidential + sink down → refuse, no buffer") } func TestReserveInternalSinkDownBuffers(t *testing.T) { d := audit.NewDegradingSink(&fakeCentral{down: true}, newFileBuffer(t), &fakeNotifier{}) out, err := d.Reserve(context.Background(), classification.Internal) require.NoError(t, err) assert.Equal(t, capture.AuditBuffered, out) } func TestReserveFloorRefusesWhenNothingCanRecord(t *testing.T) { d := audit.NewDegradingSink(&fakeCentral{down: true}, unwritableBuffer{}, &fakeNotifier{}) _, err := d.Reserve(context.Background(), classification.Internal) require.Error(t, err, "central down AND buffer unwritable → floor refuse") } // --- Record: executes the reserved outcome --- func TestRecordCentralPushes(t *testing.T) { c := &fakeCentral{} d := audit.NewDegradingSink(c, newFileBuffer(t), &fakeNotifier{}) require.NoError(t, d.Record(context.Background(), entry("p"), capture.AuditCentral)) assert.Len(t, c.pushed, 1) } func TestRecordBufferedAppendsAndNotifies(t *testing.T) { buf := newFileBuffer(t) nt := &fakeNotifier{} d := audit.NewDegradingSink(&fakeCentral{down: true}, buf, nt) require.NoError(t, d.Record(context.Background(), entry("p"), capture.AuditBuffered)) pending, err := buf.Pending() require.NoError(t, err) assert.Len(t, pending, 1) assert.NotEmpty(t, nt.msgs, "degraded state alerts via ntfy") } // --- FileBuffer durability + Confirm --- func TestFileBufferSurvivesRestart(t *testing.T) { path := filepath.Join(t.TempDir(), "buf.jsonl") b1, err := audit.NewFileBuffer(path) require.NoError(t, err) require.NoError(t, b1.Append(entry("p1"))) require.NoError(t, b1.Append(entry("p2"))) // "restart": a fresh FileBuffer over the same file sees the records. b2, err := audit.NewFileBuffer(path) require.NoError(t, err) pending, err := b2.Pending() require.NoError(t, err) assert.Len(t, pending, 2) } func TestFileBufferConfirmRemovesOnlyThatRecord(t *testing.T) { buf := newFileBuffer(t) require.NoError(t, buf.Append(entry("keep"))) require.NoError(t, buf.Append(entry("drop"))) pending, _ := buf.Pending() require.Len(t, pending, 2) var dropID string for _, p := range pending { if p.Entry.Principal == "drop" { dropID = p.ID } } require.NoError(t, buf.Confirm(dropID)) after, _ := buf.Pending() require.Len(t, after, 1) assert.Equal(t, "keep", after[0].Entry.Principal) } // --- Reconcile --- func TestReconcileReplaysAndClearsOnlyAfterConfirmedWrite(t *testing.T) { buf := newFileBuffer(t) require.NoError(t, buf.Append(entry("a"))) require.NoError(t, buf.Append(entry("b"))) c := &fakeCentral{} // up nt := &fakeNotifier{} n, err := audit.Reconcile(context.Background(), c, buf, nt) require.NoError(t, err) assert.Equal(t, 2, n) assert.Len(t, c.pushed, 2, "buffered records replayed to central") pending, _ := buf.Pending() assert.Empty(t, pending, "buffer cleared after confirmed central writes") } func TestReconcileNoopWhenCentralDown(t *testing.T) { buf := newFileBuffer(t) require.NoError(t, buf.Append(entry("a"))) n, err := audit.Reconcile(context.Background(), &fakeCentral{down: true}, buf, &fakeNotifier{}) require.NoError(t, err) assert.Equal(t, 0, n) pending, _ := buf.Pending() assert.Len(t, pending, 1, "records stay buffered while central is down") } func TestReconcileKeepsRecordWhenPushFails(t *testing.T) { buf := newFileBuffer(t) require.NoError(t, buf.Append(entry("a"))) // Ready ok but Push fails → record must remain buffered (not lost). c := &fakeCentral{pushErr: errors.New("push rejected")} n, err := audit.Reconcile(context.Background(), c, buf, &fakeNotifier{}) require.NoError(t, err) assert.Equal(t, 0, n) pending, _ := buf.Pending() assert.Len(t, pending, 1) }