Files
pulse/internal/api/audit_selection_validation_test.go
pulse-triage[bot] f98c1eae78 Compare tenant audit mutations with each request starting state
The second tenant control must preserve the first tenant deliberate clear, not require that the earlier update never happened. Capture both update counters before the rejected request and require them to stay unchanged. No production behaviour changes.

Change-source: pulse-maintainer
Contract-Neutral: Correct the synthetic tenant control baseline; production audit handlers and contracts are unchanged.
2026-10-24 05:30:25 +01:00

271 lines
11 KiB
Go

package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/pkg/audit"
)
// Exercise the canonical handlers and exporter without a database or receiver.
func TestAuditSelectionRejectsDamagedQuery(t *testing.T) {
queries := []string{
"user=%zz", "event=%", "success=%zz", "startTime=%zz", "endTime=%zz",
"limit=%zz", "offset=%zz", "format=%zz", "verify=%zz", "%zz=operator",
"user=operator;event=login", "user=operator&user=%zz", "event=login&unknown=%zz",
}
for _, endpoint := range auditScopeEndpoints() {
for _, query := range queries {
t.Run(endpoint.name+"/"+query, func(t *testing.T) {
assertAuditSelectionRejected(t, endpoint.handle, query, "invalid_query")
})
}
}
}
func TestAuditSelectionRejectsRepeatedParameters(t *testing.T) {
values := map[string]string{
"event": "login", "user": "operator", "success": "true", "startTime": "2026-01-01T00:00:00Z",
"endTime": "2026-01-02T00:00:00Z", "limit": "10", "offset": "0", "format": "json", "verify": "true",
}
for _, endpoint := range auditScopeEndpoints() {
for _, key := range []string{"event", "user", "success", "startTime", "endTime", "limit", "offset", "format", "verify"} {
for _, variant := range []string{"identical", "conflicting", "encoded_key"} {
t.Run(endpoint.name+"/"+key+"/"+variant, func(t *testing.T) {
first, second := key, url.QueryEscape(values[key])
if variant == "conflicting" {
second = "different"
}
if variant == "encoded_key" {
first = fmt.Sprintf("%%%02x%s", key[0], key[1:])
}
query := first + "=" + url.QueryEscape(values[key]) + "&" + key + "=" + second
assertAuditSelectionRejected(t, endpoint.handle, query, "ambiguous_query")
})
}
}
}
}
func assertAuditSelectionRejected(t *testing.T, handle http.HandlerFunc, query, code string) {
t.Helper()
logger := &auditScopeLogger{testAuditLogger: testAuditLogger{events: []audit.Event{{ID: "unselected-event", Signature: "synthetic"}}}}
setAuditLogger(t, logger)
rec := httptest.NewRecorder()
handle(rec, httptest.NewRequest(http.MethodGet, "/api/audit?"+query, nil))
var body APIError
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusBadRequest || body.Code != code {
t.Errorf("status/code = %d/%q, want 400/%q", rec.Code, body.Code, code)
}
if logger.queries != 0 || logger.counts != 0 || logger.verifications != 0 {
t.Errorf("query/count/verify = %d/%d/%d, want no event read", logger.queries, logger.counts, logger.verifications)
}
if strings.Contains(rec.Body.String(), "unselected-event") || rec.Header().Get("Content-Disposition") != "" || rec.Header().Get("X-Event-Count") != "" {
t.Error("rejected selection returned event/export content")
}
}
// A filter-backed, in-memory logger makes widening visible in the wire result.
type auditSelectionLogger struct {
auditScopeLogger
urls []string
updates int
}
func (l *auditSelectionLogger) selected(f audit.QueryFilter) []audit.Event {
events := []audit.Event{}
for _, e := range l.events {
if f.EventType != "" && e.EventType != f.EventType || f.User != "" && e.User != f.User || f.Success != nil && e.Success != *f.Success || f.StartTime != nil && e.Timestamp.Before(*f.StartTime) || f.EndTime != nil && !e.Timestamp.Before(*f.EndTime) {
continue
}
events = append(events, e)
}
return events
}
func (l *auditSelectionLogger) Query(f audit.QueryFilter) ([]audit.Event, error) {
l.queries++
l.lastQuery = f
events := l.selected(f)
if f.Offset >= len(events) {
return []audit.Event{}, nil
}
events = events[f.Offset:]
if f.Limit > 0 && len(events) > f.Limit {
events = events[:f.Limit]
}
return events, nil
}
func (l *auditSelectionLogger) Count(f audit.QueryFilter) (int, error) {
l.counts++
l.lastCount = f
return len(l.selected(f)), nil
}
func (l *auditSelectionLogger) GetWebhookURLs() []string { return append([]string{}, l.urls...) }
func (l *auditSelectionLogger) UpdateWebhookURLs(urls []string) error {
l.updates++
l.urls = append([]string{}, urls...)
return l.updateErr
}
func newAuditSelectionLogger() *auditSelectionLogger {
at := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
return &auditSelectionLogger{auditScopeLogger: auditScopeLogger{testAuditLogger: testAuditLogger{verifyResult: true, events: []audit.Event{
{ID: "selected-event", EventType: "login", User: "operator+é@example.invalid", Timestamp: at, Success: true, Signature: "synthetic"},
{ID: "other-user", EventType: "login", User: "another", Timestamp: at, Success: true},
{ID: "other-event", EventType: "logout", User: "operator+é@example.invalid", Timestamp: at, Success: true},
{ID: "outside-window", EventType: "login", User: "operator+é@example.invalid", Timestamp: at.Add(-48 * time.Hour), Success: true},
}}}}
}
func TestAuditSelectionPreservesEncodedWireResults(t *testing.T) {
for _, endpoint := range auditScopeEndpoints() {
for _, format := range []string{"json", "csv"} {
t.Run(endpoint.name+"/"+format, func(t *testing.T) {
logger := newAuditSelectionLogger()
setAuditLogger(t, logger)
q := url.Values{"user": {"operator+é@example.invalid"}, "event": {"login"}, "startTime": {"2026-01-01T01:00:00+01:00"}, "endTime": {"2026-01-02T00:00:00Z"}, "verify": {"true"}, "format": {format}, "unknown": {"one", "two"}}
rec := httptest.NewRecorder()
endpoint.handle(rec, httptest.NewRequest(http.MethodGet, "/api/audit?"+q.Encode(), nil))
if rec.Code != 200 || logger.queries != 1 || logger.lastQuery.User != q.Get("user") || logger.lastQuery.EventType != "login" {
t.Fatalf("status/query/filter = %d/%d/%+v", rec.Code, logger.queries, logger.lastQuery)
}
for _, id := range []string{"other-user", "other-event", "outside-window"} {
if strings.Contains(rec.Body.String(), id) {
t.Fatalf("widened selection contains %s", id)
}
}
if endpoint.name == "summary" {
var summary audit.ExportSummary
if err := json.Unmarshal(rec.Body.Bytes(), &summary); err != nil {
t.Fatal(err)
}
if summary.TotalEvents != 1 {
t.Fatalf("summary count = %d", summary.TotalEvents)
}
} else if !strings.Contains(rec.Body.String(), "selected-event") {
t.Fatal("selected event missing")
}
if endpoint.name == "export" && rec.Header().Get("X-Event-Count") != "1" {
t.Fatal("export count not scoped")
}
})
}
}
}
func TestAuditWebhookSelectionRequiresExplicitArray(t *testing.T) {
for _, method := range []string{http.MethodPost, http.MethodPut} {
for _, body := range []string{"null", "{}", `{"urls":null}`} {
t.Run(method+"/"+body, func(t *testing.T) {
logger := newAuditSelectionLogger()
logger.urls = []string{"https://8.8.8.8/audit"}
setAuditLogger(t, logger)
rec := httptest.NewRecorder()
NewAuditHandlers().HandleUpdateWebhooks(rec, httptest.NewRequest(method, "/api/admin/webhooks/audit", strings.NewReader(body)))
var payload APIError
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if rec.Code != 400 || payload.Code != "invalid_request" || logger.updates != 0 || !reflect.DeepEqual(logger.urls, []string{"https://8.8.8.8/audit"}) {
t.Fatalf("status/code/updates/urls = %d/%q/%d/%v", rec.Code, payload.Code, logger.updates, logger.urls)
}
})
}
}
}
func TestAuditWebhookSelectionExplicitClearRestoreAndValidation(t *testing.T) {
for _, method := range []string{http.MethodPost, http.MethodPut} {
t.Run(method, func(t *testing.T) {
logger := newAuditSelectionLogger()
logger.urls = []string{"https://8.8.8.8/audit"}
setAuditLogger(t, logger)
for _, tc := range []struct {
body string
status, updates int
urls []string
}{
{`{"urls":["http://127.0.0.1/private"]}`, 400, 0, []string{"https://8.8.8.8/audit"}},
{`{"urls":[]}`, 204, 1, []string{}},
{`{"urls":["https://8.8.8.8/audit"]}`, 204, 2, []string{"https://8.8.8.8/audit"}},
{`{"urls":["https://8.8.8.8/changed","http://127.0.0.1/private"]}`, 400, 2, []string{"https://8.8.8.8/audit"}},
} {
rec := httptest.NewRecorder()
NewAuditHandlers().HandleUpdateWebhooks(rec, httptest.NewRequest(method, "/api/admin/webhooks/audit", strings.NewReader(tc.body)))
if rec.Code != tc.status || logger.updates != tc.updates || !reflect.DeepEqual(logger.urls, tc.urls) {
t.Fatalf("status/updates/urls = %d/%d/%v", rec.Code, logger.updates, logger.urls)
}
}
rec := httptest.NewRecorder()
NewAuditHandlers().HandleUpdateWebhooks(rec, httptest.NewRequest(http.MethodGet, "/api/admin/webhooks/audit", strings.NewReader(`{"urls":[]}`)))
if rec.Code != 405 || logger.updates != 2 {
t.Fatal("safe method mutated selection")
}
})
}
}
// Tenant selection still comes only from the authenticated request context.
type auditSelectionFactory map[string]*auditSelectionLogger
func (f auditSelectionFactory) CreateLogger(path string) (audit.Logger, error) {
for org, l := range f {
if strings.HasSuffix(path, "/"+org+"/audit.db") {
return l, nil
}
}
return nil, context.Canceled
}
func TestAuditSelectionTenantIsolation(t *testing.T) {
previous := GetTenantAuditManager()
factory := auditSelectionFactory{"org-a": newAuditSelectionLogger(), "org-b": newAuditSelectionLogger()}
for org, l := range factory {
l.urls = []string{"https://8.8.8.8/" + org}
l.events[0].ID = org
}
manager := audit.NewTenantLoggerManager(t.TempDir(), factory)
SetTenantAuditManager(manager)
t.Cleanup(func() { manager.Close(); SetTenantAuditManager(previous) })
for _, org := range []string{"org-a", "org-b"} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/audit?user=operator%2B%C3%A9%40example.invalid&event=login&startTime=2026-01-01T00:00:00Z", nil)
req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, org))
NewAuditHandlers().HandleListAuditEvents(rec, req)
other := "org-a"
if org == other {
other = "org-b"
}
if rec.Code != 200 || !strings.Contains(rec.Body.String(), org) || strings.Contains(rec.Body.String(), other) {
t.Fatalf("tenant list: %d/%s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodPut, "/api/admin/webhooks/audit", strings.NewReader(`{"urls":null}`))
req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, org))
rec = httptest.NewRecorder()
ownUpdates, otherUpdates := factory[org].updates, factory[other].updates
NewAuditHandlers().HandleUpdateWebhooks(rec, req)
if rec.Code != 400 || factory[org].updates != ownUpdates || factory[other].updates != otherUpdates {
t.Fatal("absent selection mutated tenant receivers")
}
req = httptest.NewRequest(http.MethodPut, "/api/admin/webhooks/audit", strings.NewReader(`{"urls":[]}`))
req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, org))
rec = httptest.NewRecorder()
before := factory[other].updates
NewAuditHandlers().HandleUpdateWebhooks(rec, req)
if rec.Code != 204 || len(factory[org].urls) != 0 || factory[other].updates != before {
t.Fatal("explicit clear crossed tenant boundary")
}
}
}