17fc813823
M1, M2 and M3 complete * M1, M2 and M3 complete * review changes * docs * docs
142 lines
4.5 KiB
Go
142 lines
4.5 KiB
Go
package customschema_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"queryorchestration/internal/customschema"
|
|
)
|
|
|
|
// newCapturingLogger returns a *slog.Logger that writes JSON lines into
|
|
// the returned buffer. Tests parse the buffer to assert emitted fields.
|
|
func newCapturingLogger() (*slog.Logger, *bytes.Buffer) {
|
|
buf := &bytes.Buffer{}
|
|
handler := slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelInfo})
|
|
return slog.New(handler), buf
|
|
}
|
|
|
|
// decodeSingleLine parses exactly one JSON line from the buffer into a
|
|
// map. It fails the test if the buffer is empty or contains more than
|
|
// one line, because the audit sink contract is one-event-per-Record.
|
|
func decodeSingleLine(t *testing.T, buf *bytes.Buffer) map[string]any {
|
|
t.Helper()
|
|
raw := bytes.TrimSpace(buf.Bytes())
|
|
require.NotEmpty(t, raw, "expected sink to emit a log line, got empty buffer")
|
|
lines := bytes.Split(raw, []byte("\n"))
|
|
require.Len(t, lines, 1, "expected exactly one emitted line, got %d", len(lines))
|
|
out := map[string]any{}
|
|
require.NoError(t, json.Unmarshal(lines[0], &out))
|
|
return out
|
|
}
|
|
|
|
// TestSlogAuditSink_Record locks in the contract for the default audit
|
|
// sink that wraps a *slog.Logger. These assertions are the strict
|
|
// specification the engineer must satisfy in the next dispatch.
|
|
//
|
|
// Expected slog event:
|
|
//
|
|
// level = INFO
|
|
// msg = "audit"
|
|
// attrs = audit=true, action, actor, resource_id, client_id,
|
|
// timestamp (RFC3339Nano), plus every Details key merged flat.
|
|
func TestSlogAuditSink_Record(t *testing.T) {
|
|
actor := "00000000-0000-0000-0000-0000000000aa"
|
|
resourceID := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
|
clientID := "CLIENT_AUDIT_TEST"
|
|
ts := time.Date(2026, 4, 14, 12, 34, 56, 789000000, time.UTC)
|
|
|
|
buildRec := func(details map[string]any) customschema.AuditRecord {
|
|
return customschema.AuditRecord{
|
|
Actor: actor,
|
|
Action: customschema.AuditActionSchemaCreate,
|
|
ResourceID: resourceID,
|
|
ClientID: clientID,
|
|
Timestamp: ts,
|
|
Details: details,
|
|
}
|
|
}
|
|
|
|
t.Run("emits_audit_true_attribute", func(t *testing.T) {
|
|
logger, buf := newCapturingLogger()
|
|
sink := customschema.NewSlogAuditSink(logger)
|
|
|
|
err := sink.Record(t.Context(), buildRec(map[string]any{
|
|
"name": "alpha",
|
|
"version": 1,
|
|
}))
|
|
require.NoError(t, err)
|
|
|
|
event := decodeSingleLine(t, buf)
|
|
|
|
// slog JSON encodes booleans as literal true/false, but accept
|
|
// the string "true" as well in case a handler variant stringifies.
|
|
auditVal, ok := event["audit"]
|
|
require.True(t, ok, "missing audit attribute")
|
|
switch v := auditVal.(type) {
|
|
case bool:
|
|
require.True(t, v, "audit attribute must be true")
|
|
case string:
|
|
require.Equal(t, "true", v, "audit attribute must be true")
|
|
default:
|
|
t.Fatalf("audit attribute has unexpected type %T", v)
|
|
}
|
|
|
|
require.Equal(t, "schema.create", event["action"])
|
|
require.Equal(t, actor, event["actor"])
|
|
require.Equal(t, resourceID.String(), event["resource_id"])
|
|
require.Equal(t, clientID, event["client_id"])
|
|
|
|
tsAttr, ok := event["timestamp"].(string)
|
|
require.True(t, ok, "timestamp must be a string")
|
|
require.NotEmpty(t, tsAttr, "timestamp must not be empty")
|
|
|
|
require.Equal(t, "alpha", event["name"], "Details.name must be merged flat")
|
|
// json.Unmarshal decodes numbers as float64.
|
|
require.EqualValues(t, 1, event["version"], "Details.version must be merged flat")
|
|
|
|
require.Equal(t, "INFO", event["level"])
|
|
require.Equal(t, "audit", event["msg"])
|
|
})
|
|
|
|
t.Run("record_returns_nil_on_success", func(t *testing.T) {
|
|
logger, _ := newCapturingLogger()
|
|
sink := customschema.NewSlogAuditSink(logger)
|
|
|
|
err := sink.Record(t.Context(), buildRec(map[string]any{"k": "v"}))
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("empty_details_accepted", func(t *testing.T) {
|
|
logger, buf := newCapturingLogger()
|
|
sink := customschema.NewSlogAuditSink(logger)
|
|
|
|
require.NotPanics(t, func() {
|
|
err := sink.Record(t.Context(), buildRec(nil))
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
event := decodeSingleLine(t, buf)
|
|
_, hasAudit := event["audit"]
|
|
require.True(t, hasAudit, "audit attribute must be present even with nil Details")
|
|
require.Equal(t, "schema.create", event["action"])
|
|
})
|
|
|
|
t.Run("context_cancellation_does_not_block_emission", func(t *testing.T) {
|
|
logger, _ := newCapturingLogger()
|
|
sink := customschema.NewSlogAuditSink(logger)
|
|
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
cancel()
|
|
|
|
err := sink.Record(ctx, buildRec(map[string]any{"k": "v"}))
|
|
require.NoError(t, err)
|
|
})
|
|
}
|