package handler import ( "baron-sso-backend/internal/domain" auditmw "baron-sso-backend/internal/middleware" "baron-sso-backend/internal/service" "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "testing" "time" "github.com/gofiber/fiber/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "gorm.io/gorm" ) // --- Mocks with Unique Names to Avoid Collisions --- type devMockKetoService struct { mock.Mock } func (m *devMockKetoService) CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error) { args := m.Called(ctx, subject, namespace, object, relation) return args.Bool(0), args.Error(1) } func (m *devMockKetoService) CreateRelation(ctx context.Context, ns, obj, rel, sub string) error { return m.Called(ctx, ns, obj, rel, sub).Error(0) } func (m *devMockKetoService) DeleteRelation(ctx context.Context, ns, obj, rel, sub string) error { return m.Called(ctx, ns, obj, rel, sub).Error(0) } func (m *devMockKetoService) ListRelations(ctx context.Context, ns, obj, rel, sub string) ([]service.RelationTuple, error) { if len(m.ExpectedCalls) == 0 { return []service.RelationTuple{}, nil } hasListRelationsExpectation := false for _, call := range m.ExpectedCalls { if call.Method == "ListRelations" { hasListRelationsExpectation = true break } } if !hasListRelationsExpectation { return []service.RelationTuple{}, nil } args := m.Called(ctx, ns, obj, rel, sub) return args.Get(0).([]service.RelationTuple), args.Error(1) } func (m *devMockKetoService) ListObjects(ctx context.Context, ns, rel, sub string) ([]string, error) { args := m.Called(ctx, ns, rel, sub) return args.Get(0).([]string), args.Error(1) } type devMockDeveloperService struct { mock.Mock } func (m *devMockDeveloperService) RequestAccess(ctx context.Context, req domain.DeveloperRequest) error { args := m.Called(ctx, req) return args.Error(0) } func (m *devMockDeveloperService) GetRequestStatus(ctx context.Context, userID, tenantID string) (*domain.DeveloperAccessStatus, error) { args := m.Called(ctx, userID, tenantID) if status, ok := args.Get(0).(*domain.DeveloperAccessStatus); ok { return status, args.Error(1) } return nil, args.Error(1) } func (m *devMockDeveloperService) GetRequestByID(ctx context.Context, id uint) (*domain.DeveloperRequest, error) { args := m.Called(ctx, id) if req, ok := args.Get(0).(*domain.DeveloperRequest); ok { return req, args.Error(1) } return nil, args.Error(1) } func (m *devMockDeveloperService) ListRequests(ctx context.Context, userID, status, tenantID string) ([]domain.DeveloperRequest, error) { args := m.Called(ctx, userID, status, tenantID) if requests, ok := args.Get(0).([]domain.DeveloperRequest); ok { return requests, args.Error(1) } return nil, args.Error(1) } func (m *devMockDeveloperService) CreateGrant(ctx context.Context, req domain.DeveloperRequest) error { args := m.Called(ctx, req) return args.Error(0) } func (m *devMockDeveloperService) ApproveRequest(ctx context.Context, id uint, adminNotes string) error { args := m.Called(ctx, id, adminNotes) return args.Error(0) } func (m *devMockDeveloperService) RejectRequest(ctx context.Context, id uint, adminNotes string) error { args := m.Called(ctx, id, adminNotes) return args.Error(0) } func (m *devMockDeveloperService) CancelApprovedRequest(ctx context.Context, id uint, adminNotes string) error { args := m.Called(ctx, id, adminNotes) return args.Error(0) } type devMockRedisRepo struct { data map[string]string } type devMockKratosAdmin struct { mock.Mock } func (m *devMockKratosAdmin) ListIdentities(ctx context.Context) ([]service.KratosIdentity, error) { args := m.Called(ctx) return args.Get(0).([]service.KratosIdentity), args.Error(1) } func (m *devMockKratosAdmin) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) { args := m.Called(ctx, identifier) return args.String(0), args.Error(1) } func (m *devMockKratosAdmin) GetIdentity(ctx context.Context, identityID string) (*service.KratosIdentity, error) { args := m.Called(ctx, identityID) if identity, ok := args.Get(0).(*service.KratosIdentity); ok { return identity, args.Error(1) } return nil, args.Error(1) } func (m *devMockKratosAdmin) UpdateIdentity(ctx context.Context, identityID string, traits map[string]any, state string) (*service.KratosIdentity, error) { args := m.Called(ctx, identityID, traits, state) if identity, ok := args.Get(0).(*service.KratosIdentity); ok { return identity, args.Error(1) } return nil, args.Error(1) } func (m *devMockKratosAdmin) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error { return m.Called(ctx, identityID, newPassword).Error(0) } func (m *devMockKratosAdmin) DeleteIdentity(ctx context.Context, identityID string) error { return m.Called(ctx, identityID).Error(0) } func (m *devMockKratosAdmin) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) { args := m.Called(ctx, user, password) return args.String(0), args.Error(1) } func (m *devMockKratosAdmin) ListIdentitySessions(ctx context.Context, identityID string) ([]service.KratosSession, error) { args := m.Called(ctx, identityID) return args.Get(0).([]service.KratosSession), args.Error(1) } func (m *devMockKratosAdmin) GetSession(ctx context.Context, sessionID string) (*service.KratosSession, error) { args := m.Called(ctx, sessionID) if session, ok := args.Get(0).(*service.KratosSession); ok { return session, args.Error(1) } return nil, args.Error(1) } func (m *devMockKratosAdmin) DeleteSession(ctx context.Context, sessionID string) error { return m.Called(ctx, sessionID).Error(0) } type devMockKetoOutboxRepository struct { mock.Mock } type devMockAuthProvider struct { mock.Mock } func (m *devMockAuthProvider) GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) { args := m.Called(c) if profile, ok := args.Get(0).(*domain.UserProfileResponse); ok { return profile, args.Error(1) } return nil, args.Error(1) } func (m *devMockKetoOutboxRepository) Create(ctx context.Context, entry *domain.KetoOutbox) error { return m.Called(ctx, entry).Error(0) } func (m *devMockKetoOutboxRepository) CreateWithTx(tx *gorm.DB, entry *domain.KetoOutbox) error { return m.Called(tx, entry).Error(0) } func (m *devMockKetoOutboxRepository) FindPending(ctx context.Context, limit int) ([]domain.KetoOutbox, error) { args := m.Called(ctx, limit) return args.Get(0).([]domain.KetoOutbox), args.Error(1) } func (m *devMockKetoOutboxRepository) ListCurrentBySubject(ctx context.Context, namespace, subject string) ([]domain.KetoOutbox, error) { args := m.Called(ctx, namespace, subject) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).([]domain.KetoOutbox), args.Error(1) } func (m *devMockKetoOutboxRepository) UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error { return m.Called(ctx, id, status, retryCount, lastError).Error(0) } func (m *devMockKetoOutboxRepository) MarkProcessed(ctx context.Context, id string) error { return m.Called(ctx, id).Error(0) } func (m *devMockRedisRepo) Set(key, value string, exp time.Duration) error { if m.data == nil { m.data = make(map[string]string) } m.data[key] = value return nil } func (m *devMockRedisRepo) Get(key string) (string, error) { v, ok := m.data[key] if !ok { return "", fmt.Errorf("not found") } return v, nil } func (m *devMockRedisRepo) Delete(key string) error { delete(m.data, key) return nil } func (m *devMockRedisRepo) StoreVerificationCode(p, c string) error { return nil } func (m *devMockRedisRepo) GetVerificationCode(p string) (string, error) { return "", nil } func (m *devMockRedisRepo) DeleteVerificationCode(p string) error { return nil } type devEnhancedMockAuditRepo struct { mockAuditRepo countFailures int64 countSessions int64 } func (m *devEnhancedMockAuditRepo) CountFailuresSince(ctx context.Context, s time.Time, t string) (int64, error) { return m.countFailures, nil } func (m *devEnhancedMockAuditRepo) CountActiveSessionsSince(ctx context.Context, s time.Time, t string) (int64, error) { return m.countSessions, nil } func devTestJWKSFirstKeyString(t *testing.T, jwks map[string]any, field string) string { t.Helper() keys, ok := jwks["keys"].([]any) if !ok || len(keys) == 0 { t.Fatalf("expected jwks keys") } key, ok := keys[0].(map[string]any) if !ok { t.Fatalf("expected jwks key object") } value, ok := key[field].(string) if !ok { t.Fatalf("expected jwks field %s", field) } return value } // --- Tests --- func TestGetCurrentProfile_SetsAuditUserContext(t *testing.T) { mockAuth := new(devMockAuthProvider) handler := &DevHandler{Auth: mockAuth} app := fiber.New() mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{ ID: "0a5b7284-e88a-4fdf-b56f-98d0435b24f5", Role: domain.RoleUser, }, nil) app.Get("/test", func(c *fiber.Ctx) error { profile := handler.getCurrentProfile(c) return c.JSON(fiber.Map{ "profile_id": profile.ID, "user_id": c.Locals("user_id"), }) }) req := httptest.NewRequest(http.MethodGet, "/test", nil) resp, _ := app.Test(req) assert.Equal(t, http.StatusOK, resp.StatusCode) var body map[string]string assert.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) assert.NoError(t, resp.Body.Close()) assert.Equal(t, "0a5b7284-e88a-4fdf-b56f-98d0435b24f5", body["profile_id"]) assert.Equal(t, "0a5b7284-e88a-4fdf-b56f-98d0435b24f5", body["user_id"]) } func TestGetCurrentProfile_PreservesExistingAuditUserContext(t *testing.T) { handler := &DevHandler{} app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ ID: "profile-user", Role: domain.RoleUser, }) c.Locals("user_id", "existing-user") return c.Next() }) app.Get("/test", func(c *fiber.Ctx) error { profile := handler.getCurrentProfile(c) return c.JSON(fiber.Map{ "profile_id": profile.ID, "user_id": c.Locals("user_id"), }) }) req := httptest.NewRequest(http.MethodGet, "/test", nil) resp, _ := app.Test(req) assert.Equal(t, http.StatusOK, resp.StatusCode) var body map[string]string assert.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) assert.NoError(t, resp.Body.Close()) assert.Equal(t, "profile-user", body["profile_id"]) assert.Equal(t, "existing-user", body["user_id"]) } func TestListMyTenants_UserIncludesPrimaryJoinedAndMetadataAppointments(t *testing.T) { primaryTenantID := "tenant-primary" mockAuth := new(devMockAuthProvider) mockTenant := new(MockTenantService) handler := &DevHandler{Auth: mockAuth, TenantSvc: mockTenant} app := fiber.New() app.Get("/api/v1/dev/my-tenants", handler.ListMyTenants) profile := &domain.UserProfileResponse{ ID: "user-1", Role: domain.RoleUser, TenantID: &primaryTenantID, JoinedTenants: []domain.Tenant{ {ID: "tenant-joined", Slug: "joined", Name: "Joined Tenant", Status: domain.TenantStatusActive}, }, Metadata: map[string]any{ "additionalAppointments": []any{ map[string]any{"tenantId": "tenant-extra"}, map[string]any{"tenantSlug": "slug-extra"}, }, }, } mockAuth.On("GetEnrichedProfile", mock.Anything).Return(profile, nil).Once() mockTenant.On("GetTenant", mock.Anything, primaryTenantID).Return(&domain.Tenant{ ID: primaryTenantID, Slug: "primary", Name: "Primary Tenant", Status: domain.TenantStatusActive, }, nil).Once() mockTenant.On("GetTenant", mock.Anything, "tenant-extra").Return(&domain.Tenant{ ID: "tenant-extra", Slug: "extra-id", Name: "Extra Tenant By ID", Status: domain.TenantStatusActive, }, nil).Once() mockTenant.On("GetTenantBySlug", mock.Anything, "slug-extra").Return(&domain.Tenant{ ID: "tenant-slug-extra", Slug: "slug-extra", Name: "Extra Tenant By Slug", Status: domain.TenantStatusActive, }, nil).Once() req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/my-tenants", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var tenants []domain.Tenant assert.NoError(t, json.NewDecoder(resp.Body).Decode(&tenants)) assert.NoError(t, resp.Body.Close()) assertTenantIDs(t, tenants, []string{"tenant-primary", "tenant-joined", "tenant-extra", "tenant-slug-extra"}) mockAuth.AssertExpectations(t) mockTenant.AssertExpectations(t) } func TestListMyTenants_TenantAdminIncludesManageableJoinedAndPrimary(t *testing.T) { primaryTenantID := "tenant-primary" mockAuth := new(devMockAuthProvider) mockTenant := new(MockTenantService) handler := &DevHandler{Auth: mockAuth, TenantSvc: mockTenant} app := fiber.New() app.Get("/api/v1/dev/my-tenants", handler.ListMyTenants) profile := &domain.UserProfileResponse{ ID: "tenant-admin-1", Role: "tenant_admin", TenantID: &primaryTenantID, JoinedTenants: []domain.Tenant{ {ID: "tenant-joined", Slug: "joined", Name: "Joined Tenant", Status: domain.TenantStatusActive}, }, } mockAuth.On("GetEnrichedProfile", mock.Anything).Return(profile, nil).Once() mockTenant.On("ListManageableTenants", mock.Anything, "tenant-admin-1").Return([]domain.Tenant{ {ID: "tenant-managed", Slug: "managed", Name: "Managed Tenant", Status: domain.TenantStatusActive}, }, nil).Once() mockTenant.On("GetTenant", mock.Anything, primaryTenantID).Return(&domain.Tenant{ ID: primaryTenantID, Slug: "primary", Name: "Primary Tenant", Status: domain.TenantStatusActive, }, nil).Once() req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/my-tenants", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var tenants []domain.Tenant assert.NoError(t, json.NewDecoder(resp.Body).Decode(&tenants)) assert.NoError(t, resp.Body.Close()) assertTenantIDs(t, tenants, []string{"tenant-managed", "tenant-joined", "tenant-primary"}) mockAuth.AssertExpectations(t) mockTenant.AssertExpectations(t) } func assertTenantIDs(t *testing.T, tenants []domain.Tenant, expected []string) { t.Helper() actual := make([]string, 0, len(tenants)) for _, tenant := range tenants { actual = append(actual, tenant.ID) } assert.ElementsMatch(t, expected, actual) } func TestListClients_Success(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/clients" { return httpJSONAny(r, http.StatusOK, []map[string]any{ {"client_id": "client-1", "client_name": "App One", "metadata": map[string]any{"status": "active"}}, {"client_id": "client-2", "client_name": "App Two", "metadata": map[string]any{"status": "inactive"}}, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(true, nil) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, Keto: mockKeto, } 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.Get("/api/v1/dev/clients", h.ListClients) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) } func TestListClients_UserSeesOnlyClientsAllowedByReBAC(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/clients" { return httpJSONAny(r, http.StatusOK, []map[string]any{ {"client_id": "client-denied", "client_name": "Denied App", "metadata": map[string]any{"tenant_id": "tenant-a", "status": "active"}}, {"client_id": "client-allowed", "client_name": "Allowed App", "metadata": map[string]any{"tenant_id": "tenant-b", "status": "active"}}, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-denied", "view").Return(false, nil) mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-allowed", "view").Return(true, nil) mockKeto.On( "ListRelations", mock.Anything, "RelyingParty", mock.Anything, mock.Anything, mock.Anything, ).Return([]service.RelationTuple{}, nil).Maybe() h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, Keto: mockKeto, } app := fiber.New() app.Use(func(c *fiber.Ctx) error { tenantID := "tenant-a" c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-1", Role: domain.RoleUser, TenantID: &tenantID, }) return c.Next() }) app.Get("/api/v1/dev/clients", h.ListClients) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var result clientListResponse _ = json.NewDecoder(resp.Body).Decode(&result) if assert.Len(t, result.Items, 1) { assert.Equal(t, "client-allowed", result.Items[0].ID) } mockKeto.AssertExpectations(t) } func TestCreateClient_ReservedSystemNameForbidden(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { t.Fatalf("hydra should not be called when reserved system name is rejected") return nil, nil }) mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, Keto: mockKeto, } 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) body, _ := json.Marshal(map[string]any{ "name": "AdminFront", "type": "pkce", "redirectUris": []string{"http://localhost/cb"}, }) req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusForbidden, resp.StatusCode) } func TestUpdateClient_ReservedSystemNameForbidden(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "App One", "redirect_uris": []string{ "http://localhost/cb", }, "grant_types": []string{"authorization_code", "refresh_token"}, "response_types": []string{"code"}, "scope": "openid profile email offline_access", "token_endpoint_auth_method": "none", "metadata": map[string]any{"status": "active"}, }), nil } if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" { t.Fatalf("hydra update should not be called when reserved system name is rejected") } return httpJSONAny(r, http.StatusNotFound, nil), nil }) mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, Keto: mockKeto, } 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.Put("/api/v1/dev/clients/:id", h.UpdateClient) body, _ := json.Marshal(map[string]any{ "name": "DevFront", }) req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusForbidden, resp.StatusCode) } func TestUpdateClient_PrivateClientAllowedByEditConfigPermission(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "App One", "redirect_uris": []string{ "http://localhost/cb", }, "grant_types": []string{"authorization_code", "refresh_token"}, "response_types": []string{"code"}, "scope": "openid profile email offline_access", "token_endpoint_auth_method": "client_secret_basic", "metadata": map[string]any{ "status": "active", "tenant_id": "tenant-1", }, }), nil } if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "App One Updated", "redirect_uris": []string{ "http://localhost/cb", }, "grant_types": []string{"authorization_code", "refresh_token"}, "response_types": []string{"code"}, "scope": "openid profile email offline_access", "token_endpoint_auth_method": "client_secret_basic", "metadata": map[string]any{ "status": "active", "tenant_id": "tenant-1", }, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "edit_config").Return(true, nil) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, Keto: mockKeto, } app := fiber.New() tenantID := "tenant-1" app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-1", Role: domain.RoleUser, TenantID: &tenantID, }) return c.Next() }) app.Put("/api/v1/dev/clients/:id", h.UpdateClient) body, _ := json.Marshal(map[string]any{ "name": "App One Updated", }) req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var result clientDetailResponse _ = json.NewDecoder(resp.Body).Decode(&result) assert.Equal(t, "App One Updated", result.Client.Name) mockKeto.AssertExpectations(t) } func TestUpdateClient_ManagedRPAdminRequiresEditConfigPermission(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "App One", "redirect_uris": []string{ "http://localhost/cb", }, "grant_types": []string{"authorization_code", "refresh_token"}, "response_types": []string{"code"}, "scope": "openid profile email offline_access", "token_endpoint_auth_method": "none", "metadata": map[string]any{ "status": "active", "tenant_id": "tenant-1", }, }), nil } if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" { t.Fatalf("hydra update should not be called without edit_config permission") } return httpJSONAny(r, http.StatusNotFound, nil), nil }) mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "edit_config").Return(false, nil) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, Keto: mockKeto, } app := fiber.New() tenantID := "tenant-1" app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-1", Role: domain.RoleUser, TenantID: &tenantID, Metadata: map[string]any{ "managed_client_ids": []any{"client-1"}, }, }) return c.Next() }) app.Put("/api/v1/dev/clients/:id", h.UpdateClient) body, _ := json.Marshal(map[string]any{ "name": "App One Updated", "metadata": map[string]any{ "tenant_access_restricted": true, "allowed_tenants": []string{"tenant-1"}, }, }) req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusForbidden, resp.StatusCode) mockKeto.AssertExpectations(t) } func TestUpdateClient_SuperAdminBypassesEditConfigPermission(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "App One", "redirect_uris": []string{ "http://localhost/cb", }, "grant_types": []string{"authorization_code", "refresh_token"}, "response_types": []string{"code"}, "scope": "openid profile email offline_access", "token_endpoint_auth_method": "client_secret_basic", "metadata": map[string]any{ "status": "active", "tenant_id": "tenant-1", }, }), nil } if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "App One Updated", "redirect_uris": []string{ "http://localhost/cb", }, "grant_types": []string{"authorization_code", "refresh_token"}, "response_types": []string{"code"}, "scope": "openid profile email offline_access", "token_endpoint_auth_method": "client_secret_basic", "metadata": map[string]any{ "status": "active", "tenant_id": "tenant-1", }, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, } app := fiber.New() tenantID := "tenant-1" app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-1", Role: domain.RoleSuperAdmin, TenantID: &tenantID, }) return c.Next() }) app.Put("/api/v1/dev/clients/:id", h.UpdateClient) body, _ := json.Marshal(map[string]any{ "name": "App One Updated", }) req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var result clientDetailResponse _ = json.NewDecoder(resp.Body).Decode(&result) assert.Equal(t, "App One Updated", result.Client.Name) } func TestUpdateClient_AuditDetailsIncludeGeneralSettingChanges(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "App One", "redirect_uris": []string{ "http://localhost/cb", }, "grant_types": []string{"authorization_code", "refresh_token"}, "response_types": []string{"code"}, "scope": "openid profile email", "token_endpoint_auth_method": "client_secret_basic", "metadata": map[string]any{ "status": "active", "tenant_id": "tenant-1", "tenant_access_restricted": false, "allowed_tenants": []any{}, "id_token_claims": []any{}, "headless_login_enabled": false, "headless_jwks_uri": "", "headless_token_endpoint_auth_method": "", }, }), nil } if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "App One Updated", "redirect_uris": []string{ "http://localhost/cb", }, "grant_types": []string{"authorization_code", "refresh_token"}, "response_types": []string{"code"}, "scope": "openid profile email tenant", "token_endpoint_auth_method": "private_key_jwt", "metadata": map[string]any{ "status": "active", "tenant_id": "tenant-1", "tenant_access_restricted": true, "allowed_tenants": []any{"tenant-1", "tenant-2"}, "id_token_claims": []any{map[string]any{"namespace": "rp_claims", "key": "locale", "valueType": "text", "value": "ko-KR"}}, "headless_login_enabled": true, "headless_jwks_uri": "https://rp.example.com/jwks.json", "headless_token_endpoint_auth_method": "private_key_jwt", }, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) auditRepo := &mockAuditRepo{} h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, AuditRepo: auditRepo, } app := fiber.New() app.Use(auditmw.AuditMiddleware(auditmw.AuditConfig{Repo: auditRepo})) tenantID := "tenant-1" app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-1", Role: domain.RoleSuperAdmin, TenantID: &tenantID, }) return c.Next() }) app.Put("/api/v1/dev/clients/:id", h.UpdateClient) body, _ := json.Marshal(map[string]any{ "name": "App One Updated", "scopes": []string{"openid", "profile", "email", "tenant"}, "metadata": map[string]any{ "tenant_access_restricted": true, "allowed_tenants": []string{"tenant-1", "tenant-2"}, "id_token_claims": []map[string]any{ { "namespace": "rp_claims", "key": "locale", "valueType": "text", "value": "ko-KR", }, }, "headless_login_enabled": true, "headless_jwks_uri": "https://rp.example.com/jwks.json", "headless_token_endpoint_auth_method": "private_key_jwt", "backchannel_logout_uri": "https://rp.example.com/logout", "backchannel_logout_session_required": true, }, "tokenEndpointAuthMethod": "private_key_jwt", "jwksUri": "https://rp.example.com/jwks.json", "backchannelLogoutUri": "https://rp.example.com/logout", "backchannelLogoutSessionRequired": true, }) req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) if assert.NotEmpty(t, auditRepo.logs) { var details map[string]any assert.NoError(t, json.Unmarshal([]byte(auditRepo.logs[0].Details), &details)) before, _ := details["before"].(map[string]any) after, _ := details["after"].(map[string]any) assert.NotNil(t, before) assert.NotNil(t, after) assert.Contains(t, after, "scopes") assert.Contains(t, after, "tenant_access_restricted") assert.Contains(t, after, "allowed_tenants") assert.Contains(t, after, "id_token_claims") assert.Contains(t, after, "headless_login_enabled") assert.Contains(t, after, "headless_jwks_uri") assert.Contains(t, after, "backchannel_logout_uri") assert.Contains(t, after, "backchannel_logout_session_required") } } func TestListClients_ProtectedSystemClientHidden(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/clients" { return httpJSONAny(r, http.StatusOK, []map[string]any{ {"client_id": "oathkeeper-introspect", "client_name": "Internal Client"}, {"client_id": "client-1", "client_name": "App One", "metadata": map[string]any{"status": "active"}}, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(true, nil) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, Keto: mockKeto, } 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.Get("/api/v1/dev/clients", h.ListClients) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var res clientListResponse _ = json.NewDecoder(resp.Body).Decode(&res) assert.Len(t, res.Items, 1) assert.Equal(t, "client-1", res.Items[0].ID) } func TestListClients_ReservedSystemNameAliasHidden(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/clients" { return httpJSONAny(r, http.StatusOK, []map[string]any{ {"client_id": "adminfront", "client_name": "AdminFront", "metadata": map[string]any{"status": "active"}}, {"client_id": "4f2c9fd6-1111-2222-3333-444444444444", "client_name": "AdminFront", "metadata": map[string]any{"status": "active"}}, {"client_id": "devfront", "client_name": "DevFront", "metadata": map[string]any{"status": "active"}}, {"client_id": "7d2c9fd6-1111-2222-3333-444444444444", "client_name": "DevFront", "metadata": map[string]any{"status": "active"}}, {"client_id": "client-1", "client_name": "App One", "metadata": map[string]any{"status": "active"}}, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(true, nil) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, Keto: mockKeto, } 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.Get("/api/v1/dev/clients", h.ListClients) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var result clientListResponse _ = json.NewDecoder(resp.Body).Decode(&result) assert.Len(t, result.Items, 3) assert.Equal(t, "adminfront", result.Items[0].ID) assert.Equal(t, "devfront", result.Items[1].ID) assert.Equal(t, "client-1", result.Items[2].ID) } func TestGetClient_ReservedSystemNameAliasHidden(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/clients/4f2c9fd6-1111-2222-3333-444444444444" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "4f2c9fd6-1111-2222-3333-444444444444", "client_name": "AdminFront", "metadata": map[string]any{"status": "active"}, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, Keto: mockKeto, } 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.Get("/api/v1/dev/clients/:id", h.GetClient) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/4f2c9fd6-1111-2222-3333-444444444444", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusNotFound, resp.StatusCode) } func TestUpdateClientStatus_Success(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "metadata": map[string]any{"status": "active"}, }), nil } if r.Method == http.MethodPatch && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "metadata": map[string]any{"status": "inactive"}, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, Keto: mockKeto, } app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin}) return c.Next() }) app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus) body, _ := json.Marshal(map[string]any{"status": "inactive"}) req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/client-1/status", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var res clientDetailResponse json.NewDecoder(resp.Body).Decode(&res) assert.Equal(t, "inactive", res.Client.Status) } func TestUpdateClientStatus_UserAllowedByStatusPermission(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "App One", "metadata": map[string]any{ "tenant_id": "tenant-1", "status": "active", }, }), nil } if r.Method == http.MethodPatch && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "App One", "metadata": map[string]any{ "tenant_id": "tenant-1", "status": "inactive", }, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "change_status").Return(true, nil) mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "edit_config").Return(false, nil) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, Keto: mockKeto, } app := fiber.New() tenantID := "tenant-1" app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-1", Role: domain.RoleUser, TenantID: &tenantID, }) return c.Next() }) app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus) body, _ := json.Marshal(map[string]any{"status": "inactive"}) req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/client-1/status", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var res clientDetailResponse _ = json.NewDecoder(resp.Body).Decode(&res) assert.Equal(t, "inactive", res.Client.Status) mockKeto.AssertExpectations(t) } func TestUpdateClientStatus_UserAllowedByEditConfigPermission(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "App One", "metadata": map[string]any{ "tenant_id": "tenant-1", "status": "active", }, }), nil } if r.Method == http.MethodPatch && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "App One", "metadata": map[string]any{ "tenant_id": "tenant-1", "status": "inactive", }, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "change_status").Return(false, nil) mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "edit_config").Return(true, nil) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, Keto: mockKeto, } app := fiber.New() tenantID := "tenant-1" app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-1", Role: domain.RoleUser, TenantID: &tenantID, }) return c.Next() }) app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus) body, _ := json.Marshal(map[string]any{"status": "inactive"}) req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/client-1/status", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var res clientDetailResponse _ = json.NewDecoder(resp.Body).Decode(&res) assert.Equal(t, "inactive", res.Client.Status) mockKeto.AssertExpectations(t) } func TestUpdateClientStatus_ProtectedSystemClientForbidden(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "oathkeeper-introspect", }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, Keto: new(devMockKetoService), } app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin}) return c.Next() }) app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus) body, _ := json.Marshal(map[string]any{"status": "inactive"}) req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/oathkeeper-introspect/status", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusForbidden, resp.StatusCode) } func TestDeleteClient_Success(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{"client_id": "client-1"}), nil } if r.Method == http.MethodDelete && r.URL.Path == "/clients/client-1" { return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody}, nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) secretRepo := &mockSecretRepo{secrets: map[string]string{"client-1": "secret"}} redisRepo := &devMockRedisRepo{data: map[string]string{"client_secret:client-1": "secret"}} h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, SecretRepo: secretRepo, Redis: redisRepo, Keto: new(devMockKetoService), } app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin}) return c.Next() }) app.Delete("/api/v1/dev/clients/:id", h.DeleteClient) req := httptest.NewRequest(http.MethodDelete, "/api/v1/dev/clients/client-1", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusNoContent, resp.StatusCode) s, _ := secretRepo.GetByID(nil, "client-1") assert.Empty(t, s) _, err := redisRepo.Get("client_secret:client-1") assert.Error(t, err) } func TestDeleteClient_ProtectedSystemClientForbidden(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" { return httpJSONAny(r, http.StatusOK, map[string]any{"client_id": "oathkeeper-introspect"}), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, SecretRepo: &mockSecretRepo{secrets: map[string]string{"oathkeeper-introspect": "secret"}}, Redis: &devMockRedisRepo{data: map[string]string{"client_secret:oathkeeper-introspect": "secret"}}, Keto: new(devMockKetoService), } app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin}) return c.Next() }) app.Delete("/api/v1/dev/clients/:id", h.DeleteClient) req := httptest.NewRequest(http.MethodDelete, "/api/v1/dev/clients/oathkeeper-introspect", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusForbidden, resp.StatusCode) } func TestGetClient_ProtectedSystemClientHidden(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "oathkeeper-introspect", "client_name": "Internal Client", }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, Keto: new(devMockKetoService), } app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin}) return c.Next() }) app.Get("/api/v1/dev/clients/:id", h.GetClient) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/oathkeeper-introspect", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusNotFound, resp.StatusCode) } func TestGetClient_RPAdminAllowedByKetoViewPermission(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "App One", "metadata": map[string]any{ "tenant_id": "tenant-b", "status": "active", }, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, "User:rp-1", "RelyingParty", "client-1", "view").Return(true, nil) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, Keto: mockKeto, } app := fiber.New() tenantID := "tenant-a" app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ ID: "rp-1", Role: domain.RoleUser, TenantID: &tenantID, }) return c.Next() }) app.Get("/api/v1/dev/clients/:id", h.GetClient) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-1", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) mockKeto.AssertExpectations(t) } func TestGetClient_RedactsSecretWithoutViewSecretPermission(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "App One", "client_secret": "stored-secret", "metadata": map[string]any{ "tenant_id": "tenant-1", "status": "active", }, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view").Return(true, nil) mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_secret").Return(false, nil) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, Keto: mockKeto, } app := fiber.New() tenantID := "tenant-1" app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-1", Role: domain.RoleUser, TenantID: &tenantID, }) return c.Next() }) app.Get("/api/v1/dev/clients/:id", h.GetClient) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-1", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var result clientDetailResponse _ = json.NewDecoder(resp.Body).Decode(&result) assert.Empty(t, result.Client.ClientSecret) mockKeto.AssertExpectations(t) } func TestGetClient_UserAllowedToViewSecretByPermission(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "App One", "client_secret": "stored-secret", "metadata": map[string]any{ "tenant_id": "tenant-1", "status": "active", }, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view").Return(true, nil) mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_secret").Return(true, nil) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, Keto: mockKeto, } app := fiber.New() tenantID := "tenant-1" app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-1", Role: domain.RoleUser, TenantID: &tenantID, }) return c.Next() }) app.Get("/api/v1/dev/clients/:id", h.GetClient) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-1", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var result clientDetailResponse _ = json.NewDecoder(resp.Body).Decode(&result) assert.Equal(t, "stored-secret", result.Client.ClientSecret) mockKeto.AssertExpectations(t) } func TestRotateClientSecret_Success(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{"client_id": "client-1"}), nil } if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" { var body map[string]any json.NewDecoder(r.Body).Decode(&body) return httpJSONAny(r, http.StatusOK, body), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) secretRepo := &mockSecretRepo{secrets: make(map[string]string)} redisRepo := &devMockRedisRepo{data: make(map[string]string)} h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, SecretRepo: secretRepo, Redis: redisRepo, Keto: new(devMockKetoService), } app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin}) return c.Next() }) app.Post("/api/v1/dev/clients/:id/secret/rotate", h.RotateClientSecret) req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients/client-1/secret/rotate", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var res clientDetailResponse json.NewDecoder(resp.Body).Decode(&res) assert.NotEmpty(t, res.Client.ClientSecret) dbS, _ := secretRepo.GetByID(nil, "client-1") assert.Equal(t, res.Client.ClientSecret, dbS) } func TestCreateClient_RPAdminAllowedByTenantGrantPermission(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodPost && r.URL.Path == "/clients" { var body map[string]any _ = json.NewDecoder(r.Body).Decode(&body) body["client_secret"] = "generated-secret" return httpJSONAny(r, http.StatusCreated, body), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, "User:rp-1", "Tenant", "tenant-a", "grant_dev_permissions").Return(true, nil) secretRepo := &mockSecretRepo{secrets: make(map[string]string)} redisRepo := &devMockRedisRepo{data: make(map[string]string)} h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, SecretRepo: secretRepo, Redis: redisRepo, Keto: mockKeto, } app := fiber.New() tenantID := "tenant-a" app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ ID: "rp-1", Role: domain.RoleUser, TenantID: &tenantID, }) return c.Next() }) app.Post("/api/v1/dev/clients", h.CreateClient) body, _ := json.Marshal(map[string]any{ "id": "client-1", "name": "App One", "type": "pkce", "redirectUris": []string{"http://localhost/cb"}, }) req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusCreated, resp.StatusCode) mockKeto.AssertExpectations(t) } func TestCreateClient_ApprovedDeveloperCanCreatePrivateClient(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodPost && r.URL.Path == "/clients" { var body map[string]any _ = json.NewDecoder(r.Body).Decode(&body) body["client_secret"] = "generated-secret" return httpJSONAny(r, http.StatusCreated, body), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-a", "grant_dev_permissions").Return(true, nil) mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "admins", "User:user-1").Return([]service.RelationTuple{}, nil) mockKeto.On("CreateRelation", mock.Anything, "RelyingParty", "client-1", "admins", "User:user-1").Return(nil) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, SecretRepo: &mockSecretRepo{secrets: make(map[string]string)}, Redis: &devMockRedisRepo{data: make(map[string]string)}, Keto: mockKeto, KetoOutbox: new(devMockKetoOutboxRepository), } app := fiber.New() tenantID := "tenant-a" app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-1", Role: domain.RoleUser, TenantID: &tenantID, }) return c.Next() }) app.Post("/api/v1/dev/clients", h.CreateClient) body, _ := json.Marshal(map[string]any{ "id": "client-1", "name": "App One", "type": "private", "redirectUris": []string{"http://localhost/cb"}, }) req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusCreated, resp.StatusCode) mockKeto.AssertExpectations(t) } func TestCreateClient_ApprovedDeveloperRequestAllowsCreateWhenTenantGrantNotVisible(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodPost && r.URL.Path == "/clients" { var body map[string]any _ = json.NewDecoder(r.Body).Decode(&body) body["client_secret"] = "generated-secret" return httpJSONAny(r, http.StatusCreated, body), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-a", "grant_dev_permissions").Return(false, nil).Maybe() 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.DeveloperAccessStatus{ Status: domain.DeveloperRequestStatusApproved, }, nil).Maybe() h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, SecretRepo: &mockSecretRepo{secrets: make(map[string]string)}, Redis: &devMockRedisRepo{data: make(map[string]string)}, Keto: mockKeto, DeveloperSvc: developerSvc, } app := fiber.New() tenantID := "tenant-a" app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-1", Role: domain.RoleUser, TenantID: &tenantID, }) return c.Next() }) app.Post("/api/v1/dev/clients", h.CreateClient) body, _ := json.Marshal(map[string]any{ "id": "client-1", "name": "App One", "type": "private", "redirectUris": []string{"http://localhost/cb"}, }) req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusCreated, resp.StatusCode) mockKeto.AssertExpectations(t) developerSvc.AssertExpectations(t) } func TestGrantCreatorAdminRelation_FallsBackToOutboxOnImmediateFailure(t *testing.T) { mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "admins", "User:user-1").Return([]service.RelationTuple{}, nil).Maybe() mockKeto.On("CreateRelation", mock.Anything, "RelyingParty", "client-1", "admins", "User:user-1").Return(assert.AnError).Maybe() mockOutbox := new(devMockKetoOutboxRepository) mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool { return entry.Namespace == "RelyingParty" && entry.Object == "client-1" && entry.Relation == "admins" && entry.Subject == "User:user-1" && entry.Action == domain.KetoOutboxActionCreate })).Return(nil).Maybe() h := &DevHandler{ Keto: mockKeto, KetoOutbox: mockOutbox, } app := fiber.New() app.Get("/test", func(c *fiber.Ctx) error { assert.NoError(t, h.grantCreatorAdminRelation(c, "client-1", "User:user-1")) return c.SendStatus(fiber.StatusOK) }) req := httptest.NewRequest(http.MethodGet, "/test", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) mockKeto.AssertExpectations(t) mockOutbox.AssertExpectations(t) } func TestEnsureDeveloperGrantRelation_CreatesRequiredTenantRelations(t *testing.T) { mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} { mockKeto.On("ListRelations", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").Return([]service.RelationTuple{}, nil).Maybe() mockKeto.On("CreateRelation", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").Return(nil).Maybe() } mockOutbox := new(devMockKetoOutboxRepository) for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} { expectedRelation := relation mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool { return entry.Namespace == "Tenant" && entry.Object == "tenant-a" && entry.Relation == expectedRelation && entry.Subject == "User:user-1" && entry.Action == domain.KetoOutboxActionCreate })).Return(nil).Maybe() } h := &DevHandler{ Keto: mockKeto, KetoOutbox: mockOutbox, } app := fiber.New() app.Get("/test", func(c *fiber.Ctx) error { h.ensureDeveloperGrantRelation(c, "user-1", "tenant-a") return c.SendStatus(fiber.StatusOK) }) req := httptest.NewRequest(http.MethodGet, "/test", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) mockKeto.AssertExpectations(t) mockOutbox.AssertExpectations(t) } func TestEnsureDeveloperGrantRelation_SkipsExistingTenantRelations(t *testing.T) { mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} { mockKeto.On("ListRelations", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1"). Return([]service.RelationTuple{{Namespace: "Tenant", Object: "tenant-a", Relation: relation, SubjectID: "User:user-1"}}, nil).Maybe() } h := &DevHandler{ Keto: mockKeto, KetoOutbox: new(devMockKetoOutboxRepository), } app := fiber.New() app.Get("/test", func(c *fiber.Ctx) error { h.ensureDeveloperGrantRelation(c, "user-1", "tenant-a") return c.SendStatus(fiber.StatusOK) }) req := httptest.NewRequest(http.MethodGet, "/test", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) mockKeto.AssertExpectations(t) } func TestRevokeDeveloperGrantRelation_DeletesRequiredTenantRelations(t *testing.T) { mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} { mockKeto.On("DeleteRelation", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").Return(nil).Maybe() } mockOutbox := new(devMockKetoOutboxRepository) for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} { expectedRelation := relation mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool { return entry.Namespace == "Tenant" && entry.Object == "tenant-a" && entry.Relation == expectedRelation && entry.Subject == "User:user-1" && entry.Action == domain.KetoOutboxActionDelete })).Return(nil).Maybe() } h := &DevHandler{ Keto: mockKeto, KetoOutbox: mockOutbox, } app := fiber.New() app.Get("/test", func(c *fiber.Ctx) error { h.revokeDeveloperGrantRelation(c, "user-1", "tenant-a") return c.SendStatus(fiber.StatusOK) }) req := httptest.NewRequest(http.MethodGet, "/test", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) mockKeto.AssertExpectations(t) mockOutbox.AssertExpectations(t) } func TestGetStats_Success(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/clients" { return httpJSONAny(r, http.StatusOK, []map[string]any{ {"client_id": "c1", "metadata": map[string]any{"tenant_id": "t1"}}, {"client_id": "c2", "metadata": map[string]any{"tenant_id": "t1"}}, {"client_id": "oathkeeper-introspect", "metadata": map[string]any{"tenant_id": "t1"}}, {"client_id": "c3", "metadata": map[string]any{"tenant_id": "t2"}}, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) auditRepo := &devEnhancedMockAuditRepo{ countFailures: 7, countSessions: 3, } mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On( "CheckPermission", mock.Anything, "User:u1", "RelyingParty", mock.Anything, "view", ).Return(false, nil).Maybe() mockKeto.On( "ListRelations", mock.Anything, "RelyingParty", mock.Anything, mock.Anything, mock.Anything, ).Return([]service.RelationTuple{}, nil).Maybe() h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, AuditRepo: auditRepo, Keto: mockKeto, } app := fiber.New() tenantID := "t1" app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ ID: "u1", Role: domain.RoleSuperAdmin, TenantID: &tenantID, }) return c.Next() }) app.Get("/api/v1/dev/stats", h.GetStats) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/stats", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var res devStatsResponse json.NewDecoder(resp.Body).Decode(&res) assert.Equal(t, int64(3), res.TotalClients) assert.Equal(t, int64(7), res.AuthFailures) assert.Equal(t, int64(3), res.ActiveSessions) } func TestGetStats_UserScopesAuditMetricsToVisibleClients(t *testing.T) { now := time.Now() transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/clients" { return httpJSONAny(r, http.StatusOK, []map[string]any{ {"client_id": "client-owned", "metadata": map[string]any{"tenant_id": "tenant-a"}}, {"client_id": "client-other", "metadata": map[string]any{"tenant_id": "tenant-a"}}, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) auditRepo := &mockAuditRepo{ logs: []domain.AuditLog{ { EventID: "evt-1", Timestamp: now.Add(-15 * time.Minute), SessionID: "sess-owned", Status: "success", EventType: "GET /api/v1/dev/clients/client-owned", Details: `{"client_id":"client-owned","tenant_id":"tenant-a"}`, }, { EventID: "evt-2", Timestamp: now.Add(-20 * time.Minute), Status: "failure", EventType: "GET /api/v1/dev/clients/client-owned", Details: `{"client_id":"client-owned","tenant_id":"tenant-a"}`, }, { EventID: "evt-3", Timestamp: now.Add(-10 * time.Minute), SessionID: "sess-other", Status: "success", EventType: "GET /api/v1/dev/clients/client-other", Details: `{"client_id":"client-other","tenant_id":"tenant-a"}`, }, { EventID: "evt-4", Timestamp: now.Add(-30 * time.Minute), Status: "failure", EventType: "GET /api/v1/dev/clients/client-other", Details: `{"client_id":"client-other","tenant_id":"tenant-a"}`, }, }, } mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-owned", "view").Return(true, nil) mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-other", "view").Return(false, nil) mockKeto.On( "ListRelations", mock.Anything, "RelyingParty", mock.Anything, mock.Anything, mock.Anything, ).Return([]service.RelationTuple{}, nil).Maybe() h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, AuditRepo: auditRepo, Keto: mockKeto, } app := fiber.New() tenantID := "tenant-a" app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-1", Role: domain.RoleUser, TenantID: &tenantID, }) return c.Next() }) app.Get("/api/v1/dev/stats", h.GetStats) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/stats", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var res devStatsResponse _ = json.NewDecoder(resp.Body).Decode(&res) assert.Equal(t, int64(1), res.TotalClients) assert.Equal(t, int64(1), res.AuthFailures) assert.Equal(t, int64(1), res.ActiveSessions) mockKeto.AssertExpectations(t) } func TestGetRPUsageDaily_UserScopesItemsToVisibleClients(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/clients" { return httpJSONAny(r, http.StatusOK, []map[string]any{ {"client_id": "client-owned", "client_name": "Owned App", "metadata": map[string]any{"tenant_id": "tenant-a"}}, {"client_id": "client-other", "client_name": "Other App", "metadata": map[string]any{"tenant_id": "tenant-a"}}, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-owned", "view").Return(true, nil) mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-other", "view").Return(false, nil) mockKeto.On( "ListRelations", mock.Anything, "RelyingParty", mock.Anything, mock.Anything, mock.Anything, ).Return([]service.RelationTuple{}, nil).Maybe() usageRepo := &fakeRPUsageQueryRepo{ items: []domain.RPUsageDailyMetric{ {Date: "2026-05-12", TenantID: "tenant-a", ClientID: "client-owned", ClientName: "Owned App", LoginRequests: 3}, {Date: "2026-05-12", TenantID: "tenant-a", ClientID: "client-other", ClientName: "Other App", LoginRequests: 9}, }, } h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, Keto: mockKeto, RPUsageQueries: usageRepo, } app := fiber.New() tenantID := "tenant-a" app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-1", Role: domain.RoleUser, TenantID: &tenantID, }) return c.Next() }) app.Get("/api/v1/dev/rp-usage/daily", h.GetRPUsageDaily) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/rp-usage/daily?days=14&period=day", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var res devRPUsageDailyResponse _ = json.NewDecoder(resp.Body).Decode(&res) if assert.Len(t, res.Items, 1) { assert.Equal(t, "client-owned", res.Items[0].ClientID) } assert.Equal(t, "tenant-a", usageRepo.query.TenantID) mockKeto.AssertExpectations(t) } func TestDevHandler_NoAuditNoAction(t *testing.T) { h := &DevHandler{ Hydra: &service.HydraAdminService{AdminURL: "http://hydra.test"}, AuditRepo: nil, // Missing Keto: new(devMockKetoService), } t.Run("Mutating action fails when audit log is unavailable", func(t *testing.T) { app := fiber.New() app.Use(func(c *fiber.Ctx) error { if h.AuditRepo == nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Audit service unavailable"}) } return c.Next() }) app.Post("/api/v1/dev/clients", h.CreateClient) req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader([]byte("{}"))) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) }) } func TestCreateClient_HeadlessLoginPayloadMapping(t *testing.T) { var captured domain.HydraClient transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodPost && r.URL.Path == "/clients" { body, err := io.ReadAll(r.Body) assert.NoError(t, err) err = json.Unmarshal(body, &captured) assert.NoError(t, err) return httpJSONAny(r, http.StatusCreated, map[string]any{ "client_id": captured.ClientID, "client_name": captured.ClientName, "redirect_uris": captured.RedirectURIs, "grant_types": captured.GrantTypes, "response_types": captured.ResponseTypes, "scope": captured.Scope, "token_endpoint_auth_method": captured.TokenEndpointAuthMethod, "jwks": captured.JWKS, "metadata": captured.Metadata, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", PublicURL: "http://hydra.public", HTTPClient: &http.Client{Transport: transport}, }, Keto: new(devMockKetoService), } 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) body, _ := json.Marshal(map[string]any{ "name": "Headless Login App", "type": "private", "redirectUris": []string{"https://rp.example.com/callback"}, "scopes": []string{"openid", "profile"}, "tokenEndpointAuthMethod": "private_key_jwt", "jwksUri": "https://rp.example.com/.well-known/jwks.json", "metadata": map[string]any{ "headless_login_enabled": true, "request_object_signing_alg": "RS256", }, }) req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusCreated, resp.StatusCode) assert.Equal(t, "private_key_jwt", captured.TokenEndpointAuthMethod) assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.JWKSUri) assert.Nil(t, captured.JWKS) assert.Equal(t, "private_key_jwt", captured.Metadata["headless_token_endpoint_auth_method"]) assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.Metadata["headless_jwks_uri"]) assert.True(t, captured.IsHeadlessLoginEnabled()) assert.Equal(t, true, captured.Metadata["headless_login_enabled"]) _, hasRequestObjectAlg := captured.Metadata["request_object_signing_alg"] assert.False(t, hasRequestObjectAlg) } func TestCreateClient_DefaultsSkipConsentToTrue(t *testing.T) { var captured domain.HydraClient transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodPost && r.URL.Path == "/clients" { body, err := io.ReadAll(r.Body) assert.NoError(t, err) err = json.Unmarshal(body, &captured) assert.NoError(t, err) return httpJSONAny(r, http.StatusCreated, map[string]any{ "client_id": captured.ClientID, "client_name": captured.ClientName, "redirect_uris": captured.RedirectURIs, "grant_types": captured.GrantTypes, "response_types": captured.ResponseTypes, "scope": captured.Scope, "token_endpoint_auth_method": captured.TokenEndpointAuthMethod, "skip_consent": captured.SkipConsent, "metadata": captured.Metadata, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", PublicURL: "http://hydra.public", HTTPClient: &http.Client{Transport: transport}, }, Keto: new(devMockKetoService), } 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) body, _ := json.Marshal(map[string]any{ "name": "Trusted App", "type": "pkce", "redirectUris": []string{"https://rp.example.com/callback"}, }) req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusCreated, resp.StatusCode) assert.NotNil(t, captured.SkipConsent) assert.True(t, *captured.SkipConsent) } func TestNormalizeClientAutoLoginMetadata(t *testing.T) { t.Run("keeps supported flag and URL", func(t *testing.T) { metadata, err := normalizeClientAutoLoginMetadata(map[string]any{ "auto_login_supported": true, "auto_login_url": "https://rp.example.com/login?auto=1", }) assert.NoError(t, err) assert.Equal(t, true, metadata["auto_login_supported"]) assert.Equal(t, "https://rp.example.com/login?auto=1", metadata["auto_login_url"]) }) t.Run("requires URL when supported", func(t *testing.T) { _, err := normalizeClientAutoLoginMetadata(map[string]any{ "auto_login_supported": true, }) assert.Error(t, err) }) t.Run("removes URL when unsupported", func(t *testing.T) { metadata, err := normalizeClientAutoLoginMetadata(map[string]any{ "auto_login_supported": false, "auto_login_url": "https://rp.example.com/login?auto=1", }) assert.NoError(t, err) assert.Equal(t, false, metadata["auto_login_supported"]) _, exists := metadata["auto_login_url"] assert.False(t, exists) }) } func TestCreateClient_AllowsExplicitSkipConsentFalse(t *testing.T) { var captured domain.HydraClient transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodPost && r.URL.Path == "/clients" { body, err := io.ReadAll(r.Body) assert.NoError(t, err) err = json.Unmarshal(body, &captured) assert.NoError(t, err) return httpJSONAny(r, http.StatusCreated, map[string]any{ "client_id": captured.ClientID, "client_name": captured.ClientName, "redirect_uris": captured.RedirectURIs, "grant_types": captured.GrantTypes, "response_types": captured.ResponseTypes, "scope": captured.Scope, "token_endpoint_auth_method": captured.TokenEndpointAuthMethod, "skip_consent": captured.SkipConsent, "metadata": captured.Metadata, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", PublicURL: "http://hydra.public", HTTPClient: &http.Client{Transport: transport}, }, Keto: new(devMockKetoService), } 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) body, _ := json.Marshal(map[string]any{ "name": "Consent Required App", "type": "pkce", "skipConsent": false, "redirectUris": []string{"https://rp.example.com/callback"}, }) req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusCreated, resp.StatusCode) assert.NotNil(t, captured.SkipConsent) assert.False(t, *captured.SkipConsent) } func TestCreateClient_LegacyPKCEHeadlessInputIsNormalizedToPrivate(t *testing.T) { var captured domain.HydraClient transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodPost && r.URL.Path == "/clients" { body, err := io.ReadAll(r.Body) assert.NoError(t, err) err = json.Unmarshal(body, &captured) assert.NoError(t, err) return httpJSONAny(r, http.StatusCreated, map[string]any{ "client_id": captured.ClientID, "client_name": captured.ClientName, "redirect_uris": captured.RedirectURIs, "grant_types": captured.GrantTypes, "response_types": captured.ResponseTypes, "scope": captured.Scope, "token_endpoint_auth_method": captured.TokenEndpointAuthMethod, "jwks_uri": captured.JWKSUri, "jwks": captured.JWKS, "metadata": captured.Metadata, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", PublicURL: "http://hydra.public", HTTPClient: &http.Client{Transport: transport}, }, Keto: new(devMockKetoService), } 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) body, _ := json.Marshal(map[string]any{ "name": "Legacy Headless Login App", "type": "pkce", "redirectUris": []string{"https://rp.example.com/callback"}, "metadata": map[string]any{ "headless_login_enabled": true, "headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json", }, }) req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusCreated, resp.StatusCode) assert.Equal(t, "private_key_jwt", captured.TokenEndpointAuthMethod) assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.JWKSUri) assert.Nil(t, captured.JWKS) assert.Equal(t, "private_key_jwt", captured.Metadata["headless_token_endpoint_auth_method"]) assert.True(t, captured.IsHeadlessLoginEnabled()) } func TestMapClientSummary_ClassifiesHeadlessLoginAsPrivate(t *testing.T) { h := &DevHandler{} summary := h.mapClientSummary(domain.HydraClient{ ClientID: "client-headless-login", ClientName: "Headless Login App", TokenEndpointAuthMethod: "none", Metadata: map[string]any{ "status": "active", "headless_login_enabled": true, "headless_token_endpoint_auth_method": "private_key_jwt", "headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json", }, }) assert.Equal(t, "private", summary.Type) } func TestCreateClient_HeadlessLoginRejectsInlineJWKS(t *testing.T) { var hydraCalled bool h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", PublicURL: "http://hydra.public", HTTPClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { hydraCalled = true return httpJSONAny(r, http.StatusCreated, map[string]any{ "client_id": "client-headless-login", "client_name": "Headless Login App", "redirect_uris": []string{"https://rp.example.com/callback"}, "grant_types": []string{"authorization_code", "refresh_token"}, "response_types": []string{"code"}, "scope": "openid profile", "token_endpoint_auth_method": "none", "metadata": map[string]any{ "headless_login_enabled": true, }, }), nil })}, }, Keto: new(devMockKetoService), } 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) body, _ := json.Marshal(map[string]any{ "name": "Headless Login App", "type": "pkce", "redirectUris": []string{"https://rp.example.com/callback"}, "scopes": []string{"openid", "profile"}, "tokenEndpointAuthMethod": "private_key_jwt", "jwks": map[string]any{ "keys": []map[string]any{{ "kty": "RSA", "alg": "RS256", "n": "AQIDBAUGBw", "e": "AQAB", }}, }, "metadata": map[string]any{ "headless_login_enabled": true, "request_object_signing_alg": "RS256", }, }) req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusBadRequest, resp.StatusCode) defer resp.Body.Close() bodyBytes, _ := io.ReadAll(resp.Body) assert.Contains(t, string(bodyBytes), "headless login supports jwksUri only") assert.False(t, hydraCalled) } func TestCreateClient_NormalizesIDTokenClaimsMetadata(t *testing.T) { var captured domain.HydraClient transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodPost && r.URL.Path == "/clients" { body, err := io.ReadAll(r.Body) assert.NoError(t, err) assert.NoError(t, json.Unmarshal(body, &captured)) return httpJSONAny(r, http.StatusCreated, map[string]any{ "client_id": captured.ClientID, "client_name": captured.ClientName, "redirect_uris": captured.RedirectURIs, "grant_types": captured.GrantTypes, "response_types": captured.ResponseTypes, "scope": captured.Scope, "token_endpoint_auth_method": captured.TokenEndpointAuthMethod, "metadata": captured.Metadata, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", PublicURL: "http://hydra.public", HTTPClient: &http.Client{Transport: transport}, }, Keto: new(devMockKetoService), } 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) body, _ := json.Marshal(map[string]any{ "name": "Claims App", "type": "pkce", "redirectUris": []string{"https://rp.example.com/callback"}, "metadata": map[string]any{ "id_token_claims": []map[string]any{ { "id": "claim-1", "namespace": "rp_claims", "key": "locale", "value": " ko-KR ", "valueType": "text", }, { "id": "claim-2", "namespace": "rp_claims", "key": "tier", "value": "2", "valueType": "number", }, { "id": "claim-3", "namespace": "rp_claims", "key": "ratio", "value": "3.14", "valueType": "float", }, }, }, }) req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusCreated, resp.StatusCode) claims, ok := captured.Metadata[domain.MetadataIDTokenClaims].([]any) if assert.True(t, ok) && assert.Len(t, claims, 3) { first, ok := claims[0].(map[string]any) if assert.True(t, ok) { assert.Equal(t, "rp_claims", first["namespace"]) assert.Equal(t, "locale", first["key"]) assert.Equal(t, "ko-KR", first["value"]) assert.Equal(t, "text", first["valueType"]) _, hasID := first["id"] assert.False(t, hasID) } second, ok := claims[1].(map[string]any) if assert.True(t, ok) { assert.Equal(t, "rp_claims", second["namespace"]) assert.Equal(t, "tier", second["key"]) assert.Equal(t, "2", second["value"]) assert.Equal(t, "number", second["valueType"]) } third, ok := claims[2].(map[string]any) if assert.True(t, ok) { assert.Equal(t, "rp_claims", third["namespace"]) assert.Equal(t, "ratio", third["key"]) assert.Equal(t, "3.14", third["value"]) assert.Equal(t, "float", third["valueType"]) } } } func TestCreateClient_RejectsInvalidIDTokenClaimsMetadata(t *testing.T) { hydraCalled := false h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", PublicURL: "http://hydra.public", HTTPClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { hydraCalled = true return httpJSONAny(r, http.StatusCreated, map[string]any{}), nil })}, }, Keto: new(devMockKetoService), } 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) body, _ := json.Marshal(map[string]any{ "name": "Claims App", "type": "pkce", "redirectUris": []string{"https://rp.example.com/callback"}, "metadata": map[string]any{ "id_token_claims": []map[string]any{ { "namespace": "top_level", "key": "rp_claims", "value": "forbidden", "valueType": "text", }, }, }, }) req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusBadRequest, resp.StatusCode) defer resp.Body.Close() bodyBytes, _ := io.ReadAll(resp.Body) assert.Contains(t, string(bodyBytes), "top_level namespace is managed from admin user custom claims") assert.False(t, hydraCalled) } func TestUpdateClient_HeadlessLoginPayloadMapping(t *testing.T) { var captured domain.HydraClient transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-headless-login" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-headless-login", "client_name": "Headless Login Before", "redirect_uris": []string{"https://before.example.com/callback"}, "grant_types": []string{"authorization_code", "refresh_token"}, "response_types": []string{"code"}, "scope": "openid profile", "token_endpoint_auth_method": "none", "metadata": map[string]any{ "status": "active", "headless_jwks": map[string]any{"keys": []map[string]any{}}, "headless_jwks_uri": "https://stale.example.com/old.json", "headless_login_enabled": true, "request_object_signing_alg": "RS256", }, }), nil } if r.Method == http.MethodPut && r.URL.Path == "/clients/client-headless-login" { body, err := io.ReadAll(r.Body) assert.NoError(t, err) err = json.Unmarshal(body, &captured) assert.NoError(t, err) return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": captured.ClientID, "client_name": captured.ClientName, "redirect_uris": captured.RedirectURIs, "grant_types": captured.GrantTypes, "response_types": captured.ResponseTypes, "scope": captured.Scope, "token_endpoint_auth_method": captured.TokenEndpointAuthMethod, "jwks_uri": captured.JWKSUri, "metadata": captured.Metadata, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", PublicURL: "http://hydra.public", HTTPClient: &http.Client{Transport: transport}, }, Keto: new(devMockKetoService), } 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.Put("/api/v1/dev/clients/:id", h.UpdateClient) body, _ := json.Marshal(map[string]any{ "name": "Headless Login After", "type": "private", "tokenEndpointAuthMethod": "private_key_jwt", "jwksUri": "https://rp.example.com/.well-known/jwks.json", "metadata": map[string]any{ "headless_login_enabled": true, "request_object_signing_alg": "RS256", }, }) req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-headless-login", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, "private_key_jwt", captured.TokenEndpointAuthMethod) assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.JWKSUri) assert.Equal(t, "private_key_jwt", captured.Metadata["headless_token_endpoint_auth_method"]) assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.Metadata["headless_jwks_uri"]) _, hasInlineJWKS := captured.Metadata["headless_jwks"] assert.False(t, hasInlineJWKS) _, hasRequestObjectAlg := captured.Metadata["request_object_signing_alg"] assert.False(t, hasRequestObjectAlg) assert.True(t, captured.IsHeadlessLoginEnabled()) assert.Equal(t, true, captured.Metadata["headless_login_enabled"]) } func TestUpdateClient_AllowsExplicitSkipConsentFalse(t *testing.T) { var captured domain.HydraClient currentSkipConsent := true transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, domain.HydraClient{ ClientID: "client-1", ClientName: "Trusted Before", RedirectURIs: []string{"https://rp.example.com/callback"}, GrantTypes: []string{"authorization_code", "refresh_token"}, ResponseTypes: []string{"code"}, Scope: "openid profile", TokenEndpointAuthMethod: "none", SkipConsent: ¤tSkipConsent, Metadata: map[string]any{"status": "active"}, }), nil } if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" { body, err := io.ReadAll(r.Body) assert.NoError(t, err) err = json.Unmarshal(body, &captured) assert.NoError(t, err) return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": captured.ClientID, "client_name": captured.ClientName, "redirect_uris": captured.RedirectURIs, "grant_types": captured.GrantTypes, "response_types": captured.ResponseTypes, "scope": captured.Scope, "token_endpoint_auth_method": captured.TokenEndpointAuthMethod, "skip_consent": captured.SkipConsent, "metadata": captured.Metadata, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", PublicURL: "http://hydra.public", HTTPClient: &http.Client{Transport: transport}, }, Keto: new(devMockKetoService), } 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.Put("/api/v1/dev/clients/:id", h.UpdateClient) body, _ := json.Marshal(map[string]any{ "name": "Consent Required After", "type": "pkce", "skipConsent": false, }) req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.NotNil(t, captured.SkipConsent) assert.False(t, *captured.SkipConsent) } func TestUpdateClient_HeadlessLoginIgnoresExistingTopLevelJWKS(t *testing.T) { var captured domain.HydraClient transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-headless-login" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-headless-login", "client_name": "Headless Login Before", "redirect_uris": []string{"https://before.example.com/callback"}, "grant_types": []string{"authorization_code", "refresh_token"}, "response_types": []string{"code"}, "scope": "openid profile", "token_endpoint_auth_method": "none", "jwks": map[string]any{ "keys": []map[string]any{{ "kty": "RSA", "alg": "RS256", "n": "AQIDBAUGBw", "e": "AQAB", }}, }, "metadata": map[string]any{ "status": "active", "headless_login_enabled": true, "headless_jwks_uri": "https://stale.example.com/old.json", "request_object_signing_alg": "RS256", }, }), nil } if r.Method == http.MethodPut && r.URL.Path == "/clients/client-headless-login" { body, err := io.ReadAll(r.Body) assert.NoError(t, err) err = json.Unmarshal(body, &captured) assert.NoError(t, err) return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": captured.ClientID, "client_name": captured.ClientName, "redirect_uris": captured.RedirectURIs, "grant_types": captured.GrantTypes, "response_types": captured.ResponseTypes, "scope": captured.Scope, "token_endpoint_auth_method": captured.TokenEndpointAuthMethod, "jwks_uri": captured.JWKSUri, "metadata": captured.Metadata, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", PublicURL: "http://hydra.public", HTTPClient: &http.Client{Transport: transport}, }, Keto: new(devMockKetoService), } 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.Put("/api/v1/dev/clients/:id", h.UpdateClient) body, _ := json.Marshal(map[string]any{ "name": "Headless Login After", "type": "pkce", "tokenEndpointAuthMethod": "private_key_jwt", "jwksUri": "https://rp.example.com/.well-known/jwks.json", "metadata": map[string]any{ "headless_login_enabled": true, "request_object_signing_alg": "RS256", }, }) req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-headless-login", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Nil(t, captured.JWKS) assert.Equal(t, "private_key_jwt", captured.TokenEndpointAuthMethod) assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.JWKSUri) assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.Metadata["headless_jwks_uri"]) _, hasRequestObjectAlg := captured.Metadata["request_object_signing_alg"] assert.False(t, hasRequestObjectAlg) } func TestUpdateClient_RevokesExistingConsentsWhenTenantPolicyChanges(t *testing.T) { var revokedSubjects []string transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, domain.HydraClient{ ClientID: "client-1", ClientName: "Tenant Guarded App", RedirectURIs: []string{"https://rp.example.com/callback"}, GrantTypes: []string{"authorization_code", "refresh_token"}, ResponseTypes: []string{"code"}, Scope: "openid tenant profile email", TokenEndpointAuthMethod: "none", Metadata: map[string]any{ "tenant_access_restricted": true, "allowed_tenants": []string{"tenant-a"}, }, }), nil } if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" { var updated domain.HydraClient body, err := io.ReadAll(r.Body) assert.NoError(t, err) assert.NoError(t, json.Unmarshal(body, &updated)) return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": updated.ClientID, "client_name": updated.ClientName, "redirect_uris": updated.RedirectURIs, "grant_types": updated.GrantTypes, "response_types": updated.ResponseTypes, "scope": updated.Scope, "token_endpoint_auth_method": updated.TokenEndpointAuthMethod, "metadata": updated.Metadata, }), nil } if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" { revokedSubjects = append(revokedSubjects, r.URL.Query().Get("subject")) assert.Equal(t, "client-1", r.URL.Query().Get("client")) return httpResponse(r, http.StatusNoContent, ""), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) consentRepo := &mockConsentRepo{ consents: []domain.ClientConsent{ {ClientID: "client-1", Subject: "user-1"}, {ClientID: "client-1", Subject: "user-2"}, {ClientID: "other-client", Subject: "user-3"}, }, } h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", PublicURL: "http://hydra.public", HTTPClient: &http.Client{Transport: transport}, }, ConsentRepo: consentRepo, Keto: new(devMockKetoService), } 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.Put("/api/v1/dev/clients/:id", h.UpdateClient) body, _ := json.Marshal(map[string]any{ "metadata": map[string]any{ "tenant_access_restricted": true, "allowed_tenants": []string{"tenant-b"}, }, }) req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.ElementsMatch(t, []string{"user-1", "user-2"}, revokedSubjects) assert.Len(t, consentRepo.consents, 1) assert.Equal(t, "other-client", consentRepo.consents[0].ClientID) } func TestUpdateClient_DoesNotRevokeConsentsWhenTenantPolicyUnchanged(t *testing.T) { revoked := false transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, domain.HydraClient{ ClientID: "client-1", ClientName: "Tenant Guarded App", RedirectURIs: []string{"https://rp.example.com/callback"}, GrantTypes: []string{"authorization_code", "refresh_token"}, ResponseTypes: []string{"code"}, Scope: "openid tenant profile email", TokenEndpointAuthMethod: "none", Metadata: map[string]any{ "tenant_access_restricted": true, "allowed_tenants": []string{"tenant-a"}, }, }), nil } if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" { var updated domain.HydraClient body, err := io.ReadAll(r.Body) assert.NoError(t, err) assert.NoError(t, json.Unmarshal(body, &updated)) return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": updated.ClientID, "client_name": updated.ClientName, "redirect_uris": updated.RedirectURIs, "grant_types": updated.GrantTypes, "response_types": updated.ResponseTypes, "scope": updated.Scope, "token_endpoint_auth_method": updated.TokenEndpointAuthMethod, "metadata": updated.Metadata, }), nil } if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" { revoked = true return httpResponse(r, http.StatusNoContent, ""), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) consentRepo := &mockConsentRepo{ consents: []domain.ClientConsent{ {ClientID: "client-1", Subject: "user-1"}, }, } h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", PublicURL: "http://hydra.public", HTTPClient: &http.Client{Transport: transport}, }, ConsentRepo: consentRepo, Keto: new(devMockKetoService), } 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.Put("/api/v1/dev/clients/:id", h.UpdateClient) body, _ := json.Marshal(map[string]any{ "name": "Renamed App", }) req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.False(t, revoked) assert.Len(t, consentRepo.consents, 1) } func TestRefreshHeadlessJWKSCache_ReturnsUpdatedCacheState(t *testing.T) { privateKey, jwks := mustHeadlessRSAJWK(t) _ = privateKey jwksBody, _ := json.Marshal(jwks) expectedN := devTestJWKSFirstKeyString(t, jwks, "n") redisRepo := &devMockRedisRepo{data: map[string]string{}} h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", PublicURL: "http://hydra.public", HTTPClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-headless-login" { return httpJSONAny(r, http.StatusOK, domain.HydraClient{ ClientID: "client-headless-login", Metadata: map[string]any{ "status": "active", "headless_login_enabled": true, "headless_token_endpoint_auth_method": "private_key_jwt", "headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json", }, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil })}, }, Redis: redisRepo, HeadlessJWKS: service.NewHeadlessJWKSCacheService(redisRepo, &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", r.URL.String()) var payload map[string]any _ = json.Unmarshal(jwksBody, &payload) return httpJSONAny(r, http.StatusOK, payload), nil })}), Keto: new(devMockKetoService), } 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/:id/headless-jwks/refresh", h.RefreshHeadlessJWKSCache) req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients/client-headless-login/headless-jwks/refresh", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var got clientDetailResponse err := json.NewDecoder(resp.Body).Decode(&got) assert.NoError(t, err) if assert.NotNil(t, got.HeadlessJWKSCache) { assert.Equal(t, "success", got.HeadlessJWKSCache.LastRefreshStatus) assert.Equal(t, []string{"test-kid"}, got.HeadlessJWKSCache.CachedKids) if assert.Len(t, got.HeadlessJWKSCache.ParsedKeys, 1) { assert.Equal(t, "test-kid", got.HeadlessJWKSCache.ParsedKeys[0].Kid) assert.Equal(t, "RSA", got.HeadlessJWKSCache.ParsedKeys[0].Kty) assert.Equal(t, "sig", got.HeadlessJWKSCache.ParsedKeys[0].Use) assert.Equal(t, "RS256", got.HeadlessJWKSCache.ParsedKeys[0].Alg) assert.Equal(t, expectedN, got.HeadlessJWKSCache.ParsedKeys[0].N) } } } func TestRevokeHeadlessJWKSCache_DeletesCachedState(t *testing.T) { redisRepo := &devMockRedisRepo{data: map[string]string{}} cacheService := service.NewHeadlessJWKSCacheService(redisRepo, nil) now := time.Now() expiresAt := now.Add(30 * time.Minute) err := cacheService.SaveState("client-headless-login", domain.HeadlessJWKSCacheState{ ClientID: "client-headless-login", JWKSURI: "https://rp.example.com/.well-known/jwks.json", CachedAt: &now, ExpiresAt: &expiresAt, LastRefreshStatus: "success", ConsecutiveFailures: 0, RawJWKS: `{"keys":[{"kid":"cached-key","kty":"RSA","n":"AQIDBAUGBw","e":"AQAB"}]}`, }) assert.NoError(t, err) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", PublicURL: "http://hydra.public", HTTPClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-headless-login" { return httpJSONAny(r, http.StatusOK, domain.HydraClient{ ClientID: "client-headless-login", Metadata: map[string]any{ "status": "active", "headless_login_enabled": true, }, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil })}, }, Redis: redisRepo, HeadlessJWKS: cacheService, Keto: new(devMockKetoService), } 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.Delete("/api/v1/dev/clients/:id/headless-jwks/cache", h.RevokeHeadlessJWKSCache) req := httptest.NewRequest(http.MethodDelete, "/api/v1/dev/clients/client-headless-login/headless-jwks/cache", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusNoContent, resp.StatusCode) stored, err := cacheService.GetState("client-headless-login") assert.Error(t, err) assert.Nil(t, stored) } func TestListAuditLogs_TenantMemberWithoutAuditPermissionReturnsEmpty(t *testing.T) { h := &DevHandler{ Hydra: &service.HydraAdminService{AdminURL: "http://hydra.test"}, AuditRepo: &mockAuditRepo{}, Keto: new(devMockKetoService), } app := fiber.New() tenantID := "tenant-a" app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ ID: "u-member", Role: domain.RoleUser, TenantID: &tenantID, }) return c.Next() }) app.Get("/api/v1/dev/audit-logs", h.ListAuditLogs) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/audit-logs", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var result devAuditListResponse _ = json.NewDecoder(resp.Body).Decode(&result) assert.Empty(t, result.Items) } func TestListAuditLogs_RPAdminScope(t *testing.T) { auditRepo := &mockAuditRepo{ logs: []domain.AuditLog{ { EventID: "evt-1", EventType: "POST /api/v1/dev/clients", Status: "success", Timestamp: time.Now().UTC(), Details: `{"target_id":"client-allowed","tenant_id":"tenant-a","action":"CREATE_CLIENT"}`, }, { EventID: "evt-2", EventType: "POST /api/v1/dev/clients", Status: "success", Timestamp: time.Now().UTC().Add(-time.Minute), Details: `{"target_id":"client-other","tenant_id":"tenant-a","action":"CREATE_CLIENT"}`, }, }, } h := &DevHandler{ Hydra: &service.HydraAdminService{AdminURL: "http://hydra.test"}, AuditRepo: auditRepo, Keto: new(devMockKetoService), } app := fiber.New() tenantID := "tenant-a" app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ ID: "u-rp-admin", Role: domain.RoleUser, TenantID: &tenantID, Metadata: map[string]any{ "managed_client_ids": []any{"client-allowed"}, }, }) return c.Next() }) app.Get("/api/v1/dev/audit-logs", h.ListAuditLogs) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/audit-logs?limit=50", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var result devAuditListResponse _ = json.NewDecoder(resp.Body).Decode(&result) assert.Len(t, result.Items, 1) assert.Equal(t, "evt-1", result.Items[0].EventID) } func TestListAuditLogs_UserAllowedByRPAuditPermission(t *testing.T) { auditRepo := &mockAuditRepo{ logs: []domain.AuditLog{ { EventID: "evt-allowed", EventType: "POST /api/v1/dev/clients/client-allowed/secret/rotate", Status: "success", Timestamp: time.Now().UTC(), Details: `{"target_id":"client-allowed","tenant_id":"tenant-a","action":"ROTATE_SECRET"}`, }, { EventID: "evt-allowed-path", EventType: "GET /api/v1/dev/clients/client-allowed/relations", Status: "success", Timestamp: time.Now().UTC().Add(-30 * time.Second), Details: `{"request_id":"req-1"}`, }, { EventID: "evt-denied", EventType: "POST /api/v1/dev/clients/client-denied/secret/rotate", Status: "success", Timestamp: time.Now().UTC().Add(-time.Minute), Details: `{"target_id":"client-denied","tenant_id":"tenant-b","action":"ROTATE_SECRET"}`, }, }, } transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/clients" { return httpJSONAny(r, http.StatusOK, []map[string]any{ {"client_id": "client-allowed", "client_name": "Allowed App", "metadata": map[string]any{"tenant_id": "tenant-a"}}, {"client_id": "client-denied", "client_name": "Denied App", "metadata": map[string]any{"tenant_id": "tenant-b"}}, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-allowed", "audit_viewer").Return(true, nil) mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-denied", "audit_viewer").Return(false, nil) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, AuditRepo: auditRepo, Keto: mockKeto, } app := fiber.New() tenantID := "tenant-a" app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-1", Role: domain.RoleUser, TenantID: &tenantID, }) return c.Next() }) app.Get("/api/v1/dev/audit-logs", h.ListAuditLogs) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/audit-logs?limit=50", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var result devAuditListResponse _ = json.NewDecoder(resp.Body).Decode(&result) if assert.Len(t, result.Items, 2) { assert.Equal(t, "evt-allowed", result.Items[0].EventID) assert.Equal(t, "evt-allowed-path", result.Items[1].EventID) } mockKeto.AssertExpectations(t) } func TestListConsents_UserAllowedByRPAdminsRelation(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "App One", "metadata": map[string]any{ "tenant_id": "tenant-1", "status": "active", }, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_consents").Return(true, nil) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, ConsentRepo: &mockConsentRepo{ consents: []domain.ClientConsent{ { ClientID: "client-1", Subject: "subject-1", GrantedScopes: []string{"openid", "profile"}, CreatedAt: time.Now().UTC(), }, }, }, Keto: mockKeto, } app := fiber.New() tenantID := "tenant-1" app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-1", Role: domain.RoleUser, TenantID: &tenantID, }) return c.Next() }) app.Get("/api/v1/dev/consents", h.ListConsents) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/consents?client_id=client-1", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var result consentListResponse _ = json.NewDecoder(resp.Body).Decode(&result) if assert.Len(t, result.Items, 1) { assert.Equal(t, "client-1", result.Items[0].ClientID) assert.Equal(t, "subject-1", result.Items[0].Subject) } mockKeto.AssertExpectations(t) } func TestListConsents_IncludesRPUserMetadata(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "App One", "metadata": map[string]any{ "tenant_id": "tenant-1", "status": "active", }, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) repo := new(devMockRPUserMetadataRepo) repo.On("Get", mock.Anything, "client-1", "subject-1").Return(&domain.RPUserMetadata{ ClientID: "client-1", UserID: "subject-1", Metadata: domain.JSONMap{ "approvalLevel": "A", "reviewedAt": "2026-06-09T09:30:00+09:00", }, }, nil).Once() h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, ConsentRepo: &mockConsentRepo{ consents: []domain.ClientConsent{ { ClientID: "client-1", Subject: "subject-1", GrantedScopes: []string{"openid", "profile"}, CreatedAt: time.Now().UTC(), }, }, }, RPUserMetadataRepo: repo, } app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin", Role: domain.RoleSuperAdmin}) return c.Next() }) app.Get("/api/v1/dev/consents", h.ListConsents) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/consents?client_id=client-1", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var result consentListResponse _ = json.NewDecoder(resp.Body).Decode(&result) if assert.Len(t, result.Items, 1) { assert.Equal(t, domain.JSONMap{ "approvalLevel": "A", "reviewedAt": "2026-06-09T09:30:00+09:00", }, result.Items[0].RPMetadata) } repo.AssertExpectations(t) } func TestNormalizeIDTokenClaimsMetadata_AllowsDateAndDatetime(t *testing.T) { metadata, err := normalizeIDTokenClaimsMetadata(map[string]any{ domain.MetadataIDTokenClaims: []any{ map[string]any{ "namespace": "rp_claims", "key": "contract_date", "value": "2026-06-09", "valueType": "date", }, map[string]any{ "namespace": "rp_claims", "key": "approved_at", "value": "2026-06-09T09:30:00+09:00", "valueType": "datetime", }, }, }) assert.NoError(t, err) claims := metadata[domain.MetadataIDTokenClaims].([]normalizedIDTokenClaim) assert.Equal(t, "date", claims[0].ValueType) assert.Equal(t, "datetime", claims[1].ValueType) } func TestNormalizeIDTokenClaimsMetadata_PreservesNullableDefaultValue(t *testing.T) { metadata, err := normalizeIDTokenClaimsMetadata(map[string]any{ domain.MetadataIDTokenClaims: []any{ map[string]any{ "namespace": "rp_claims", "key": "contract_date", "value": "", "valueType": "date", "nullable": true, "readPermission": "user_and_admin", "writePermission": "user_and_admin", }, }, }) assert.NoError(t, err) claims := metadata[domain.MetadataIDTokenClaims].([]normalizedIDTokenClaim) if assert.Len(t, claims, 1) { assert.Equal(t, "contract_date", claims[0].Key) assert.Equal(t, "date", claims[0].ValueType) assert.True(t, claims[0].Nullable) assert.Equal(t, "user_and_admin", claims[0].ReadPermission) assert.Equal(t, "user_and_admin", claims[0].WritePermission) } } func TestUpdateClient_RejectsTopLevelIDTokenClaimsFromDevConsole(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "App One", "redirect_uris": []string{"http://localhost/cb"}, "grant_types": []string{"authorization_code"}, "response_types": []string{"code"}, "scope": "openid profile", "token_endpoint_auth_method": "none", "metadata": map[string]any{"status": "active"}, }), nil } if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" { t.Fatalf("hydra update should not be called for top-level id token claims") } return httpJSONAny(r, http.StatusNotFound, nil), nil }) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, } app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin", Role: domain.RoleSuperAdmin}) return c.Next() }) app.Put("/api/v1/dev/clients/:id", h.UpdateClient) body, _ := json.Marshal(map[string]any{ "metadata": map[string]any{ domain.MetadataIDTokenClaims: []any{ map[string]any{ "namespace": "top_level", "key": "employee_id", "value": "EMP001", "valueType": "text", }, }, }, }) req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusBadRequest, resp.StatusCode) } func TestListClientRelations_RPAdminAllowedByViewRelationshipsPermission(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "App One", "metadata": map[string]any{ "tenant_id": "tenant-1", "status": "active", }, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_relationships").Return(true, nil) mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "config_editor", "").Return([]service.RelationTuple{ {Object: "client-1", Relation: "config_editor", SubjectID: "User:user-2"}, }, nil) for _, relation := range []string{"admins", "creator", "secret_viewer", "secret_rotator", "jwks_viewer", "jwks_operator", "consent_viewer", "consent_revoker", "relationship_viewer", "audit_viewer", "status_operator"} { mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", relation, "").Return([]service.RelationTuple{}, nil) } mockKratos := new(devMockKratosAdmin) mockKratos.On("GetIdentity", mock.Anything, "user-2").Return(&service.KratosIdentity{ ID: "user-2", Traits: map[string]any{ "name": "김용연", "email": "kyy@example.com", "id": "kyy01", }, }, nil) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, Keto: mockKeto, KratosAdmin: mockKratos, } app := fiber.New() app.Use(func(c *fiber.Ctx) error { tenantID := "tenant-1" c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-1", Role: domain.RoleUser, TenantID: &tenantID, }) return c.Next() }) app.Get("/api/v1/dev/clients/:id/relations", h.ListClientRelations) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-1/relations", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var result clientRelationListResponse _ = json.NewDecoder(resp.Body).Decode(&result) assert.Len(t, result.Items, 1) assert.Equal(t, "config_editor", result.Items[0].Relation) assert.Equal(t, "User", result.Items[0].SubjectType) assert.Equal(t, "user-2", result.Items[0].SubjectID) assert.Equal(t, "김용연", result.Items[0].UserName) assert.Equal(t, "kyy@example.com", result.Items[0].UserEmail) assert.Equal(t, "kyy01", result.Items[0].UserLoginID) } func TestListClientRelations_DedupesDuplicateRelations(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "App One", "metadata": map[string]any{ "tenant_id": "tenant-1", "status": "active", }, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_relationships").Return(true, nil) mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "admins", "").Return([]service.RelationTuple{ {Namespace: "RelyingParty", Object: "client-1", Relation: "admins", SubjectID: "User:user-1"}, {Namespace: "RelyingParty", Object: "client-1", Relation: "admins", SubjectID: "User:user-1"}, }, nil) for _, relation := range []string{"creator", "config_editor", "secret_viewer", "secret_rotator", "jwks_viewer", "jwks_operator", "consent_viewer", "consent_revoker", "relationship_viewer", "audit_viewer", "status_operator"} { mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", relation, "").Return([]service.RelationTuple{}, nil) } mockKratos := new(devMockKratosAdmin) mockKratos.On("GetIdentity", mock.Anything, "user-1").Return(&service.KratosIdentity{ ID: "user-1", Traits: map[string]any{ "name": "Tester", "email": "tester@example.com", }, }, nil).Maybe() h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, Keto: mockKeto, KratosAdmin: mockKratos, } app := fiber.New() app.Use(func(c *fiber.Ctx) error { tenantID := "tenant-1" c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-1", Role: domain.RoleUser, TenantID: &tenantID, }) return c.Next() }) app.Get("/api/v1/dev/clients/:id/relations", h.ListClientRelations) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-1/relations", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var result clientRelationListResponse _ = json.NewDecoder(resp.Body).Decode(&result) if assert.Len(t, result.Items, 1) { assert.Equal(t, "admins", result.Items[0].Relation) assert.Equal(t, "User:user-1", result.Items[0].Subject) } mockKeto.AssertExpectations(t) mockKratos.AssertExpectations(t) } func TestAddClientRelation_RPAdminAllowedByTenantGrantPermission(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "App One", "metadata": map[string]any{ "tenant_id": "tenant-1", "status": "active", }, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "manage").Return(false, nil) mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-1", "grant_dev_permissions").Return(true, nil) mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "config_editor", "User:user-2").Return([]service.RelationTuple{}, nil) mockOutbox := new(devMockKetoOutboxRepository) mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool { return entry.Namespace == "RelyingParty" && entry.Object == "client-1" && entry.Relation == "config_editor" && entry.Subject == "User:user-2" && entry.Action == domain.KetoOutboxActionCreate })).Return(nil) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, Keto: mockKeto, KetoOutbox: mockOutbox, } app := fiber.New() app.Use(func(c *fiber.Ctx) error { tenantID := "tenant-1" c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-1", Role: domain.RoleUser, TenantID: &tenantID, }) return c.Next() }) app.Post("/api/v1/dev/clients/:id/relations", h.AddClientRelation) body, _ := json.Marshal(map[string]any{ "relation": "config_editor", "userId": "user-2", }) req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients/client-1/relations", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusCreated, resp.StatusCode) mockOutbox.AssertExpectations(t) } func TestRemoveClientRelation_RPAdminAllowedByManagePermission(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "App One", "metadata": map[string]any{ "tenant_id": "tenant-1", "status": "active", }, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "manage").Return(true, nil) mockOutbox := new(devMockKetoOutboxRepository) mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool { return entry.Namespace == "RelyingParty" && entry.Object == "client-1" && entry.Relation == "config_editor" && entry.Subject == "User:user-2" && entry.Action == domain.KetoOutboxActionDelete })).Return(nil) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, Keto: mockKeto, KetoOutbox: mockOutbox, } app := fiber.New() app.Use(func(c *fiber.Ctx) error { tenantID := "tenant-1" c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-1", Role: domain.RoleUser, TenantID: &tenantID, }) return c.Next() }) app.Delete("/api/v1/dev/clients/:id/relations", h.RemoveClientRelation) req := httptest.NewRequest(http.MethodDelete, "/api/v1/dev/clients/client-1/relations?relation=config_editor&subject=User:user-2", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusNoContent, resp.StatusCode) mockOutbox.AssertExpectations(t) } func TestSearchUsers_RPAdminSearchByNameOrEmailWithinTenantScope(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "App One", "metadata": map[string]any{ "tenant_id": "tenant-1", "status": "active", }, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) mockKratos := new(devMockKratosAdmin) mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{ { ID: "user-1", Traits: map[string]any{ "name": "Alice Kim", "email": "alice@example.com", "id": "alice01", "tenant_id": "tenant-1", }, }, { ID: "user-2", Traits: map[string]any{ "name": "Bob Lee", "email": "bob@example.com", "id": "bob01", "tenant_id": "tenant-2", }, }, }, nil) mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, "User:user-9", "RelyingParty", "client-1", "manage").Return(true, nil).Maybe() h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, KratosAdmin: mockKratos, Keto: mockKeto, } app := fiber.New() app.Use(func(c *fiber.Ctx) error { tenantID := "tenant-1" c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-9", Role: domain.RoleUser, TenantID: &tenantID, ManageableTenants: []domain.Tenant{ {ID: "tenant-1", Slug: "tenant-one"}, }, Metadata: map[string]any{ "managed_client_ids": []any{"client-1"}, }, }) return c.Next() }) app.Get("/api/v1/dev/users", h.SearchUsers) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/users?clientId=client-1&search=alice", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var result devUserListResponse _ = json.NewDecoder(resp.Body).Decode(&result) if assert.Len(t, result.Items, 1) { assert.Equal(t, "user-1", result.Items[0].ID) assert.Equal(t, "Alice Kim", result.Items[0].Name) assert.Equal(t, "alice@example.com", result.Items[0].Email) } mockKratos.AssertExpectations(t) } func TestSearchUsers_UserAllowedByRPAdminRelation(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "App One", "metadata": map[string]any{ "tenant_id": "tenant-1", "status": "active", }, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "manage").Return(true, nil) mockKratos := new(devMockKratosAdmin) mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{ { ID: "target-user", Traits: map[string]any{ "name": "김용연", "email": "kyy@example.com", "id": "kyy01", "tenant_id": "tenant-1", }, }, }, nil) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, Keto: mockKeto, KratosAdmin: mockKratos, } app := fiber.New() tenantID := "tenant-1" app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-1", Role: domain.RoleUser, TenantID: &tenantID, }) return c.Next() }) app.Get("/api/v1/dev/users", h.SearchUsers) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/users?clientId=client-1&search=김용연", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var result devUserListResponse _ = json.NewDecoder(resp.Body).Decode(&result) if assert.Len(t, result.Items, 1) { assert.Equal(t, "target-user", result.Items[0].ID) assert.Equal(t, "김용연", result.Items[0].Name) } mockKeto.AssertExpectations(t) mockKratos.AssertExpectations(t) }