mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-21 04:57:48 +00:00
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
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// r167TimeoutErr implements net.Error with Timeout()==true so the retry
|
||||
// executor classifies it as a client-side timeout (as http.Client.Do does
|
||||
// when the server takes longer than Client.Timeout).
|
||||
type r167TimeoutErr struct{}
|
||||
|
||||
func (r167TimeoutErr) Error() string { return "net/http: request canceled (Client.Timeout exceeded)" }
|
||||
func (r167TimeoutErr) Timeout() bool { return true }
|
||||
func (r167TimeoutErr) Temporary() bool { return true }
|
||||
|
||||
// r167RoundTripper always returns a client-side timeout and counts the number
|
||||
// of token requests actually sent.
|
||||
type r167RoundTripper struct {
|
||||
calls *int32
|
||||
}
|
||||
|
||||
func (rt r167RoundTripper) RoundTrip(_ *http.Request) (*http.Response, error) {
|
||||
atomic.AddInt32(rt.calls, 1)
|
||||
return nil, &url.Error{Op: "Post", URL: "http://token.example.com/token", Err: r167TimeoutErr{}}
|
||||
}
|
||||
|
||||
// TestR167_RefreshSingleUseOnTimeout regresses the token refresh retry path
|
||||
// (token_resilience.go): ExecuteTokenRefresh now runs single-use, so a
|
||||
// client-side timeout is NOT retried — it yields after exactly one exchange
|
||||
// attempt. On a refresh-token-rotating provider a timed-out-but-processed
|
||||
// first attempt rotates the token, so a retry would re-present the stale
|
||||
// token, surface invalid_grant, and clear the session (full logout) — a
|
||||
// transient timeout escalating to logout. Old code passed singleUse=false,
|
||||
// retrying on timeout up to MaxAttempts (3) and hitting the token endpoint
|
||||
// 3 times.
|
||||
func TestR167_RefreshSingleUseOnTimeout(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("timing-sensitive retry regression")
|
||||
}
|
||||
|
||||
var calls int32
|
||||
oidc := &TraefikOidc{
|
||||
tokenURL: "http://token.example.com/token",
|
||||
clientID: "test_client",
|
||||
clientSecret: "test_secret",
|
||||
tokenHTTPClient: &http.Client{Transport: r167RoundTripper{calls: &calls}},
|
||||
logger: GetSingletonNoOpLogger(),
|
||||
}
|
||||
|
||||
cfg := DefaultTokenResilienceConfig()
|
||||
// Keep the old (retrying) path fast for the fail-on-old run.
|
||||
cfg.RetryConfig.InitialDelay = time.Millisecond
|
||||
cfg.RetryConfig.MaxDelay = time.Millisecond
|
||||
rm := NewTokenResilienceManager(cfg, GetSingletonNoOpLogger())
|
||||
|
||||
_, err := rm.ExecuteTokenRefresh(context.Background(), oidc, "refresh_token")
|
||||
if err == nil {
|
||||
t.Fatal("expect a timeout error from the token endpoint")
|
||||
}
|
||||
|
||||
got := atomic.LoadInt32(&calls)
|
||||
if got != 1 {
|
||||
t.Fatalf("refresh must be single-use and not retried on timeout: got %d token-exchange attempts, want 1 (old code retried to %d on a timeout, which on a rotating provider surfaces invalid_grant and logs out the user)", got, 1)
|
||||
}
|
||||
|
||||
// Sanity: the error surfaced is the timeout, not something down-track.
|
||||
var netErr interface {
|
||||
Timeout() bool
|
||||
}
|
||||
if !errors.As(err, &netErr) || !netErr.Timeout() {
|
||||
t.Fatalf("expected a timeout error, got: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user