mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
test(api): synchronize auth denial log capture
Some checks failed
Build and Test / Backend tests (rest-1) (push) Blocked by required conditions
Build and Test / Backend tests (rest-0) (push) Blocked by required conditions
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Detect changed areas (push) Waiting to run
Build and Test / Frontend (push) Blocked by required conditions
Build and Test / Benchmarks (push) Blocked by required conditions
Canonical Governance / governance (push) Waiting to run
Public docs / check (push) Waiting to run
Build and Test / Backend tests (api) (push) Blocked by required conditions
Build and Test / Script smoke tests & backend build (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 7/8) (push) Waiting to run
Unified Agent Native Verification / Linux x64 (push) Waiting to run
Unified Agent Native Verification / macOS ARM64 (push) Waiting to run
Unified Agent Native Verification / macOS Intel (push) Waiting to run
Unified Agent Native Verification / FreeBSD cross-build contract (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 5/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 6/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 8/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/8) (push) Waiting to run
Core E2E Tests / Agent registration lifecycle (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Unified Agent Native Verification / Linux ARM64 (push) Waiting to run
Unified Agent Native Verification / Windows x64 (push) Waiting to run
Patrol Qualification Regression / Catalog, scorer, and replay regression (push) Has been cancelled
Some checks failed
Build and Test / Backend tests (rest-1) (push) Blocked by required conditions
Build and Test / Backend tests (rest-0) (push) Blocked by required conditions
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Detect changed areas (push) Waiting to run
Build and Test / Frontend (push) Blocked by required conditions
Build and Test / Benchmarks (push) Blocked by required conditions
Canonical Governance / governance (push) Waiting to run
Public docs / check (push) Waiting to run
Build and Test / Backend tests (api) (push) Blocked by required conditions
Build and Test / Script smoke tests & backend build (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 7/8) (push) Waiting to run
Unified Agent Native Verification / Linux x64 (push) Waiting to run
Unified Agent Native Verification / macOS ARM64 (push) Waiting to run
Unified Agent Native Verification / macOS Intel (push) Waiting to run
Unified Agent Native Verification / FreeBSD cross-build contract (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 5/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 6/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 8/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/8) (push) Waiting to run
Core E2E Tests / Agent registration lifecycle (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Unified Agent Native Verification / Linux ARM64 (push) Waiting to run
Unified Agent Native Verification / Windows x64 (push) Waiting to run
Patrol Qualification Regression / Catalog, scorer, and replay regression (push) Has been cancelled
This commit is contained in:
parent
6d8a509376
commit
ff03168e5e
1 changed files with 94 additions and 13 deletions
|
|
@ -2,8 +2,10 @@ package api
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -11,12 +13,32 @@ import (
|
|||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// lockedLogBuffer keeps log capture safe when another package goroutine logs
|
||||
// while an auth-denial test owns the process-wide zerolog logger. bytes.Buffer
|
||||
// is not safe for concurrent writes or for a read racing a write.
|
||||
type lockedLogBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *lockedLogBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *lockedLogBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
// captureAuthDenialLogs swaps the global logger for a buffer at debug level and
|
||||
// resets the denial tracker, so each case starts from a known window.
|
||||
func captureAuthDenialLogs(t *testing.T) *bytes.Buffer {
|
||||
func captureAuthDenialLogs(t *testing.T) *lockedLogBuffer {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
var buf lockedLogBuffer
|
||||
prevLogger := log.Logger
|
||||
prevLevel := zerolog.GlobalLevel()
|
||||
log.Logger = zerolog.New(&buf).Level(zerolog.DebugLevel)
|
||||
|
|
@ -39,8 +61,26 @@ func captureAuthDenialLogs(t *testing.T) *bytes.Buffer {
|
|||
return &buf
|
||||
}
|
||||
|
||||
func countLevel(output, level string) int {
|
||||
return strings.Count(output, `"level":"`+level+`"`)
|
||||
func countLogEvents(t *testing.T, buf *lockedLogBuffer, level, message string) int {
|
||||
t.Helper()
|
||||
|
||||
count := 0
|
||||
for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var event struct {
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
t.Fatalf("decode captured log event %q: %v", line, err)
|
||||
}
|
||||
if event.Level == level && event.Message == message {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// A non-admin browsing a UI that still mounts an admin-only surface produces a
|
||||
|
|
@ -49,16 +89,16 @@ func countLevel(output, level string) int {
|
|||
func TestAuthDenialBelowThresholdStaysDebug(t *testing.T) {
|
||||
buf := captureAuthDenialLogs(t)
|
||||
req := httptest.NewRequest("GET", "/api/connections", nil)
|
||||
const message = "Non-admin user attempted to access admin endpoint"
|
||||
|
||||
for i := 0; i < authDenialWarnThreshold-1; i++ {
|
||||
logAuthDenial(req, "viewer", "Non-admin user attempted to access admin endpoint", nil)
|
||||
logAuthDenial(req, "viewer", message, nil)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if got := countLevel(output, "debug"); got != authDenialWarnThreshold-1 {
|
||||
if got := countLogEvents(t, buf, "debug", message); got != authDenialWarnThreshold-1 {
|
||||
t.Fatalf("debug lines = %d, want %d", got, authDenialWarnThreshold-1)
|
||||
}
|
||||
if got := countLevel(output, "warn"); got != 0 {
|
||||
if got := countLogEvents(t, buf, "warn", "Repeated authorization denials from one caller; possible endpoint probing"); got != 0 {
|
||||
t.Fatalf("warn lines = %d, want 0 below the probing threshold", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -74,7 +114,7 @@ func TestAuthDenialEscalatesOncePerWindow(t *testing.T) {
|
|||
}
|
||||
|
||||
output := buf.String()
|
||||
if got := countLevel(output, "warn"); got != 1 {
|
||||
if got := countLogEvents(t, buf, "warn", "Repeated authorization denials from one caller; possible endpoint probing"); got != 1 {
|
||||
t.Fatalf("warn lines = %d, want exactly 1 for a single window", got)
|
||||
}
|
||||
if !strings.Contains(output, "possible endpoint probing") {
|
||||
|
|
@ -91,7 +131,7 @@ func TestAuthDenialWindowRolloverRearmsEscalation(t *testing.T) {
|
|||
for i := 0; i < authDenialWarnThreshold; i++ {
|
||||
logAuthDenial(req, "viewer", "denied", nil)
|
||||
}
|
||||
if got := countLevel(buf.String(), "warn"); got != 1 {
|
||||
if got := countLogEvents(t, buf, "warn", "Repeated authorization denials from one caller; possible endpoint probing"); got != 1 {
|
||||
t.Fatalf("first window warn lines = %d, want 1", got)
|
||||
}
|
||||
|
||||
|
|
@ -101,7 +141,7 @@ func TestAuthDenialWindowRolloverRearmsEscalation(t *testing.T) {
|
|||
for i := 0; i < authDenialWarnThreshold; i++ {
|
||||
logAuthDenial(req, "viewer", "denied", nil)
|
||||
}
|
||||
if got := countLevel(buf.String(), "warn"); got != 2 {
|
||||
if got := countLogEvents(t, buf, "warn", "Repeated authorization denials from one caller; possible endpoint probing"); got != 2 {
|
||||
t.Fatalf("warn lines after rollover = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -117,7 +157,7 @@ func TestAuthDenialTracksIdentityAcrossAddresses(t *testing.T) {
|
|||
logAuthDenial(req, "viewer", "denied", nil)
|
||||
}
|
||||
|
||||
if got := countLevel(buf.String(), "warn"); got != 1 {
|
||||
if got := countLogEvents(t, buf, "warn", "Repeated authorization denials from one caller; possible endpoint probing"); got != 1 {
|
||||
t.Fatalf("warn lines = %d, want 1 — identity should aggregate across addresses", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -133,11 +173,52 @@ func TestAuthDenialSeparatesCallers(t *testing.T) {
|
|||
logAuthDenial(req, "viewer-b", "denied", nil)
|
||||
}
|
||||
|
||||
if got := countLevel(buf.String(), "warn"); got != 0 {
|
||||
if got := countLogEvents(t, buf, "warn", "Repeated authorization denials from one caller; possible endpoint probing"); got != 0 {
|
||||
t.Fatalf("warn lines = %d, want 0 — neither caller crossed the threshold", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The API package can have background monitor goroutines logging while these
|
||||
// tests run, and production handlers may record denials concurrently. Exercise
|
||||
// that concurrency directly so an unsynchronized capture sink or tracker state
|
||||
// fails deterministically under go test -race.
|
||||
func TestAuthDenialConcurrentLoggingIsRaceFreeAndWarnsOnce(t *testing.T) {
|
||||
buf := captureAuthDenialLogs(t)
|
||||
|
||||
base := time.Unix(1_700_000_000, 0)
|
||||
authDenialNow = func() time.Time { return base }
|
||||
const (
|
||||
identity = "concurrent-viewer"
|
||||
message = "concurrent denial"
|
||||
attempts = authDenialWarnThreshold * 8
|
||||
)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(attempts)
|
||||
for i := 0; i < attempts; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
req := httptest.NewRequest("GET", "/api/connections", nil)
|
||||
logAuthDenial(req, identity, message, nil)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if got := countLogEvents(t, buf, "debug", message); got != attempts {
|
||||
t.Fatalf("debug lines = %d, want %d", got, attempts)
|
||||
}
|
||||
if got := countLogEvents(t, buf, "warn", "Repeated authorization denials from one caller; possible endpoint probing"); got != 1 {
|
||||
t.Fatalf("warn lines = %d, want exactly 1 for concurrent denials in one window", got)
|
||||
}
|
||||
|
||||
authDenialMu.Lock()
|
||||
entry := *authDenials["user:"+identity]
|
||||
authDenialMu.Unlock()
|
||||
if entry.count != attempts || !entry.warned {
|
||||
t.Fatalf("counter = {count:%d warned:%t}, want {count:%d warned:true}", entry.count, entry.warned, attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthDenialPruneKeepsTrackedSetBounded(t *testing.T) {
|
||||
captureAuthDenialLogs(t)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue