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>
87 lines
2.6 KiB
Go
87 lines
2.6 KiB
Go
package audit_test
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestLokiReadyAndPush(t *testing.T) {
|
|
var pushBody string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/ready":
|
|
w.WriteHeader(http.StatusOK)
|
|
case "/loki/api/v1/push":
|
|
b, _ := io.ReadAll(r.Body)
|
|
pushBody = string(b)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := audit.NewLokiCentral(srv.URL)
|
|
require.NotNil(t, c)
|
|
require.NoError(t, c.Ready(context.Background()))
|
|
|
|
err := c.Push(context.Background(), capture.AuditEntry{
|
|
Principal: "koala-cli", EffectiveClassification: "internal", Items: []string{"insight:x"},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Contains(t, pushBody, "streams")
|
|
assert.Contains(t, pushBody, "koala-cli", "audit entry serialised into the loki line")
|
|
}
|
|
|
|
func TestLokiReadyFailsWhenDown(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
}))
|
|
defer srv.Close()
|
|
require.Error(t, audit.NewLokiCentral(srv.URL).Ready(context.Background()))
|
|
}
|
|
|
|
func TestLokiNilWhenUnconfigured(t *testing.T) {
|
|
assert.Nil(t, audit.NewLokiCentral(""))
|
|
}
|
|
|
|
func TestNtfyNotify(t *testing.T) {
|
|
var gotBody, gotAuth string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
b, _ := io.ReadAll(r.Body)
|
|
gotBody = string(b)
|
|
gotAuth = r.Header.Get("Authorization")
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
n := audit.NewNtfyNotifier(srv.URL, "ntfy-token")
|
|
require.NotNil(t, n)
|
|
require.NoError(t, n.Notify(context.Background(), "audit buffered locally"))
|
|
assert.Contains(t, gotBody, "audit buffered locally")
|
|
assert.Equal(t, "Bearer ntfy-token", gotAuth)
|
|
}
|
|
|
|
func TestNtfyDoesNotLeakTokenOnError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
defer srv.Close()
|
|
err := audit.NewNtfyNotifier(srv.URL, "secret-token").Notify(context.Background(), "x")
|
|
require.Error(t, err)
|
|
assert.False(t, strings.Contains(err.Error(), "secret-token"), "token must not leak into errors")
|
|
}
|
|
|
|
func TestNtfyNilWhenUnconfigured(t *testing.T) {
|
|
assert.Nil(t, audit.NewNtfyNotifier("", "tok"))
|
|
}
|