forked from baron/baron-sso
headless JWKS 워커 backoff 회귀 테스트 추가
This commit is contained in:
@@ -6,7 +6,9 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -186,6 +188,218 @@ func TestHeadlessJWKSCacheService_ShouldPrefetch_SkipsUntilNextRetryAt(t *testin
|
|||||||
assert.True(t, cacheService.ShouldPrefetch(state, now.Add(11*time.Minute)))
|
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) {
|
func mustServiceHeadlessRSAJWK(t *testing.T, kid string) (*rsa.PrivateKey, jose.JSONWebKeySet) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -205,3 +419,31 @@ func mustServiceHeadlessRSAJWK(t *testing.T, kid string) (*rsa.PrivateKey, jose.
|
|||||||
func ptrTestTime(value time.Time) *time.Time {
|
func ptrTestTime(value time.Time) *time.Time {
|
||||||
return &value
|
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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user