Files
traefikoidc/review_r123_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

151 lines
5.6 KiB
Go

package traefikoidc
import (
"net/http/httptest"
"testing"
)
// TestRetryExecutor_Terminal4xxNotRetriedByMessage guards the R123 fix to
// error_recovery.go isRetryableError: the configured message-substring scan
// ran BEFORE the HTTP status check, so a terminal 4xx whose provider body
// merely contained a retryable-looking word (e.g. a 400 whose message
// includes "timeout") was reclassified retryable and retried to MaxAttempts
// on a permanent error. A real (non-0) status that is neither 5xx nor 429
// must win over message text.
func TestRetryExecutor_Terminal4xxNotRetriedByMessage(t *testing.T) {
re := NewRetryExecutor(RetryConfig{RetryableErrors: []string{"timeout"}}, GetSingletonNoOpLogger())
if re.isRetryableError(&HTTPError{StatusCode: 400, Message: "invalid grant: token timeout"}) {
t.Fatal("terminal 400 with a retryable-looking body must NOT be classified retryable")
}
if !re.isRetryableError(&HTTPError{StatusCode: 503, Message: "timeout"}) {
t.Fatal("503 must remain retryable")
}
// Status-0 message carrier: the substring scan must still apply so a
// message-loaded (no real HTTP status) error with a retryable word
// keeps its historical retryable behavior.
if !re.isRetryableError(&HTTPError{StatusCode: 0, Message: "request timeout"}) {
t.Fatal("status-0 message carrier with a retryable word must remain retryable")
}
}
// TestConfigValidate_RejectsNonPositiveHSTSMaxAge guards the R123 fix to
// settings.go Config.Validate: an enabled HSTS with a non-positive
// StrictTransportSecurityMaxAge emitted "Strict-Transport-Security:
// max-age=0" (or negative) verbatim, which per RFC 6797 s.6.1 tells the
// UA to REMOVE the HSTS policy — the operator's protection silently
// disabled with no error. It must be rejected at validation.
func validTestConfig() *Config {
cfg := CreateConfig()
cfg.ProviderURL = "https://auth.example.com"
cfg.CallbackURL = "/callback"
cfg.ClientID = "test-client"
cfg.ClientSecret = "test-client-secret"
cfg.SessionEncryptionKey = "test-encryption-key-32-bytes-long!!"
return cfg
}
func TestConfigValidate_RejectsNonPositiveHSTSMaxAge(t *testing.T) {
cfg := validTestConfig()
cfg.SecurityHeaders.StrictTransportSecurity = true
cfg.SecurityHeaders.StrictTransportSecurityMaxAge = 3
if err := cfg.Validate(); err != nil {
t.Fatalf("positive max-age must validate: %v", err)
}
for _, bad := range []int{0, -1} {
cfg := validTestConfig()
cfg.SecurityHeaders.StrictTransportSecurity = true
cfg.SecurityHeaders.StrictTransportSecurityMaxAge = bad
if err := cfg.Validate(); err == nil {
t.Fatalf("max-age=%d with HSTS enabled must fail validation", bad)
}
}
}
// TestConfigValidate_RedisEnvAppliedBeforeValidate guards the R123 fix to
// settings.go Config.Validate: an operator enabling Redis via REDIS_ENABLED
// (documented) had c.Redis.Enabled==false at config load, so the Redis
// validation guards (address required) were skipped and the backend was
// built with an empty address, silently falling back to memory-only at
// runtime. Validate must apply env fallbacks before gating on Enabled.
func TestConfigValidate_RedisEnvAppliedBeforeValidate(t *testing.T) {
t.Setenv("REDIS_ENABLED", "true")
t.Setenv("REDIS_ADDRESS", "")
cfg := validTestConfig()
cfg.Redis = &RedisConfig{}
if err := cfg.Validate(); err == nil {
t.Fatal("env-enabled Redis with an empty address must fail validation")
}
// With an address the same env config must pass.
t.Setenv("REDIS_ADDRESS", "redis://127.0.0.1:6379")
cfg = validTestConfig()
cfg.Redis = &RedisConfig{}
if err := cfg.Validate(); err != nil {
t.Fatalf("env-enabled Redis with an address must validate: %v", err)
}
}
// TestValidateRedirectCount_PersistsReset guards the R123 fix to
// auth_flow.go validateRedirectCount: on exceeding the redirect limit the
// counter was ResetRedirectCount() but never Saved, so the cookie kept the
// old count and every subsequent request re-observed the limit, hard-
// locking the user on 508 for the whole session with no recovery. The
// reset must be persisted so the next request starts fresh.
func TestValidateRedirectCount_PersistsReset(t *testing.T) {
sessionManager, err := NewSessionManager(
"test-encryption-key-32-bytes-long!!",
false,
"",
"",
0,
NewLogger("error"),
)
if err != nil {
t.Fatalf("session manager: %v", err)
}
defer sessionManager.Shutdown()
tOidc := &TraefikOidc{
logger: GetSingletonNoOpLogger(),
sessionManager: sessionManager,
}
req := httptest.NewRequest("GET", "/", nil)
rw := httptest.NewRecorder()
session, err := sessionManager.GetSession(req)
if err != nil {
t.Fatalf("get session: %v", err)
}
defer session.returnToPoolSafely()
// Set the counter at/over the limit.
session.mainSession.Values["redirect_count"] = 5
// Trigger the limit path; it must persist the reset via Save.
if err := tOidc.validateRedirectCount(session, rw, req); err == nil {
t.Fatal("validateRedirectCount must return an error when the limit is reached")
}
// The reset must have been flushed to a session cookie on this response.
cookies := rw.Result().Cookies()
if len(cookies) == 0 {
t.Fatal("validateRedirectCount must emit a session cookie carrying the reset")
}
// A subsequent request carrying that cookie must see the counter reset.
afterReq := httptest.NewRequest("GET", "/", nil)
afterReq.Header.Set("Cookie", cookies[0].String())
afterSess, err := sessionManager.GetSession(afterReq)
if err != nil {
t.Fatalf("get after session: %v", err)
}
defer afterSess.returnToPoolSafely()
if c := afterSess.GetRedirectCount(); c != 0 {
t.Fatalf("reset must be persisted (next request starts at 0), got %d", c)
}
}