diff --git a/adminfront/tests/users.spec.ts b/adminfront/tests/users.spec.ts index 3c5ac793..44c37824 100644 --- a/adminfront/tests/users.spec.ts +++ b/adminfront/tests/users.spec.ts @@ -320,6 +320,27 @@ test.describe("User Management", () => { }) => { let updatePayload: Record | undefined; + await page.route(/\/admin\/global-custom-claims$/, async (route) => { + if (route.request().method() !== "GET") { + return route.fallback(); + } + + return route.fulfill({ + json: { + items: [ + { + key: "contract_date", + label: "계약일", + valueType: "date", + readPermission: "admin_only", + writePermission: "admin_only", + description: "", + }, + ], + }, + }); + }); + await page.route(/\/admin\/users\/u-1$/, async (route) => { const method = route.request().method(); diff --git a/backend/internal/handler/auth_handler_link_test.go b/backend/internal/handler/auth_handler_link_test.go index 2518c5a0..18e8410c 100644 --- a/backend/internal/handler/auth_handler_link_test.go +++ b/backend/internal/handler/auth_handler_link_test.go @@ -3,7 +3,6 @@ package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" - "baron-sso-backend/internal/testsupport" "bytes" "encoding/json" "io" @@ -50,35 +49,37 @@ func newHeadlessLinkTestApp(h *AuthHandler) *fiber.App { return app } -func newKratosWhoamiTestServer(t *testing.T, identityID string) *httptest.Server { +func newKratosWhoamiTestServer(t *testing.T, identityID string) string { t.Helper() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/sessions/whoami" { - http.NotFound(w, r) - return - } - if r.Header.Get("Cookie") == "" && r.Header.Get("X-Session-Token") == "" { - http.Error(w, "missing session", http.StatusUnauthorized) - return - } - _ = json.NewEncoder(w).Encode(map[string]any{ - "id": "session-123", - "authenticated_at": "2026-05-21T00:00:00Z", - "identity": map[string]any{ - "id": identityID, - "traits": map[string]any{ - "email": "user@example.com", - }, - }, - }) - })) origDefaultClient := http.DefaultClient - http.DefaultClient = server.Client() + http.DefaultClient = &http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path != "/sessions/whoami" { + return httpResponse(r, http.StatusNotFound, "not found"), nil + } + if r.Header.Get("Cookie") == "" && r.Header.Get("X-Session-Token") == "" { + return httpResponse(r, http.StatusUnauthorized, "missing session"), nil + } + body, err := json.Marshal(map[string]any{ + "id": "session-123", + "authenticated_at": "2026-05-21T00:00:00Z", + "identity": map[string]any{ + "id": identityID, + "traits": map[string]any{ + "email": "user@example.com", + }, + }, + }) + if err != nil { + return nil, err + } + return httpResponse(r, http.StatusOK, string(body)), nil + }), + } t.Cleanup(func() { http.DefaultClient = origDefaultClient }) - t.Cleanup(server.Close) - return server + return "http://kratos.test" } func TestEnchantedLinkFlow_Email_Success(t *testing.T) { @@ -215,8 +216,7 @@ func TestVerifyMagicLink_VerifyOnlySharedBrowserSameSubjectApprovesOnly(t *testi redis := &mockRedisRepo{data: map[string]string{ prefixToken + "token-123": `{"pendingRef":"pending-123","loginId":"user@example.com"}`, }} - kratosPublic := newKratosWhoamiTestServer(t, "kratos-user-1") - t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) + t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-user-1")) h := &AuthHandler{ RedisService: redis, @@ -248,8 +248,7 @@ func TestVerifyMagicLink_VerifyOnlySharedBrowserDifferentSubjectApprovesOnly(t * redis := &mockRedisRepo{data: map[string]string{ prefixToken + "token-123": `{"pendingRef":"pending-123","loginId":"user@example.com"}`, }} - kratosPublic := newKratosWhoamiTestServer(t, "kratos-other-user") - t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) + t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-other-user")) h := &AuthHandler{ RedisService: redis, @@ -302,8 +301,7 @@ func TestVerifyLoginCode_VerifyOnlySharedBrowserDifferentSubjectApprovesOnly(t * prefixLoginCodePending + "user@example.com": "pending-123", prefixLoginCodeValue + "pending-123": "569765", }} - kratosPublic := newKratosWhoamiTestServer(t, "kratos-other-user") - t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) + t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-other-user")) h := &AuthHandler{ RedisService: redis, @@ -393,8 +391,7 @@ func TestPollEnchantedLink_SharedBrowserSameSubjectIssuesSession(t *testing.T) { redis := &mockRedisRepo{data: map[string]string{ prefixSession + "pending-123": `{"status":"approved","loginId":"user@example.com"}`, }} - kratosPublic := newKratosWhoamiTestServer(t, "kratos-user-1") - t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) + t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-user-1")) h := &AuthHandler{ RedisService: redis, @@ -425,8 +422,7 @@ func TestPollEnchantedLink_SharedBrowserDifferentSubjectConflicts(t *testing.T) redis := &mockRedisRepo{data: map[string]string{ prefixSession + "pending-123": `{"status":"approved","loginId":"user@example.com"}`, }} - kratosPublic := newKratosWhoamiTestServer(t, "kratos-other-user") - t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) + t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-other-user")) h := &AuthHandler{ RedisService: redis, @@ -456,18 +452,11 @@ func TestPollEnchantedLink_SharedBrowserDifferentSubjectConflicts(t *testing.T) func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) { t.Setenv("BACKEND_PUBLIC_URL", "") - if !testsupport.PortBindingAvailable() { - t.Skip("skipping headless link tests because this environment cannot bind local TCP listeners") - } - redis := &mockRedisRepo{data: make(map[string]string)} privateKey, jwks := mustHeadlessRSAJWK(t) jwksBody, _ := json.Marshal(jwks) - jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(jwksBody) - })) - defer jwksServer.Close() + jwksClient := newJWKSHTTPClient(t, jwksBody) + jwksURI := jwksURL() idp := &mockIdpProvider{ userExists: true, @@ -485,7 +474,7 @@ func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) { "status": "active", "headless_login_enabled": true, "headless_token_endpoint_auth_method": "private_key_jwt", - "headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json", + "headless_jwks_uri": jwksURI, }, }, }) @@ -497,6 +486,7 @@ func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) { h := &AuthHandler{ RedisService: redis, IdpProvider: idp, + HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient), SmsService: &mockSmsService{}, Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", @@ -529,10 +519,6 @@ func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) { func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) { t.Setenv("BACKEND_PUBLIC_URL", "") - if !testsupport.PortBindingAvailable() { - t.Skip("skipping headless link tests because this environment cannot bind local TCP listeners") - } - redis := &mockRedisRepo{data: make(map[string]string)} privateKey, jwks := mustHeadlessRSAJWK(t) jwksBody, _ := json.Marshal(jwks) @@ -659,10 +645,6 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) { func TestHeadlessLinkPoll_ApproverSubjectConflictBlocksMixedRP(t *testing.T) { t.Setenv("BACKEND_PUBLIC_URL", "") - if !testsupport.PortBindingAvailable() { - t.Skip("skipping headless link tests because this environment cannot bind local TCP listeners") - } - redis := &mockRedisRepo{data: make(map[string]string)} privateKey, jwks := mustHeadlessRSAJWK(t) jwksBody, _ := json.Marshal(jwks) @@ -748,8 +730,7 @@ func TestHeadlessLinkPoll_ApproverSubjectConflictBlocksMixedRP(t *testing.T) { } assert.NotEmpty(t, token) - kratosPublic := newKratosWhoamiTestServer(t, "kratos-userfront-a") - t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) + t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-userfront-a")) verifyBody, _ := json.Marshal(map[string]any{ "token": token, @@ -785,10 +766,6 @@ func TestHeadlessLinkPoll_ApproverSubjectConflictBlocksMixedRP(t *testing.T) { func TestHeadlessLinkPoll_RequestCookieSubjectConflictBlocksMixedRP(t *testing.T) { t.Setenv("BACKEND_PUBLIC_URL", "") - if !testsupport.PortBindingAvailable() { - t.Skip("skipping headless link tests because this environment cannot bind local TCP listeners") - } - redis := &mockRedisRepo{data: make(map[string]string)} privateKey, jwks := mustHeadlessRSAJWK(t) jwksBody, _ := json.Marshal(jwks) @@ -880,8 +857,7 @@ func TestHeadlessLinkPoll_RequestCookieSubjectConflictBlocksMixedRP(t *testing.T resp, _ = app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) - kratosPublic := newKratosWhoamiTestServer(t, "kratos-userfront-a") - t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) + t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-userfront-a")) pollBody, _ := json.Marshal(map[string]string{ "client_id": "headless-login-client", diff --git a/backend/internal/handler/auth_handler_login_test.go b/backend/internal/handler/auth_handler_login_test.go index 19c9fc6c..c4565403 100644 --- a/backend/internal/handler/auth_handler_login_test.go +++ b/backend/internal/handler/auth_handler_login_test.go @@ -9,7 +9,6 @@ import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/middleware" "baron-sso-backend/internal/service" - "baron-sso-backend/internal/testsupport" "bytes" "context" "crypto/ecdsa" @@ -446,10 +445,6 @@ func runHeadlessPasswordLoginWithAssertionRequest( headers map[string]string, ) *http.Response { t.Helper() - if !testsupport.PortBindingAvailable() { - t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners") - } - mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ SessionToken: &domain.Token{JWT: "valid-jwt"}, @@ -463,11 +458,8 @@ func runHeadlessPasswordLoginWithAssertionRequest( if err != nil { t.Fatalf("failed to marshal jwks body: %v", err) } - jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(jwksBody) - })) - t.Cleanup(jwksServer.Close) + jwksClient := newJWKSHTTPClient(t, jwksBody) + jwksURI := jwksURL() hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { @@ -481,7 +473,7 @@ func runHeadlessPasswordLoginWithAssertionRequest( "status": "active", "headless_login_enabled": true, "headless_token_endpoint_auth_method": "private_key_jwt", - "headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json", + "headless_jwks_uri": jwksURI, }, }, }) @@ -496,6 +488,7 @@ func runHeadlessPasswordLoginWithAssertionRequest( h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, + HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient), Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, @@ -551,10 +544,6 @@ func runHeadlessPasswordLoginWithAssertionAndLoggerRequest( logger *slog.Logger, ) *http.Response { t.Helper() - if !testsupport.PortBindingAvailable() { - t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners") - } - mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ SessionToken: &domain.Token{JWT: "valid-jwt"}, @@ -568,11 +557,8 @@ func runHeadlessPasswordLoginWithAssertionAndLoggerRequest( if err != nil { t.Fatalf("failed to marshal jwks body: %v", err) } - jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(jwksBody) - })) - t.Cleanup(jwksServer.Close) + jwksClient := newJWKSHTTPClient(t, jwksBody) + jwksURI := jwksURL() hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { @@ -586,7 +572,7 @@ func runHeadlessPasswordLoginWithAssertionAndLoggerRequest( "status": "active", "headless_login_enabled": true, "headless_token_endpoint_auth_method": "private_key_jwt", - "headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json", + "headless_jwks_uri": jwksURI, }, }, }) @@ -601,6 +587,7 @@ func runHeadlessPasswordLoginWithAssertionAndLoggerRequest( h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, + HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient), Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, @@ -879,10 +866,6 @@ func TestPasswordLogin_UserFront_AuditIncludesDefaultClientMetadata(t *testing.T func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) { t.Setenv("BACKEND_PUBLIC_URL", "") - if !testsupport.PortBindingAvailable() { - t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners") - } - mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ SessionToken: &domain.Token{JWT: "valid-jwt"}, @@ -891,11 +874,8 @@ func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) { privateKey, jwks := mustHeadlessRSAJWK(t) jwksBody, _ := json.Marshal(jwks) - jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(jwksBody) - })) - defer jwksServer.Close() + jwksClient := newJWKSHTTPClient(t, jwksBody) + jwksURI := jwksURL() hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { @@ -909,7 +889,7 @@ func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) { "status": "active", "headless_login_enabled": true, "headless_token_endpoint_auth_method": "private_key_jwt", - "headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json", + "headless_jwks_uri": jwksURI, }, }, }) @@ -926,6 +906,7 @@ func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) { h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, + HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient), Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, @@ -979,10 +960,6 @@ func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) { func TestHeadlessPasswordLogin_OIDCSubjectConflictBlocksMixedRP(t *testing.T) { t.Setenv("BACKEND_PUBLIC_URL", "") - if !testsupport.PortBindingAvailable() { - t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners") - } - mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "employee002", "password").Return(&domain.AuthInfo{ SessionToken: &domain.Token{JWT: "valid-jwt"}, @@ -991,11 +968,8 @@ func TestHeadlessPasswordLogin_OIDCSubjectConflictBlocksMixedRP(t *testing.T) { privateKey, jwks := mustHeadlessRSAJWK(t) jwksBody, _ := json.Marshal(jwks) - jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(jwksBody) - })) - defer jwksServer.Close() + jwksClient := newJWKSHTTPClient(t, jwksBody) + jwksURI := jwksURL() acceptCalled := false hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -1012,7 +986,7 @@ func TestHeadlessPasswordLogin_OIDCSubjectConflictBlocksMixedRP(t *testing.T) { "status": "active", "headless_login_enabled": true, "headless_token_endpoint_auth_method": "private_key_jwt", - "headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json", + "headless_jwks_uri": jwksURI, }, }, }) @@ -1030,6 +1004,7 @@ func TestHeadlessPasswordLogin_OIDCSubjectConflictBlocksMixedRP(t *testing.T) { h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, + HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient), Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, @@ -1065,10 +1040,6 @@ func TestHeadlessPasswordLogin_OIDCSubjectConflictBlocksMixedRP(t *testing.T) { func TestHeadlessPasswordLogin_OIDCSubjectSameAllowsMixedRP(t *testing.T) { t.Setenv("BACKEND_PUBLIC_URL", "") - if !testsupport.PortBindingAvailable() { - t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners") - } - mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ SessionToken: &domain.Token{JWT: "valid-jwt"}, @@ -1077,11 +1048,8 @@ func TestHeadlessPasswordLogin_OIDCSubjectSameAllowsMixedRP(t *testing.T) { privateKey, jwks := mustHeadlessRSAJWK(t) jwksBody, _ := json.Marshal(jwks) - jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(jwksBody) - })) - defer jwksServer.Close() + jwksClient := newJWKSHTTPClient(t, jwksBody) + jwksURI := jwksURL() hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { @@ -1097,7 +1065,7 @@ func TestHeadlessPasswordLogin_OIDCSubjectSameAllowsMixedRP(t *testing.T) { "status": "active", "headless_login_enabled": true, "headless_token_endpoint_auth_method": "private_key_jwt", - "headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json", + "headless_jwks_uri": jwksURI, }, }, }) @@ -1114,6 +1082,7 @@ func TestHeadlessPasswordLogin_OIDCSubjectSameAllowsMixedRP(t *testing.T) { h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, + HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient), Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, @@ -1271,10 +1240,6 @@ func TestHeadlessPasswordLogin_AuditIncludesClientMetadata(t *testing.T) { func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured(t *testing.T) { t.Setenv("BACKEND_PUBLIC_URL", "") - if !testsupport.PortBindingAvailable() { - t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners") - } - mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ SessionToken: &domain.Token{JWT: "valid-jwt"}, @@ -1283,11 +1248,8 @@ func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured( privateKey, jwks := mustHeadlessRSAJWK(t) jwksBody, _ := json.Marshal(jwks) - jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(jwksBody) - })) - defer jwksServer.Close() + jwksClient := newJWKSHTTPClient(t, jwksBody) + jwksURI := jwksURL() hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { @@ -1301,7 +1263,7 @@ func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured( "status": "active", "headless_login_enabled": true, "headless_token_endpoint_auth_method": "private_key_jwt", - "headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json", + "headless_jwks_uri": jwksURI, "headless_jwks": map[string]any{ "keys": []map[string]any{}, }, @@ -1321,6 +1283,7 @@ func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured( h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, + HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient), Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, @@ -1360,10 +1323,6 @@ func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured( func TestHeadlessPasswordLogin_RefreshesJWKSWhenSignatureFailsForCachedKid(t *testing.T) { t.Setenv("BACKEND_PUBLIC_URL", "") - if !testsupport.PortBindingAvailable() { - t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners") - } - mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ SessionToken: &domain.Token{JWT: "valid-jwt"}, @@ -1383,12 +1342,11 @@ func TestHeadlessPasswordLogin_RefreshesJWKSWhenSignatureFailsForCachedKid(t *te } fetchCount := 0 - jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + jwksClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { fetchCount++ - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(freshRaw) - })) - defer jwksServer.Close() + return httpResponse(r, http.StatusOK, string(freshRaw)), nil + })} + jwksURI := jwksURL() hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { @@ -1402,7 +1360,7 @@ func TestHeadlessPasswordLogin_RefreshesJWKSWhenSignatureFailsForCachedKid(t *te "status": "active", "headless_login_enabled": true, "headless_token_endpoint_auth_method": "private_key_jwt", - "headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json", + "headless_jwks_uri": jwksURI, }, }, }) @@ -1417,12 +1375,12 @@ func TestHeadlessPasswordLogin_RefreshesJWKSWhenSignatureFailsForCachedKid(t *te mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee001").Return("kratos-identity-id", nil) redisRepo := &testRedisRepo{values: map[string]string{}} - cacheService := service.NewHeadlessJWKSCacheService(redisRepo, jwksServer.Client()) + cacheService := service.NewHeadlessJWKSCacheService(redisRepo, jwksClient) now := time.Now() expiresAt := now.Add(30 * time.Minute) if err := cacheService.SaveState("headless-login-client", domain.HeadlessJWKSCacheState{ ClientID: "headless-login-client", - JWKSURI: jwksServer.URL + "/.well-known/jwks.json", + JWKSURI: jwksURI, RawJWKS: string(staleRaw), CachedKids: []string{"test-kid"}, CachedAt: &now, @@ -1546,10 +1504,6 @@ func TestHeadlessPasswordLogin_MissingClientAssertionRejected(t *testing.T) { } func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) { - if !testsupport.PortBindingAvailable() { - t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners") - } - mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ SessionToken: &domain.Token{JWT: "valid-jwt"}, @@ -1562,11 +1516,8 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) { invalidKey, _ := mustHeadlessRSAJWK(t) _ = validKey jwksBody, _ := json.Marshal(jwks) - jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(jwksBody) - })) - defer jwksServer.Close() + jwksClient := newJWKSHTTPClient(t, jwksBody) + jwksURI := jwksURL() hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { @@ -1580,7 +1531,7 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) { "status": "active", "headless_login_enabled": true, "headless_token_endpoint_auth_method": "private_key_jwt", - "headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json", + "headless_jwks_uri": jwksURI, }, }, }) @@ -1595,6 +1546,7 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) { h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, + HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient), Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, @@ -2198,8 +2150,7 @@ func TestPasswordLogin_SharedBrowserSameSubjectAllowed(t *testing.T) { Subject: "kratos-user-1", }, nil) - kratosPublic := newKratosWhoamiTestServer(t, "kratos-user-1") - t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) + t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-user-1")) mockKratos := new(MockKratosAdminService) mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-user-1", nil) @@ -2237,8 +2188,7 @@ func TestPasswordLogin_SharedBrowserDifferentSubjectConflicts(t *testing.T) { Subject: "kratos-user-1", }, nil) - kratosPublic := newKratosWhoamiTestServer(t, "kratos-other-user") - t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) + t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-other-user")) mockKratos := new(MockKratosAdminService) mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-user-1", nil) diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index 1aede3bd..c299a01c 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -1590,10 +1590,8 @@ func TestCreateClient_ApprovedDeveloperRequestAllowsCreateWhenTenantGrantNotVisi mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "System", "global", "manage_all").Return(false, nil).Maybe() developerSvc := new(devMockDeveloperService) - developerSvc.On("GetRequestStatus", mock.Anything, "user-1", "tenant-a").Return(&domain.DeveloperRequest{ - UserID: "user-1", - TenantID: "tenant-a", - Status: domain.DeveloperRequestStatusApproved, + developerSvc.On("GetRequestStatus", mock.Anything, "user-1", "tenant-a").Return(&domain.DeveloperAccessStatus{ + Status: domain.DeveloperRequestStatusApproved, }, nil).Maybe() h := &DevHandler{ diff --git a/backend/internal/handler/test_server_helper_test.go b/backend/internal/handler/test_server_helper_test.go new file mode 100644 index 00000000..c7f08798 --- /dev/null +++ b/backend/internal/handler/test_server_helper_test.go @@ -0,0 +1,91 @@ +package handler + +import ( + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func newIPv4TestServer(t *testing.T, handler http.Handler) *httptest.Server { + t.Helper() + + ln, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to bind test server listener: %v", err) + } + + server := httptest.NewUnstartedServer(handler) + server.Listener = ln + server.Start() + t.Cleanup(server.Close) + + return server +} + +func newJWKSHTTPClient(t *testing.T, jwksBody []byte) *http.Client { + t.Helper() + + return &http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/.well-known/jwks.json" { + return httpResponse(r, http.StatusOK, string(jwksBody)), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + }), + } +} + +func installKratosWhoamiClient(t *testing.T, identityID string) string { + t.Helper() + + origDefaultClient := http.DefaultClient + http.DefaultClient = &http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path != "/sessions/whoami" { + return httpResponse(r, http.StatusNotFound, "not found"), nil + } + if r.Header.Get("Cookie") == "" && r.Header.Get("X-Session-Token") == "" { + return httpResponse(r, http.StatusUnauthorized, "missing session"), nil + } + body, err := json.Marshal(map[string]any{ + "id": "session-123", + "authenticated_at": "2026-05-21T00:00:00Z", + "identity": map[string]any{ + "id": identityID, + "traits": map[string]any{ + "email": "user@example.com", + }, + }, + }) + if err != nil { + return nil, err + } + resp := httpResponse(r, http.StatusOK, string(body)) + resp.Header.Set("Content-Type", "application/json") + return resp, nil + }), + } + t.Cleanup(func() { + http.DefaultClient = origDefaultClient + }) + + return "http://kratos.test" +} + +func jwksURL() string { + u := &url.URL{Scheme: "http", Host: "jwks.test", Path: "/.well-known/jwks.json"} + return u.String() +} + +func mustJSONBody(t *testing.T, value any) []byte { + t.Helper() + + body, err := json.Marshal(value) + if err != nil { + t.Fatalf("failed to marshal test body: %v", err) + } + return body +}