package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/gofiber/fiber/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) type devMockRPUserMetadataRepo struct { mock.Mock } func (m *devMockRPUserMetadataRepo) Get(ctx context.Context, clientID, userID string) (*domain.RPUserMetadata, error) { args := m.Called(ctx, clientID, userID) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*domain.RPUserMetadata), args.Error(1) } func (m *devMockRPUserMetadataRepo) Upsert(ctx context.Context, metadata *domain.RPUserMetadata) error { args := m.Called(ctx, metadata) return args.Error(0) } func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "Client One", "metadata": map[string]any{ "tenant_id": "tenant-1", "id_token_claims": []map[string]any{ { "namespace": "rp_claims", "key": "approvalLevel", "valueType": "text", "value": "A", }, { "namespace": "rp_claims", "key": "activeMember", "valueType": "boolean", "value": "true", }, { "namespace": "rp_claims", "key": "score", "valueType": "number", "value": "1", }, }, }, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) repo := new(devMockRPUserMetadataRepo) repo.On("Upsert", mock.Anything, mock.MatchedBy(func(row *domain.RPUserMetadata) bool { return row.ClientID == "client-1" && row.UserID == "user-1" && row.Metadata["approvalLevel"] == "A" && row.Metadata["activeMember"] == false && row.Metadata["score"] == float64(42) && row.Metadata["approvalLevel_permissions"].(map[string]any)["readPermission"] == "admin_only" && row.Metadata["approvalLevel_permissions"].(map[string]any)["writePermission"] == "user_and_admin" })).Return(nil).Once() repo.On("Get", mock.Anything, "client-1", "user-1").Return(&domain.RPUserMetadata{ ClientID: "client-1", UserID: "user-1", Metadata: domain.JSONMap{"approvalLevel": "A"}, }, nil).Once() h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, 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.Put("/api/v1/dev/clients/:id/users/:userId/metadata", h.UpsertRPUserMetadata) app.Get("/api/v1/dev/clients/:id/users/:userId/metadata", h.GetRPUserMetadata) body, _ := json.Marshal(map[string]any{ "metadata": map[string]any{ "approvalLevel": "A", "activeMember": false, "score": 42, "approvalLevel_permissions": map[string]any{ "writePermission": "user_and_admin", }, }, }) putReq := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1/users/user-1/metadata", bytes.NewReader(body)) putReq.Header.Set("Content-Type", "application/json") putResp, _ := app.Test(putReq, -1) assert.Equal(t, http.StatusOK, putResp.StatusCode) getReq := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-1/users/user-1/metadata", nil) getResp, _ := app.Test(getReq, -1) assert.Equal(t, http.StatusOK, getResp.StatusCode) var got map[string]any assert.NoError(t, json.NewDecoder(getResp.Body).Decode(&got)) assert.Equal(t, "client-1", got["clientId"]) assert.Equal(t, "user-1", got["userId"]) assert.Equal(t, "A", got["metadata"].(map[string]any)["approvalLevel"]) repo.AssertExpectations(t) } func TestDevHandler_RPUserMetadataAdminUpsertRequiresRPManage(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "Client One", "metadata": map[string]any{ "tenant_id": "tenant-1", "id_token_claims": []map[string]any{ { "namespace": "rp_claims", "key": "approvalLevel", "valueType": "text", "value": "A", "readPermission": "user_and_admin", "writePermission": "user_and_admin", }, }, }, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) t.Run("tenant grant does not allow rp user metadata admin upsert", func(t *testing.T) { repo := new(devMockRPUserMetadataRepo) repo.On("Upsert", mock.Anything, mock.AnythingOfType("*domain.RPUserMetadata")).Return(nil).Maybe() keto := new(devMockKetoService) keto.On("CheckPermission", mock.Anything, "User:operator-1", "RelyingParty", "client-1", "manage").Return(false, nil) keto.On("CheckPermission", mock.Anything, "User:operator-1", "Tenant", "tenant-1", "grant_dev_permissions").Return(true, nil).Maybe() h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, Keto: keto, RPUserMetadataRepo: repo, } app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ID: "operator-1", Role: domain.RoleUser}) return c.Next() }) app.Put("/api/v1/dev/clients/:id/users/:userId/metadata", h.UpsertRPUserMetadata) body, _ := json.Marshal(map[string]any{ "metadata": map[string]any{"approvalLevel": "B"}, }) req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1/users/user-1/metadata", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) require.Equal(t, http.StatusForbidden, resp.StatusCode) repo.AssertNotCalled(t, "Upsert", mock.Anything, mock.Anything) keto.AssertExpectations(t) }) t.Run("rp manage allows rp user metadata admin upsert", func(t *testing.T) { repo := new(devMockRPUserMetadataRepo) repo.On("Upsert", mock.Anything, mock.MatchedBy(func(row *domain.RPUserMetadata) bool { return row.ClientID == "client-1" && row.UserID == "user-1" && row.Metadata["approvalLevel"] == "B" })).Return(nil).Once() keto := new(devMockKetoService) keto.On("CheckPermission", mock.Anything, "User:operator-1", "RelyingParty", "client-1", "manage").Return(true, nil) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, Keto: keto, RPUserMetadataRepo: repo, } app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ID: "operator-1", Role: domain.RoleUser}) return c.Next() }) app.Put("/api/v1/dev/clients/:id/users/:userId/metadata", h.UpsertRPUserMetadata) body, _ := json.Marshal(map[string]any{ "metadata": map[string]any{"approvalLevel": "B"}, }) req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1/users/user-1/metadata", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) require.Equal(t, http.StatusOK, resp.StatusCode) repo.AssertExpectations(t) keto.AssertExpectations(t) }) } func TestDevHandler_RPUserMetadataMirrorsToKratosTraits(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "Client One", "metadata": map[string]any{ "tenant_id": "tenant-1", "id_token_claims": []map[string]any{ { "namespace": "rp_claims", "key": "approvalLevel", "valueType": "text", "value": "A", "readPermission": "user_and_admin", "writePermission": "admin_only", }, }, }, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) repo := new(devMockRPUserMetadataRepo) repo.On("Upsert", mock.Anything, mock.AnythingOfType("*domain.RPUserMetadata")).Return(nil).Once() kratos := new(MockKratosAdmin) kratos.On("GetIdentity", mock.Anything, "user-1").Return(&service.KratosIdentity{ ID: "user-1", State: "active", Traits: map[string]any{ "email": "user@example.com", "name": "User One", }, }, nil).Once() var capturedTraits map[string]any kratos.On("UpdateIdentity", mock.Anything, "user-1", mock.Anything, "active").Run(func(args mock.Arguments) { capturedTraits = args.Get(2).(map[string]any) }).Return(&service.KratosIdentity{ID: "user-1", State: "active", Traits: map[string]any{}}, nil).Once() identityWriter := service.NewIdentityWriteService(kratos, nil) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, KratosAdmin: kratos, IdentityWriter: identityWriter, 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.Put("/api/v1/dev/clients/:id/users/:userId/metadata", h.UpsertRPUserMetadata) body, _ := json.Marshal(map[string]any{ "metadata": map[string]any{"approvalLevel": "B"}, }) req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1/users/user-1/metadata", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) require.Equal(t, http.StatusOK, resp.StatusCode) rpClaims := capturedTraits["rp_custom_claims"].(map[string]any) clientClaims := rpClaims["client-1"].(domain.JSONMap) require.Equal(t, "B", clientClaims["approvalLevel"]) require.Equal(t, map[string]any{ "readPermission": "user_and_admin", "writePermission": "admin_only", }, clientClaims["approvalLevel_permissions"]) repo.AssertExpectations(t) kratos.AssertExpectations(t) } func TestDevHandler_SelfUpdateRPUserMetadataHonorsWritePermission(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "Client One", "metadata": map[string]any{ "tenant_id": "tenant-1", "id_token_claims": []map[string]any{ { "namespace": "rp_claims", "key": "approvalLevel", "valueType": "text", "value": "A", "readPermission": "user_and_admin", "writePermission": "user_and_admin", }, { "namespace": "rp_claims", "key": "internalRank", "valueType": "text", "value": "S", "readPermission": "admin_only", "writePermission": "admin_only", }, }, }, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) t.Run("rejects admin_only claim", func(t *testing.T) { repo := new(devMockRPUserMetadataRepo) repo.On("Upsert", mock.Anything, mock.AnythingOfType("*domain.RPUserMetadata")).Return(nil).Maybe() h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, RPUserMetadataRepo: repo, } app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleUser}) return c.Next() }) app.Put("/api/v1/dev/clients/:id/users/me/metadata", h.SelfUpdateRPUserMetadata) body, _ := json.Marshal(map[string]any{ "metadata": map[string]any{"internalRank": "A"}, }) req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1/users/me/metadata", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) require.Equal(t, http.StatusForbidden, resp.StatusCode) repo.AssertNotCalled(t, "Upsert", mock.Anything, mock.Anything) }) t.Run("allows user_and_admin claim for self", func(t *testing.T) { repo := new(devMockRPUserMetadataRepo) repo.On("Get", mock.Anything, "client-1", "user-1").Return(&domain.RPUserMetadata{ ClientID: "client-1", UserID: "user-1", Metadata: domain.JSONMap{ "internalRank": "S", "internalRank_permissions": map[string]any{ "readPermission": "admin_only", "writePermission": "admin_only", }, }, }, nil).Once() repo.On("Upsert", mock.Anything, mock.MatchedBy(func(row *domain.RPUserMetadata) bool { return row.ClientID == "client-1" && row.UserID == "user-1" && row.Metadata["approvalLevel"] == "B" && row.Metadata["internalRank"] == "S" })).Return(nil).Once() kratos := new(MockKratosAdmin) kratos.On("GetIdentity", mock.Anything, "user-1").Return(&service.KratosIdentity{ ID: "user-1", State: "active", Traits: map[string]any{ "email": "user@example.com", }, }, nil).Once() var capturedTraits map[string]any kratos.On("UpdateIdentity", mock.Anything, "user-1", mock.Anything, "active").Run(func(args mock.Arguments) { capturedTraits = args.Get(2).(map[string]any) }).Return(&service.KratosIdentity{ID: "user-1", State: "active", Traits: map[string]any{}}, nil).Once() h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, KratosAdmin: kratos, IdentityWriter: service.NewIdentityWriteService(kratos, nil), RPUserMetadataRepo: repo, } app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleUser}) return c.Next() }) app.Put("/api/v1/dev/clients/:id/users/me/metadata", h.SelfUpdateRPUserMetadata) body, _ := json.Marshal(map[string]any{ "metadata": map[string]any{"approvalLevel": "B"}, }) req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1/users/me/metadata", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) require.Equal(t, http.StatusOK, resp.StatusCode) rpClaims := capturedTraits["rp_custom_claims"].(map[string]any) clientClaims := rpClaims["client-1"].(domain.JSONMap) require.Equal(t, "B", clientClaims["approvalLevel"]) require.Equal(t, "S", clientClaims["internalRank"]) repo.AssertExpectations(t) kratos.AssertExpectations(t) }) } func TestDevHandler_RPUserMetadataRejectsUndefinedClaimKey(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "Client One", "metadata": map[string]any{ "id_token_claims": []map[string]any{ { "namespace": "rp_claims", "key": "contract_date", "valueType": "date", "value": "2026-06-09", }, }, }, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) repo := new(devMockRPUserMetadataRepo) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, 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.Put("/api/v1/dev/clients/:id/users/:userId/metadata", h.UpsertRPUserMetadata) body, _ := json.Marshal(map[string]any{ "metadata": map[string]any{"unknown_claim": "A"}, }) req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1/users/user-1/metadata", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusBadRequest, resp.StatusCode) repo.AssertNotCalled(t, "Upsert", mock.Anything, mock.Anything) } func TestDevHandler_RPUserMetadataRejectsInvalidTypedClaimValue(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/clients/client-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "client-1", "client_name": "Client One", "metadata": map[string]any{ "id_token_claims": []map[string]any{ { "namespace": "rp_claims", "key": "contract_date", "valueType": "date", "value": "2026-06-09", }, }, }, }), nil } return httpJSONAny(r, http.StatusNotFound, nil), nil }) repo := new(devMockRPUserMetadataRepo) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, 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.Put("/api/v1/dev/clients/:id/users/:userId/metadata", h.UpsertRPUserMetadata) body, _ := json.Marshal(map[string]any{ "metadata": map[string]any{"contract_date": "2026/06/09"}, }) req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1/users/user-1/metadata", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusBadRequest, resp.StatusCode) repo.AssertNotCalled(t, "Upsert", mock.Anything, mock.Anything) }