1
0
forked from baron/baron-sso

test(dev): harden client secret regression coverage

- cover get fallback paths for hydra metadata redis and postgres
- cover create rotate and trusted RP update secret persistence
- keep regression coverage isolated from broken handler package tests
This commit is contained in:
Lectom C Han
2026-03-30 21:38:04 +09:00
parent 45dfaf5905
commit 26890dfabb

View File

@@ -83,6 +83,348 @@ func httpJSONAny(r *http.Request, code int, payload any) *http.Response {
}
}
func newDevHandlerApp(h *handler.DevHandler) *fiber.App {
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Post("/api/v1/dev/clients", h.CreateClient)
app.Get("/api/v1/dev/clients/:id", h.GetClient)
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
app.Post("/api/v1/dev/clients/:id/secret/rotate", h.RotateClientSecret)
return app
}
func decodeClientSecret(t *testing.T, resp *http.Response) string {
t.Helper()
var payload struct {
Client struct {
ClientSecret string `json:"clientSecret"`
} `json:"client"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
return payload.Client.ClientSecret
}
func TestGetClient_ClientSecretFallbackPaths(t *testing.T) {
tests := []struct {
name string
hydraClient map[string]any
initialRedis map[string]string
initialSecrets map[string]string
expectedSecret string
expectedRedisAfter string
expectRedisAfterSet bool
}{
{
name: "uses hydra client_secret directly",
hydraClient: map[string]any{
"client_id": "client-direct",
"client_name": "Direct App",
"client_secret": "hydra-secret",
"redirect_uris": []string{"https://direct.example.com/callback"},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"scope": "openid profile",
"token_endpoint_auth_method": "client_secret_basic",
"metadata": map[string]any{"status": "active"},
},
expectedSecret: "hydra-secret",
},
{
name: "falls back to metadata client_secret",
hydraClient: map[string]any{
"client_id": "client-metadata",
"client_name": "Metadata App",
"redirect_uris": []string{"https://metadata.example.com/callback"},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"scope": "openid profile",
"token_endpoint_auth_method": "client_secret_basic",
"metadata": map[string]any{
"status": "active",
"client_secret": "metadata-secret",
},
},
expectedSecret: "metadata-secret",
},
{
name: "falls back to redis cache",
hydraClient: map[string]any{
"client_id": "client-redis",
"client_name": "Redis App",
"redirect_uris": []string{"https://redis.example.com/callback"},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"scope": "openid profile",
"token_endpoint_auth_method": "client_secret_basic",
"metadata": map[string]any{"status": "active"},
},
initialRedis: map[string]string{"client_secret:client-redis": "redis-secret"},
expectedSecret: "redis-secret",
},
{
name: "falls back to postgres and warms redis",
hydraClient: map[string]any{
"client_id": "client-postgres",
"client_name": "Postgres App",
"redirect_uris": []string{"https://postgres.example.com/callback"},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"scope": "openid profile",
"token_endpoint_auth_method": "client_secret_basic",
"metadata": map[string]any{"status": "active"},
},
initialSecrets: map[string]string{"client-postgres": "postgres-secret"},
expectedSecret: "postgres-secret",
expectedRedisAfter: "postgres-secret",
expectRedisAfterSet: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/"+tt.hydraClient["client_id"].(string) {
return httpJSONAny(r, http.StatusOK, tt.hydraClient), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
secretRepo := &mockSecretRepo{secrets: map[string]string{}}
for k, v := range tt.initialSecrets {
secretRepo.secrets[k] = v
}
redisRepo := &mockRedisRepo{data: map[string]string{}}
for k, v := range tt.initialRedis {
redisRepo.data[k] = v
}
h := &handler.DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
PublicURL: "http://hydra.public",
HTTPClient: &http.Client{Transport: transport},
},
SecretRepo: secretRepo,
Redis: redisRepo,
}
app := newDevHandlerApp(h)
clientID := tt.hydraClient["client_id"].(string)
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/"+clientID, nil)
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("get request failed: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected get 200, got %d", resp.StatusCode)
}
if secret := decodeClientSecret(t, resp); secret != tt.expectedSecret {
t.Fatalf("expected secret %q, got %q", tt.expectedSecret, secret)
}
if tt.expectRedisAfterSet {
redisSecret, err := redisRepo.Get("client_secret:" + clientID)
if err != nil {
t.Fatalf("expected warmed redis secret, got error: %v", err)
}
if redisSecret != tt.expectedRedisAfter {
t.Fatalf("expected warmed redis secret %q, got %q", tt.expectedRedisAfter, redisSecret)
}
}
})
}
}
func TestCreateClient_PersistsSecretForLaterDetailFetch(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
return httpJSONAny(r, http.StatusCreated, map[string]any{
"client_id": "client-created",
"client_name": "Created App",
"client_secret": "created-secret",
"redirect_uris": []string{"https://created.example.com/callback"},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"scope": "openid profile",
"token_endpoint_auth_method": "client_secret_basic",
"metadata": map[string]any{"status": "active"},
}), nil
}
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-created" {
return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-created",
"client_name": "Created App",
"redirect_uris": []string{"https://created.example.com/callback"},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"scope": "openid profile",
"token_endpoint_auth_method": "client_secret_basic",
"metadata": map[string]any{"status": "active"},
}), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
secretRepo := &mockSecretRepo{secrets: make(map[string]string)}
redisRepo := &mockRedisRepo{data: make(map[string]string)}
h := &handler.DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
PublicURL: "http://hydra.public",
HTTPClient: &http.Client{Transport: transport},
},
SecretRepo: secretRepo,
Redis: redisRepo,
}
app := newDevHandlerApp(h)
createBody, _ := json.Marshal(map[string]any{
"name": "Created App",
"type": "private",
"redirectUris": []string{"https://created.example.com/callback"},
"scopes": []string{"openid", "profile"},
})
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(createBody))
createReq.Header.Set("Content-Type", "application/json")
createResp, err := app.Test(createReq, -1)
if err != nil {
t.Fatalf("create request failed: %v", err)
}
if createResp.StatusCode != http.StatusCreated {
t.Fatalf("expected create 201, got %d", createResp.StatusCode)
}
if secret := decodeClientSecret(t, createResp); secret != "created-secret" {
t.Fatalf("expected create secret created-secret, got %q", secret)
}
storedSecret, _ := secretRepo.GetByID(context.Background(), "client-created")
if storedSecret != "created-secret" {
t.Fatalf("expected postgres secret created-secret, got %q", storedSecret)
}
redisSecret, err := redisRepo.Get("client_secret:client-created")
if err != nil {
t.Fatalf("expected redis secret after create, got error: %v", err)
}
if redisSecret != "created-secret" {
t.Fatalf("expected redis secret created-secret, got %q", redisSecret)
}
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-created", nil)
getResp, err := app.Test(getReq, -1)
if err != nil {
t.Fatalf("get request failed: %v", err)
}
if getResp.StatusCode != http.StatusOK {
t.Fatalf("expected get 200, got %d", getResp.StatusCode)
}
if secret := decodeClientSecret(t, getResp); secret != "created-secret" {
t.Fatalf("expected detail secret created-secret, got %q", secret)
}
}
func TestRotateClientSecret_PersistsForLaterDetailFetch(t *testing.T) {
getCount := 0
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-rotate" {
getCount++
return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-rotate",
"client_name": "Rotate App",
"redirect_uris": []string{"https://rotate.example.com/callback"},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"scope": "openid profile",
"token_endpoint_auth_method": "client_secret_basic",
"metadata": map[string]any{"status": "active"},
}), nil
}
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-rotate" {
return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-rotate",
"client_name": "Rotate App",
"redirect_uris": []string{"https://rotate.example.com/callback"},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"scope": "openid profile",
"token_endpoint_auth_method": "client_secret_basic",
"metadata": map[string]any{"status": "active"},
}), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
secretRepo := &mockSecretRepo{secrets: make(map[string]string)}
redisRepo := &mockRedisRepo{data: make(map[string]string)}
h := &handler.DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
PublicURL: "http://hydra.public",
HTTPClient: &http.Client{Transport: transport},
},
SecretRepo: secretRepo,
Redis: redisRepo,
}
app := newDevHandlerApp(h)
rotateReq := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients/client-rotate/secret/rotate", nil)
rotateResp, err := app.Test(rotateReq, -1)
if err != nil {
t.Fatalf("rotate request failed: %v", err)
}
if rotateResp.StatusCode != http.StatusOK {
t.Fatalf("expected rotate 200, got %d", rotateResp.StatusCode)
}
rotatedSecret := decodeClientSecret(t, rotateResp)
if rotatedSecret == "" {
t.Fatalf("expected rotated secret to be present")
}
storedSecret, _ := secretRepo.GetByID(context.Background(), "client-rotate")
if storedSecret != rotatedSecret {
t.Fatalf("expected postgres secret %q, got %q", rotatedSecret, storedSecret)
}
redisSecret, err := redisRepo.Get("client_secret:client-rotate")
if err != nil {
t.Fatalf("expected redis secret after rotate, got error: %v", err)
}
if redisSecret != rotatedSecret {
t.Fatalf("expected redis secret %q, got %q", rotatedSecret, redisSecret)
}
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-rotate", nil)
getResp, err := app.Test(getReq, -1)
if err != nil {
t.Fatalf("get request failed: %v", err)
}
if getResp.StatusCode != http.StatusOK {
t.Fatalf("expected get 200, got %d", getResp.StatusCode)
}
if secret := decodeClientSecret(t, getResp); secret != rotatedSecret {
t.Fatalf("expected detail secret %q, got %q", rotatedSecret, secret)
}
}
func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) {
getCount := 0