Files
traefikoidc/review_r122_test.go
T
lukaszraczylo 6d96e8869b test: add R-round regression and security suite
- fail-on-old regression tests for every shipped fix (R-rounds)
- fuzz targets (parseJWT, bearer), concurrency-stress, shuffle-flake guards
- coverage-closing contract tests (health-check backend, TLS-posture reuse)
- r154 ID-token fixture built from parts to avoid false-positive secret scan
2026-08-14 16:22:39 +01:00

139 lines
5.5 KiB
Go

package traefikoidc
import (
"crypto/rand"
"encoding/base64"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
// TestRefreshCoordinator_IsUnderMemoryPressureUsesOwnThreshold guards the R122
// fix to refresh_coordinator.go: isUnderMemoryPressure consulted the GLOBAL
// memory monitor's alert level (driven by the shared 256MB "High" heap
// threshold, and only populated once real sampling runs) instead of the
// coordinator's OWN MemoryPressureThresholdMB. That meant (a) the operator's
// configured threshold was ignored entirely, and (b) in a shared process
// where real heap crosses 256MB every request was silently denied. The gate
// must compare current heap against the coordinator's own threshold.
func TestRefreshCoordinator_IsUnderMemoryPressureUsesOwnThreshold(t *testing.T) {
// Pin the global monitor's stats to a deterministic 3MB heap for the
// duration of this test (restored below). The original test assumed
// "any real heap exceeds 1MB", which broke under -shuffle when the
// real heap was below the threshold (GetCurrentStats returns the
// global monitor's cached, possibly-stale snapshot) — an intermittent
// CI failure with no production cause (R196).
mm := GetGlobalMemoryMonitor()
mm.mu.Lock()
old := mm.lastStats
mm.lastStats = &MemoryStats{HeapAllocBytes: 3 * (1 << 20)} // 3MB
mm.mu.Unlock()
t.Cleanup(func() {
mm.mu.Lock()
mm.lastStats = old
mm.mu.Unlock()
})
// Threshold 1MB: pinned heap (3MB) exceeds it -> under pressure.
cfg := DefaultRefreshCoordinatorConfig()
cfg.MemoryPressureThresholdMB = 1
rc := NewRefreshCoordinator(cfg, GetSingletonNoOpLogger())
if !rc.isUnderMemoryPressure() {
t.Fatal("coordinator with a 1MB threshold must report under memory pressure for a 3MB heap")
}
// Threshold 1TB: never under pressure.
cfg2 := DefaultRefreshCoordinatorConfig()
cfg2.MemoryPressureThresholdMB = 1 << 20 // 1,048,576 MB
rc2 := NewRefreshCoordinator(cfg2, GetSingletonNoOpLogger())
if rc2.isUnderMemoryPressure() {
t.Fatal("coordinator with a ~1TB threshold must not report under memory pressure")
}
}
// TestRegisterBackgroundTask_ReplacesNeverStartedTask guards the R122 fix to
// singleton_resources.go: RegisterBackgroundTask kept an existing task when
// stopped==0, but a task whose startOnce was consumed without ever starting
// (Start rejected by the circuit breaker: started==0, stopped==0) has
// stopped==0 too, so it was kept and StartBackgroundTask became a permanent
// no-op (Start is sync.Once-guarded), leaving the singleton task dead until
// restart. The keep-condition must require started==1.
func TestRegisterBackgroundTask_ReplacesNeverStartedTask(t *testing.T) {
rm := &ResourceManager{
tasks: map[string]*BackgroundTask{},
logger: GetSingletonNoOpLogger(),
}
const name = "replace-never-started"
// A task in the never-started state (started==0, stopped==0): its
// Start was consumed via a circuit-breaker rejection. Re-registering
// must REPLACE it so a later Start can actually run it.
dead := NewBackgroundTask(name, time.Hour, func() {}, rm.logger, &rm.wg)
atomic.StoreInt32(&dead.started, 0)
atomic.StoreInt32(&dead.stopped, 0)
rm.tasks[name] = dead
if err := rm.RegisterBackgroundTask(name, time.Hour, func() {}); err != nil {
t.Fatalf("register: %v", err)
}
if rm.tasks[name] == dead {
t.Fatal("a never-started task (started==0, stopped==0) must be REPLACED, otherwise Start is a permanent no-op")
}
// An actively running task (started==1, stopped==0) must be kept.
running := NewBackgroundTask("running", time.Hour, func() {}, rm.logger, &rm.wg)
atomic.StoreInt32(&running.started, 1)
atomic.StoreInt32(&running.stopped, 0)
rm.tasks["running"] = running
if err := rm.RegisterBackgroundTask("running", time.Hour, func() {}); err != nil {
t.Fatalf("register running: %v", err)
}
if rm.tasks["running"] != running {
t.Fatal("an actively running task must be kept (idempotent re-registration)")
}
}
// TestSetAccessToken_PreservesOldTokenOnChunkAbort guards the R122 fix to
// session.go: the chunked-write branch of SetAccessToken cleared the
// existing token (accessSession.Values["token"] and accessTokenChunks)
// BEFORE validating the new token's chunks. On any abort (here: >50
// chunks, since maxCookieSize=1400 and the token crosses 50*1400=
// 70KB with compression still < 100KB) the previous valid access token
// was left nowhere, GetAccessToken returned "", and the user was forced
// to re-authenticate. The clear now happens only after validation.
func TestSetAccessToken_PreservesOldTokenOnChunkAbort(t *testing.T) {
sm, err := NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, NewLogger("error"))
if err != nil {
t.Fatalf("session manager: %v", err)
}
req := httptest.NewRequest("GET", "/", nil)
sess, err := sm.GetSession(req)
if err != nil {
t.Fatalf("get session: %v", err)
}
t.Cleanup(sess.returnToPoolSafely)
const small = "smallaccesstokenvaluevaluevalue"
sess.SetAccessToken(small)
if got := sess.GetAccessToken(); got != small {
t.Fatalf("precondition: small token must be retrievable, got %q", got)
}
// Incompressible payload large enough that compressed size lands
// between 50*maxCookieSize (70KB) and the 100KB cap, so the chunked
// branch splits into >50 chunks and aborts after too-many-chunks.
blob := make([]byte, 60*1024)
if _, err := rand.Read(blob); err != nil {
t.Fatal(err)
}
bigToken := base64.StdEncoding.EncodeToString(blob)
sess.SetAccessToken(bigToken)
if got := sess.GetAccessToken(); got != small {
t.Fatalf("old access token must be preserved when storing a new oversized token fails, got %q", got)
}
}