Files
traefikoidc/review_r34_regression_test.go
T
lukaszraczylo 35d26078f0 Fix 6 issues surfaced by adversarial agent team (round 35)
Concurrency/lifecycle (P1, highest-value):
- singleton_resources.go: RegisterBackgroundTask now REPLACES a stopped
  task instead of reusing it. BackgroundTask.Start is sync.Once-guarded,
  so a task stopped on instance close and re-registered (Traefik
  close+recreate for the same provider) was permanently dead: IsTaskRunning
  returned false (started && !stopped) so callers retried a start that
  was a no-op. Metadata refresh, token cleanup, and health checks now
  resume across a recreate cycle.
- error_recovery.go: the shared graceful-degradation health task was
  bound to the FIRST instance's performHealthChecks (later instances'
  checks never ran) and any instance's Close stopped it for all. Added a
  global live-instance registry; the shared task runs every instance's
  checks and is stopped only when the last instance closes (mirrors the
  singleton-token-cleanup gate).

Storage/cache:
- hybrid.go recordL2Error: Store(now) then immediately compared
  time.Since(t)<1s against its own write => ALWAYS fired, so a single
  transient L2 error flipped the whole cache into fallback (full L2
  bypass -> upstream stampede). Now enters fallback only on a PRIOR
  error within the window (true "multiple errors"). Updated two tests
  that had encoded the old single-error-fallback bug.
- redis_pool.go: ConnectionPool.Put checked closed but sent on
  p.connections without holding p.mu; Close closed it under p.mu.
  Window => 'send on closed channel' panic at shutdown. Put now holds
  p.mu (the lock only Close used).

HTTP/transport:
- jwk.go fetchJWKS: added an explicit >1 MiB guard (was silent partial
  read, could yield a truncated keyset and spurious missing-kid).
- http_client_pool.go configKey: now includes every transport-behavior
  field it actually consumes (compression, HTTP2, keep-alives, timeouts,
  buffer sizes, dial) so behaviorally different configs no longer share
  one transport built with the first config's settings.

Each fix has a regression test. Verified: full source-order suite green,
golangci-lint 0 issues, 3x full -race -count=1 -shuffle=on ./... all exit 0.
2026-08-12 23:55:55 +01:00

181 lines
5.5 KiB
Go

package traefikoidc
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestFetchJWKSOversizeResponse: a provider JWKS larger than 1 MiB must be
// rejected explicitly, not silently truncated (which could yield a partial
// keyset and a spurious "no matching public key for kid").
func TestFetchJWKSOversizeResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Body strictly larger than the 1 MiB read limit (prefixed with a
// valid JSON opener so the ONLY reason to reject is the size guard).
body := `{"keys":[` + strings.Repeat("{}", (1<<20)/2) + `]}`
w.Write([]byte(body))
}))
defer server.Close()
cache := NewJWKCache()
jwks, err := cache.GetJWKS(context.Background(), server.URL, http.DefaultClient)
require.Error(t, err, "oversized JWKS must be rejected")
require.Nil(t, jwks)
require.Contains(t, err.Error(), "1 MiB")
}
func waitForHealthTaskRunning(t *testing.T, want bool) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if GetResourceManager().IsTaskRunning("graceful-degradation-health-check") == want {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("health task running state != %v", want)
}
func waitForGdInstances(t *testing.T, want int) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
gdInstances.RLock()
n := len(gdInstances.set)
gdInstances.RUnlock()
if n >= want {
return
}
time.Sleep(5 * time.Millisecond)
}
gdInstances.RLock()
n := len(gdInstances.set)
gdInstances.RUnlock()
t.Fatalf("gd instances wanted %d got %d", want, n)
}
// resetGdInstancesForTest establishes a clean baseline for the process-global
// instance registry so a test's "last instance" and iteration assertions are
// not skewed by instances created by earlier tests in the same process.
func resetGdInstancesForTest() {
gdInstances.Lock()
gdInstances.set = make(map[*GracefulDegradation]struct{})
gdInstances.Unlock()
}
// TestConfigKeySeparatesBehaviorDifferingConfigs: two configs that differ only
// in a transport-behavior field (here DisableCompression) must get distinct
// transports, not share one built with the first config's settings.
func TestConfigKeySeparatesBehaviorDifferingConfigs(t *testing.T) {
pool := &SharedTransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
maxClients: 5,
clientCount: 0,
ctx: context.Background(),
}
a := DefaultHTTPClientConfig()
b := a
b.DisableCompression = !a.DisableCompression
require.NotEqual(t, pool.configKey(a), pool.configKey(b),
"configs differing only in DisableCompression must have distinct keys")
ta := pool.GetOrCreateTransport(a)
tb := pool.GetOrCreateTransport(b)
require.NotEqual(t, ta, tb, "behaviorally different configs must not share a transport")
// Same config still reuses its transport.
ta2 := pool.GetOrCreateTransport(a)
require.Equal(t, ta, ta2, "identical config reuses its transport")
}
// TestRegisterBackgroundTaskRestartsAfterStop: a singleton task stopped on a
// close+recreate cycle must be restartable on re-registration (Start is
// once-guarded, so the registry must replace a stopped task with a fresh one).
func TestRegisterBackgroundTaskRestartsAfterStop(t *testing.T) {
rm := &ResourceManager{
tasks: map[string]*BackgroundTask{},
logger: GetSingletonNoOpLogger(),
}
const name = "restart-test-task"
var calls int32
register := func() {
t.Helper()
require.NoError(t, rm.RegisterBackgroundTask(name, time.Hour, func() {
atomic.AddInt32(&calls, 1)
}))
}
register()
require.NoError(t, rm.StartBackgroundTask(name))
require.True(t, rm.IsTaskRunning(name))
require.NoError(t, rm.StopBackgroundTask(name))
require.False(t, rm.IsTaskRunning(name))
// Re-register after stop: must be restartable (fresh BackgroundTask).
register()
require.NoError(t, rm.StartBackgroundTask(name))
require.True(t, rm.IsTaskRunning(name), "re-registered singleton task must be restartable after stop")
require.NoError(t, rm.StopBackgroundTask(name))
}
func TestSharedHealthTaskSurvivesOtherInstanceClose(t *testing.T) {
resetGdInstancesForTest()
cfg := DefaultGracefulDegradationConfig()
cfg.HealthCheckInterval = time.Hour // keep the task from churning
gd1 := NewGracefulDegradation(cfg, GetSingletonNoOpLogger())
gd2 := NewGracefulDegradation(cfg, GetSingletonNoOpLogger())
defer gd2.Close()
waitForHealthTaskRunning(t, true)
waitForGdInstances(t, 2)
gd1.Close()
waitForHealthTaskRunning(t, true)
gd2.Close()
waitForHealthTaskRunning(t, false)
}
// TestSharedHealthTaskReachesAllInstances: the shared health task must exercise
// health checks registered on ANY live instance, not just the first one.
func TestSharedHealthTaskReachesAllInstances(t *testing.T) {
resetGdInstancesForTest()
cfg := DefaultGracefulDegradationConfig()
cfg.HealthCheckInterval = time.Hour
gd1 := NewGracefulDegradation(cfg, GetSingletonNoOpLogger())
gd2 := NewGracefulDegradation(cfg, GetSingletonNoOpLogger())
defer gd1.Close()
defer gd2.Close()
waitForGdInstances(t, 2)
waitForHealthTaskRunning(t, true)
var called atomic.Bool
gd2.RegisterHealthCheck("svc-on-instance-2", func() bool {
called.Store(true)
return true
})
globalPerformHealthChecks()
require.True(t, called.Load(),
"a health check registered on the second instance must be exercised by the shared task")
}
// TestRecordL2Error not here: lives in internal/cache/backends (hybrid test).