From dd1238a4e4a6b89c7d2405482511c6999bcf7615 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 20 May 2026 11:32:11 +0900 Subject: [PATCH] =?UTF-8?q?headless=20JWKS=20=EC=9B=8C=EC=BB=A4=20backoff?= =?UTF-8?q?=20=ED=9A=8C=EA=B7=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/headless_jwks_cache_test.go | 242 ++++++++++++++++++ 1 file changed, 242 insertions(+) diff --git a/backend/internal/service/headless_jwks_cache_test.go b/backend/internal/service/headless_jwks_cache_test.go index 8d835eef..b08b8225 100644 --- a/backend/internal/service/headless_jwks_cache_test.go +++ b/backend/internal/service/headless_jwks_cache_test.go @@ -6,7 +6,9 @@ import ( "crypto/rand" "crypto/rsa" "encoding/json" + "io" "net/http" + "strings" "testing" "time" @@ -186,6 +188,218 @@ func TestHeadlessJWKSCacheService_ShouldPrefetch_SkipsUntilNextRetryAt(t *testin assert.True(t, cacheService.ShouldPrefetch(state, now.Add(11*time.Minute))) } +func TestHeadlessJWKSCacheWorker_RunOnce_SkipsBackoffTargets(t *testing.T) { + clients := []domain.HydraClient{ + newTestHeadlessClient("client-fail", "https://fail.example.com/.well-known/jwks.json"), + newTestHeadlessClient("client-skip", "https://skip.example.com/.well-known/jwks.json"), + } + + hydra := &HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: clientForHandler(jsonHandler(t, clients)), + } + + redisRepo := &headlessJWKSCacheTestRedis{} + cacheService := NewHeadlessJWKSCacheService(redisRepo, nil) + cacheService.FailureThreshold = 3 + cacheService.FailureBackoff = 15 * time.Minute + + now := time.Now() + require.NoError(t, cacheService.SaveState("client-fail", domain.HeadlessJWKSCacheState{ + ClientID: "client-fail", + JWKSURI: clients[0].HeadlessJWKSURI(), + LastRefreshStatus: "failure", + ConsecutiveFailures: 2, + })) + require.NoError(t, cacheService.SaveState("client-skip", domain.HeadlessJWKSCacheState{ + ClientID: "client-skip", + JWKSURI: clients[1].HeadlessJWKSURI(), + LastRefreshStatus: "failure", + ConsecutiveFailures: 3, + NextRetryAt: ptrTestTime(now.Add(10 * time.Minute)), + })) + + fetchCounts := map[string]int{} + cacheService.HTTPClient = &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + fetchCounts[req.URL.Host]++ + if req.URL.Host == "fail.example.com" { + return jsonHTTPResponse(http.StatusInternalServerError, `{"error":"boom"}`), nil + } + t.Fatalf("unexpected fetch for host %s", req.URL.Host) + return nil, nil + }), + } + + worker := &HeadlessJWKSCacheWorker{ + Hydra: hydra, + Cache: cacheService, + PageSize: 100, + } + + worker.runOnce(context.Background()) + + assert.Equal(t, 1, fetchCounts["fail.example.com"]) + assert.Equal(t, 0, fetchCounts["skip.example.com"]) + + failedState, err := cacheService.GetState("client-fail") + require.NoError(t, err) + require.NotNil(t, failedState) + assert.Equal(t, 3, failedState.ConsecutiveFailures) + require.NotNil(t, failedState.NextRetryAt) + + skippedState, err := cacheService.GetState("client-skip") + require.NoError(t, err) + require.NotNil(t, skippedState) + assert.Equal(t, 3, skippedState.ConsecutiveFailures) + require.NotNil(t, skippedState.NextRetryAt) + assert.WithinDuration(t, now.Add(10*time.Minute), *skippedState.NextRetryAt, time.Second) +} + +func TestHeadlessJWKSCacheWorker_RunOnce_RetriesAfterBackoffAndClearsFailureStateOnSuccess(t *testing.T) { + _, freshJWKS := mustServiceHeadlessRSAJWK(t, "fresh-key") + freshRaw, err := json.Marshal(freshJWKS) + require.NoError(t, err) + + client := newTestHeadlessClient("client-recover", "https://recover.example.com/.well-known/jwks.json") + hydra := &HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: clientForHandler(jsonHandler(t, []domain.HydraClient{client})), + } + + redisRepo := &headlessJWKSCacheTestRedis{} + cacheService := NewHeadlessJWKSCacheService(redisRepo, nil) + cacheService.FailureThreshold = 3 + cacheService.FailureBackoff = 15 * time.Minute + + require.NoError(t, cacheService.SaveState("client-recover", domain.HeadlessJWKSCacheState{ + ClientID: "client-recover", + JWKSURI: client.HeadlessJWKSURI(), + LastRefreshStatus: "failure", + LastError: "previous failure", + ConsecutiveFailures: 3, + NextRetryAt: ptrTestTime(time.Now().Add(-time.Minute)), + })) + + fetchCount := 0 + cacheService.HTTPClient = &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + fetchCount++ + assert.Equal(t, "recover.example.com", req.URL.Host) + return jsonHTTPResponse(http.StatusOK, string(freshRaw)), nil + }), + } + + worker := &HeadlessJWKSCacheWorker{ + Hydra: hydra, + Cache: cacheService, + PageSize: 100, + } + + worker.runOnce(context.Background()) + + assert.Equal(t, 1, fetchCount) + + recoveredState, err := cacheService.GetState("client-recover") + require.NoError(t, err) + require.NotNil(t, recoveredState) + assert.Equal(t, "success", recoveredState.LastRefreshStatus) + assert.Empty(t, recoveredState.LastError) + assert.Equal(t, 0, recoveredState.ConsecutiveFailures) + assert.Nil(t, recoveredState.NextRetryAt) + assert.Equal(t, []string{"fresh-key"}, recoveredState.CachedKids) +} + +func TestHeadlessJWKSCacheWorker_RunOnce_MixedClients(t *testing.T) { + _, successJWKS := mustServiceHeadlessRSAJWK(t, "success-key") + successRaw, err := json.Marshal(successJWKS) + require.NoError(t, err) + + successClient := newTestHeadlessClient("client-success", "https://success.example.com/.well-known/jwks.json") + failClient := newTestHeadlessClient("client-fail", "https://fail.example.com/.well-known/jwks.json") + skipClient := newTestHeadlessClient("client-skip", "https://skip.example.com/.well-known/jwks.json") + disabledClient := domain.HydraClient{ + ClientID: "client-disabled", + Metadata: map[string]any{ + domain.MetadataHeadlessLoginEnabled: false, + domain.MetadataHeadlessJWKSURI: "https://disabled.example.com/.well-known/jwks.json", + domain.MetadataHeadlessTokenEndpointAuthMethod: "private_key_jwt", + }, + } + + hydra := &HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: clientForHandler(jsonHandler(t, []domain.HydraClient{ + successClient, + failClient, + skipClient, + disabledClient, + })), + } + + redisRepo := &headlessJWKSCacheTestRedis{} + cacheService := NewHeadlessJWKSCacheService(redisRepo, nil) + cacheService.FailureThreshold = 3 + cacheService.FailureBackoff = 20 * time.Minute + + require.NoError(t, cacheService.SaveState("client-fail", domain.HeadlessJWKSCacheState{ + ClientID: "client-fail", + JWKSURI: failClient.HeadlessJWKSURI(), + LastRefreshStatus: "failure", + ConsecutiveFailures: 2, + })) + require.NoError(t, cacheService.SaveState("client-skip", domain.HeadlessJWKSCacheState{ + ClientID: "client-skip", + JWKSURI: skipClient.HeadlessJWKSURI(), + LastRefreshStatus: "failure", + ConsecutiveFailures: 3, + NextRetryAt: ptrTestTime(time.Now().Add(10 * time.Minute)), + })) + + fetchCounts := map[string]int{} + cacheService.HTTPClient = &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + fetchCounts[req.URL.Host]++ + switch req.URL.Host { + case "success.example.com": + return jsonHTTPResponse(http.StatusOK, string(successRaw)), nil + case "fail.example.com": + return jsonHTTPResponse(http.StatusInternalServerError, `{"error":"boom"}`), nil + default: + t.Fatalf("unexpected fetch for host %s", req.URL.Host) + return nil, nil + } + }), + } + + worker := &HeadlessJWKSCacheWorker{ + Hydra: hydra, + Cache: cacheService, + PageSize: 100, + } + + worker.runOnce(context.Background()) + + assert.Equal(t, 1, fetchCounts["success.example.com"]) + assert.Equal(t, 1, fetchCounts["fail.example.com"]) + assert.Equal(t, 0, fetchCounts["skip.example.com"]) + assert.Equal(t, 0, fetchCounts["disabled.example.com"]) + + successState, err := cacheService.GetState("client-success") + require.NoError(t, err) + require.NotNil(t, successState) + assert.Equal(t, "success", successState.LastRefreshStatus) + assert.Equal(t, 0, successState.ConsecutiveFailures) + assert.Nil(t, successState.NextRetryAt) + + failState, err := cacheService.GetState("client-fail") + require.NoError(t, err) + require.NotNil(t, failState) + assert.Equal(t, "failure", failState.LastRefreshStatus) + assert.Equal(t, 3, failState.ConsecutiveFailures) + require.NotNil(t, failState.NextRetryAt) +} + func mustServiceHeadlessRSAJWK(t *testing.T, kid string) (*rsa.PrivateKey, jose.JSONWebKeySet) { t.Helper() @@ -205,3 +419,31 @@ func mustServiceHeadlessRSAJWK(t *testing.T, kid string) (*rsa.PrivateKey, jose. func ptrTestTime(value time.Time) *time.Time { return &value } + +func newTestHeadlessClient(clientID, jwksURI string) domain.HydraClient { + return domain.HydraClient{ + ClientID: clientID, + Metadata: map[string]any{ + domain.MetadataHeadlessLoginEnabled: true, + domain.MetadataHeadlessJWKSURI: jwksURI, + domain.MetadataHeadlessTokenEndpointAuthMethod: "private_key_jwt", + }, + } +} + +func jsonHandler(t *testing.T, payload any) http.HandlerFunc { + t.Helper() + return func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/clients", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(payload)) + } +} + +func jsonHTTPResponse(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + } +}