feat(audit): DegradingSink + durable buffer + loki/ntfy + reconcile (#54)
The classification-aware I5 audit path (§4.4): - DegradingSink.Reserve: central up → AuditCentral; central down + confidential → refuse (no buffer); central down + internal/public + buffer writable → AuditBuffered; central down + buffer unwritable → floor refuse. Record executes the reserved outcome and, when buffered, fires an ntfy alert. - FileBuffer: durable JSONL buffer that survives process restart; Confirm rewrites the file without a record, so a buffered record is cleared ONLY after its central write is confirmed. - LokiCentral: /ready probe + /loki/api/v1/push (full audit entry as the structured line). NtfyNotifier: degraded-state alerts; token only in the auth header, never logged (regression-tested). - Reconcile + StartReconcile: replay buffered records to central on recovery, confirm-then-clear per record; a failed push keeps the record buffered (no loss). SlogSink updated to the two-phase shape (always central, never fails) — the default when no loki endpoint is set. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user