- Map capture.ErrAuditUnavailable → HTTP 503 (audit substrate down / confidential unauditable / floor). - main: buildAuditSink selects the DegradingSink (loki central + durable file buffer under brainDir + optional ntfy) when BRAIN_LOKI_URL is set and starts the reconcile loop; else the plain slog sink. Notifier kept as a nil interface (not typed-nil) when unconfigured so the sink and reconcile skip it cleanly. Env: BRAIN_LOKI_URL, BRAIN_NTFY_URL, BRAIN_NTFY_TOKEN, BRAIN_AUDIT_RECONCILE_INTERVAL (default 60s). Buffer at <brain>/.audit-buffer/capture.jsonl. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
199 lines
7.8 KiB
Go
199 lines
7.8 KiB
Go
package capturehttp_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const staticTok = "static-secret"
|
|
|
|
// fakeValidator stands in for the chassis JWT validator.
|
|
type fakeValidator struct {
|
|
subject string
|
|
err error
|
|
}
|
|
|
|
func (f fakeValidator) Validate(context.Context, string) (string, error) {
|
|
return f.subject, f.err
|
|
}
|
|
|
|
type fakeTracker struct{ failCreate bool }
|
|
|
|
func (f fakeTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) {
|
|
if f.failCreate {
|
|
return capture.IssueRef{}, errors.New("gitea down")
|
|
}
|
|
return capture.IssueRef{Repo: "hyperguild", Number: 1, URL: "https://git/1"}, nil
|
|
}
|
|
func (fakeTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
|
return capture.IssueRef{}, nil
|
|
}
|
|
func (fakeTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
|
return capture.IssueRef{}, nil
|
|
}
|
|
|
|
func newHandler(t *testing.T, v capturehttp.Validator, tr capture.IssueTracker, sovereign []string) *capturehttp.Handler {
|
|
t.Helper()
|
|
cfg, err := classification.Load(t.TempDir())
|
|
require.NoError(t, err)
|
|
svc := capture.NewService(brainstore.New(t.TempDir()), tr, nil, cfg, audit.NewSlogSink(nil))
|
|
return capturehttp.New(svc, v, staticTok, "local-cli", capturehttp.NewOriginResolver(sovereign))
|
|
}
|
|
|
|
func do(t *testing.T, h *capturehttp.Handler, authz string, body any) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
b, _ := json.Marshal(body)
|
|
req := httptest.NewRequest(http.MethodPost, "/capture", bytes.NewReader(b))
|
|
if authz != "" {
|
|
req.Header.Set("Authorization", authz)
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
h.ServeHTTP(rr, req)
|
|
return rr
|
|
}
|
|
|
|
func internalReq() map[string]any {
|
|
return map[string]any{
|
|
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
|
|
"insights": []map[string]any{{"text": "a fact", "wing": "hyperguild", "hall": "facts"}},
|
|
}
|
|
}
|
|
|
|
func TestUnauthorizedWithoutToken(t *testing.T) {
|
|
h := newHandler(t, fakeValidator{err: errors.New("no")}, fakeTracker{}, nil)
|
|
rr := do(t, h, "", internalReq())
|
|
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
|
}
|
|
|
|
func TestUnauthorizedBadToken(t *testing.T) {
|
|
h := newHandler(t, fakeValidator{err: errors.New("bad jwt")}, fakeTracker{}, nil)
|
|
rr := do(t, h, "Bearer wrong", internalReq())
|
|
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
|
}
|
|
|
|
func TestHappyPathStaticToken(t *testing.T) {
|
|
h := newHandler(t, nil, fakeTracker{}, nil)
|
|
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
|
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
|
|
"insights": []map[string]any{{"text": "a fact", "wing": "hyperguild", "hall": "facts"}},
|
|
"tickets": []map[string]any{{"repo": "hyperguild", "action": "create", "title": "t"}},
|
|
})
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
var rec capture.CaptureReceipt
|
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &rec))
|
|
assert.True(t, rec.Insights[0].OK)
|
|
assert.True(t, rec.Tickets[0].OK)
|
|
assert.Empty(t, rec.Errors)
|
|
}
|
|
|
|
func TestConfidentialViaUSNexusRefused(t *testing.T) {
|
|
// JWT principal not in the sovereign allowlist ⇒ us-nexus; confidential ⇒ 403.
|
|
h := newHandler(t, fakeValidator{subject: "claudeai-oauth-client"}, fakeTracker{}, nil)
|
|
rr := do(t, h, "Bearer jwt-token", map[string]any{
|
|
"context": map[string]any{"harness": "claudeai-chat", "actor": "mathias", "classification": "confidential"},
|
|
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
|
})
|
|
assert.Equal(t, http.StatusForbidden, rr.Code)
|
|
assert.Contains(t, rr.Body.String(), "sovereignty")
|
|
}
|
|
|
|
func TestConfidentialViaSovereignJWTAllowed(t *testing.T) {
|
|
// Same confidential payload, but the principal is allowlisted sovereign ⇒ allowed.
|
|
h := newHandler(t, fakeValidator{subject: "koala-cli"}, fakeTracker{}, []string{"koala-cli"})
|
|
rr := do(t, h, "Bearer jwt-token", map[string]any{
|
|
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "confidential"},
|
|
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
|
})
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
}
|
|
|
|
func TestStaticTokenIsSovereignSoConfidentialAllowed(t *testing.T) {
|
|
h := newHandler(t, nil, fakeTracker{}, nil)
|
|
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
|
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "confidential"},
|
|
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
|
})
|
|
assert.Equal(t, http.StatusOK, rr.Code)
|
|
}
|
|
|
|
func TestValidationRejectedBeforeWrite(t *testing.T) {
|
|
h := newHandler(t, nil, fakeTracker{}, nil)
|
|
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
|
"context": map[string]any{"actor": "mathias", "classification": "internal"},
|
|
"insights": []map[string]any{{"text": "x", "wing": "hyperguild", "hall": "not-a-hall"}},
|
|
})
|
|
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
|
}
|
|
|
|
func TestPartialFailureIs207(t *testing.T) {
|
|
h := newHandler(t, nil, fakeTracker{failCreate: true}, nil)
|
|
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
|
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
|
|
"insights": []map[string]any{{"text": "ok insight", "wing": "hyperguild", "hall": "facts"}},
|
|
"tickets": []map[string]any{{"repo": "hyperguild", "action": "create", "title": "fails"}},
|
|
})
|
|
assert.Equal(t, http.StatusMultiStatus, rr.Code)
|
|
var rec capture.CaptureReceipt
|
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &rec))
|
|
assert.True(t, rec.Insights[0].OK)
|
|
assert.False(t, rec.Tickets[0].OK)
|
|
assert.Len(t, rec.Errors, 1)
|
|
}
|
|
|
|
func TestDryRunWritesNothing(t *testing.T) {
|
|
h := newHandler(t, nil, fakeTracker{}, nil)
|
|
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
|
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
|
|
"insights": []map[string]any{{"text": "a", "wing": "hyperguild", "hall": "facts"}},
|
|
"dry_run": true,
|
|
})
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
var rec capture.CaptureReceipt
|
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &rec))
|
|
assert.True(t, rec.DryRun)
|
|
}
|
|
|
|
func TestCallerCannotForgeOrigin(t *testing.T) {
|
|
// Even if the body tried to assert a sovereign harness, a us-nexus JWT
|
|
// principal + confidential ⇒ refused. (Origin is server-derived.)
|
|
h := newHandler(t, fakeValidator{subject: "claudeai-oauth-client"}, fakeTracker{}, nil)
|
|
rr := do(t, h, "Bearer jwt", map[string]any{
|
|
"context": map[string]any{"harness": "sovereign-soil", "actor": "mathias", "classification": "confidential"},
|
|
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
|
})
|
|
assert.Equal(t, http.StatusForbidden, rr.Code)
|
|
}
|
|
|
|
// refusingAudit refuses at Reserve (e.g. confidential + loki down, or floor).
|
|
type refusingAudit struct{}
|
|
|
|
func (refusingAudit) Reserve(context.Context, classification.Level) (capture.AuditOutcome, error) {
|
|
return 0, errors.New("central audit sink unreachable")
|
|
}
|
|
func (refusingAudit) Record(context.Context, capture.AuditEntry, capture.AuditOutcome) error {
|
|
return nil
|
|
}
|
|
|
|
func TestAuditUnavailableIs503(t *testing.T) {
|
|
cfg, err := classification.Load(t.TempDir())
|
|
require.NoError(t, err)
|
|
svc := capture.NewService(brainstore.New(t.TempDir()), fakeTracker{}, nil, cfg, refusingAudit{})
|
|
h := capturehttp.New(svc, nil, staticTok, "local-cli", capturehttp.NewOriginResolver(nil))
|
|
|
|
rr := do(t, h, "Bearer "+staticTok, internalReq())
|
|
assert.Equal(t, http.StatusServiceUnavailable, rr.Code)
|
|
}
|