mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-27 05:57:27 +00:00
f98c1eae78
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.
271 lines
11 KiB
Go
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")
|
|
}
|
|
}
|
|
}
|