test(ingestion): OIDC discovery 502s then 200 against a real issuer (#83)
The retry landed in 9a232ad but was tested only through startupretry with
fake funcs; nothing exercised chassisauth.NewJWTValidator's real 502 path.
Extracts the main() closure into buildJWTValidator (behaviour unchanged)
and tests it against an httptest issuer: 3x502 then 200 succeeds with 4
discoveries; a persistent 502 stops at exactly the 8-attempt budget with
the last error; cancelling ctx mid-backoff returns context.Canceled.
Flip-verified: Attempts=1 turns all three red.
Lane: risk/medium issue, committed to main because the behaviour change
already shipped in 9a232ad; this is a test plus a pure extraction.
Closes #83
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/startupretry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildJWTValidator runs OIDC discovery under policy, so a 502 from an auth
|
||||||
|
// upstream that is not serving yet on a cold boot is retried instead of
|
||||||
|
// exiting (#83). It honours ctx between attempts.
|
||||||
|
func buildJWTValidator(
|
||||||
|
ctx context.Context, logger *slog.Logger, issuer, audience string, policy startupretry.Policy,
|
||||||
|
) (*chassisauth.JWTValidator, error) {
|
||||||
|
return startupretry.Do(ctx, policy, func(ctx context.Context) (*chassisauth.JWTValidator, error) {
|
||||||
|
v, err := chassisauth.NewJWTValidator(ctx, issuer, audience)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("oidc discovery not ready, retrying", "err", err)
|
||||||
|
}
|
||||||
|
return v, err
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/startupretry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeIssuer answers OIDC discovery with 502 for the first fail requests, as
|
||||||
|
// ingress does while dex is not yet serving on a cold boot (#83), then with a
|
||||||
|
// real discovery document and an empty JWKS.
|
||||||
|
func fakeIssuer(t *testing.T, fail int32) (*httptest.Server, *atomic.Int32) {
|
||||||
|
t.Helper()
|
||||||
|
var discoveries atomic.Int32
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
var srv *httptest.Server
|
||||||
|
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
if discoveries.Add(1) <= fail {
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, `{"issuer":"`+srv.URL+`","jwks_uri":"`+srv.URL+`/keys"}`)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/keys", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, `{"keys":[]}`)
|
||||||
|
})
|
||||||
|
srv = httptest.NewServer(mux)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv, &discoveries
|
||||||
|
}
|
||||||
|
|
||||||
|
var fastPolicy = startupretry.Policy{Attempts: 8, Base: time.Millisecond, Max: 5 * time.Millisecond}
|
||||||
|
|
||||||
|
func TestBuildJWTValidatorRetriesDiscovery502sThenSucceeds(t *testing.T) {
|
||||||
|
srv, discoveries := fakeIssuer(t, 3)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
v, err := buildJWTValidator(ctx, slog.New(slog.DiscardHandler), srv.URL, "brain", fastPolicy)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, v)
|
||||||
|
assert.Equal(t, int32(4), discoveries.Load(), "three 502s, then the successful discovery")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildJWTValidatorGivesUpAfterTheBudgetWithTheLastError(t *testing.T) {
|
||||||
|
srv, discoveries := fakeIssuer(t, 1000)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := buildJWTValidator(ctx, slog.New(slog.DiscardHandler), srv.URL, "brain", fastPolicy)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "status 502")
|
||||||
|
assert.Equal(t, int32(8), discoveries.Load(), "bounded: exactly the attempt budget")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildJWTValidatorStopsRetryingWhenTheContextIsCancelled(t *testing.T) {
|
||||||
|
srv, _ := fakeIssuer(t, 1000)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
slow := startupretry.Policy{Attempts: 8, Base: time.Hour, Max: time.Hour}
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := buildJWTValidator(ctx, slog.New(slog.DiscardHandler), srv.URL, "brain", slow)
|
||||||
|
done <- err
|
||||||
|
}()
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
assert.ErrorIs(t, err, context.Canceled)
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("did not return after the context was cancelled")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -381,15 +381,9 @@ func main() {
|
|||||||
//
|
//
|
||||||
// The budget stays bounded: retrying forever would turn a misconfiguration
|
// The budget stays bounded: retrying forever would turn a misconfiguration
|
||||||
// into a process that looks alive and never serves.
|
// into a process that looks alive and never serves.
|
||||||
jwtValidator, err := startupretry.Do(ctx,
|
jwtValidator, err := buildJWTValidator(ctx, logger,
|
||||||
startupretry.Policy{Attempts: 8, Base: time.Second, Max: 30 * time.Second},
|
os.Getenv("DEX_ISSUER_URL"), os.Getenv("MCP_AUDIENCE"),
|
||||||
func(ctx context.Context) (*chassisauth.JWTValidator, error) {
|
startupretry.Policy{Attempts: 8, Base: time.Second, Max: 30 * time.Second})
|
||||||
v, err := chassisauth.NewJWTValidator(ctx, os.Getenv("DEX_ISSUER_URL"), os.Getenv("MCP_AUDIENCE"))
|
|
||||||
if err != nil {
|
|
||||||
logger.Warn("oidc discovery not ready, retrying", "err", err)
|
|
||||||
}
|
|
||||||
return v, err
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("build jwt validator", "err", err)
|
logger.Error("build jwt validator", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
Reference in New Issue
Block a user