package handler import ( "baron-sso-backend/internal/domain" "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) { 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 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]interface{}, 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 } 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) 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 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]interface{}{ {"client_id": "client-1", "client_name": "App One", "metadata": map[string]interface{}{"status": "active"}}, {"client_id": "client-2", "client_name": "App Two", "metadata": map[string]interface{}{"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(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 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 }) 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: "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 }) 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: "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 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]interface{}{ {"client_id": "oathkeeper-introspect", "client_name": "Internal Client"}, {"client_id": "client-1", "client_name": "App One", "metadata": map[string]interface{}{"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(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]interface{}{ {"client_id": "adminfront", "client_name": "AdminFront", "metadata": map[string]interface{}{"status": "active"}}, {"client_id": "4f2c9fd6-1111-2222-3333-444444444444", "client_name": "AdminFront", "metadata": map[string]interface{}{"status": "active"}}, {"client_id": "devfront", "client_name": "DevFront", "metadata": map[string]interface{}{"status": "active"}}, {"client_id": "7d2c9fd6-1111-2222-3333-444444444444", "client_name": "DevFront", "metadata": map[string]interface{}{"status": "active"}}, {"client_id": "client-1", "client_name": "App One", "metadata": map[string]interface{}{"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(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]interface{}{ "client_id": "4f2c9fd6-1111-2222-3333-444444444444", "client_name": "AdminFront", "metadata": map[string]interface{}{"status": "active"}, }), 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: "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]interface{}{ "client_id": "client-1", "metadata": map[string]interface{}{"status": "active"}, }), nil } if r.Method == http.MethodPatch && r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]interface{}{ "client_id": "client-1", "metadata": map[string]interface{}{"status": "inactive"}, }), 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]interface{}{"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_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]interface{}{ "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]interface{}{"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]interface{}{"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]interface{}{"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]interface{}{ "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]interface{}{ "client_id": "client-1", "client_name": "App One", "metadata": map[string]interface{}{ "tenant_id": "tenant-b", "status": "active", }, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, "User:rp-1", "Tenant", "tenant-b", "view_dev_console").Return(false, nil) 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.RoleRPAdmin, 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 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]interface{}{"client_id": "client-1"}), nil } if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" { var body map[string]interface{} 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]interface{} _ = 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: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.RoleRPAdmin, 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 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]interface{}{ {"client_id": "c1", "metadata": map[string]interface{}{"tenant_id": "t1"}}, {"client_id": "c2", "metadata": map[string]interface{}{"tenant_id": "t1"}}, {"client_id": "oathkeeper-introspect", "metadata": map[string]interface{}{"tenant_id": "t1"}}, {"client_id": "c3", "metadata": map[string]interface{}{"tenant_id": "t2"}}, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) auditRepo := &devEnhancedMockAuditRepo{ countFailures: 7, countSessions: 3, } mockKeto := new(devMockKetoService) 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.RoleTenantAdmin, 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(2), res.TotalClients) assert.Equal(t, int64(7), res.AuthFailures) assert.Equal(t, int64(3), res.ActiveSessions) mockKeto.AssertNotCalled(t, "CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all") } 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": "pkce", "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, "none", captured.TokenEndpointAuthMethod) 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_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 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": "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.Equal(t, "none", captured.TokenEndpointAuthMethod) assert.Equal(t, "", 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_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, "", 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 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_TenantMemberForbidden(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.StatusForbidden, resp.StatusCode) } 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.RoleRPAdmin, 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 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, "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_rotator", "jwks_viewer", "jwks_operator", "consent_viewer", "consent_revoker", "relationship_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]interface{}{ "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.RoleRPAdmin, 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 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, "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.RoleRPAdmin, 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, "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.RoleRPAdmin, 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) { mockKratos := new(devMockKratosAdmin) mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{ { ID: "user-1", Traits: map[string]interface{}{ "name": "Alice Kim", "email": "alice@example.com", "id": "alice01", "tenant_id": "tenant-1", }, }, { ID: "user-2", Traits: map[string]interface{}{ "name": "Bob Lee", "email": "bob@example.com", "id": "bob01", "tenant_id": "tenant-2", }, }, }, nil) h := &DevHandler{ KratosAdmin: mockKratos, } app := fiber.New() app.Use(func(c *fiber.Ctx) error { tenantID := "tenant-1" c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-9", Role: domain.RoleRPAdmin, TenantID: &tenantID, ManageableTenants: []domain.Tenant{ {ID: "tenant-1", Slug: "tenant-one"}, }, }) return c.Next() }) app.Get("/api/v1/dev/users", h.SearchUsers) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/users?search=alice", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var result devUserListResponse _ = json.NewDecoder(resp.Body).Decode(&result) 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) }