package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "sync" "testing" "github.com/gofiber/fiber/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) type rpClaimsE2ERepo struct { mu sync.Mutex rows map[string]*domain.RPUserMetadata getKeys []string } func newRPClaimsE2ERepo() *rpClaimsE2ERepo { return &rpClaimsE2ERepo{rows: map[string]*domain.RPUserMetadata{}} } func (r *rpClaimsE2ERepo) Get(ctx context.Context, clientID, userID string) (*domain.RPUserMetadata, error) { r.mu.Lock() defer r.mu.Unlock() key := rpClaimsE2ERepoKey(clientID, userID) r.getKeys = append(r.getKeys, key) row, ok := r.rows[key] if !ok { return nil, fmt.Errorf("rp user metadata not found") } return cloneRPUserMetadata(row), nil } func (r *rpClaimsE2ERepo) Upsert(ctx context.Context, metadata *domain.RPUserMetadata) error { r.mu.Lock() defer r.mu.Unlock() r.rows[rpClaimsE2ERepoKey(metadata.ClientID, metadata.UserID)] = cloneRPUserMetadata(metadata) return nil } func (r *rpClaimsE2ERepo) seenGet(clientID, userID string) bool { r.mu.Lock() defer r.mu.Unlock() key := rpClaimsE2ERepoKey(clientID, userID) for _, seen := range r.getKeys { if seen == key { return true } } return false } func rpClaimsE2ERepoKey(clientID, userID string) string { return strings.TrimSpace(clientID) + "\x00" + strings.TrimSpace(userID) } func cloneRPUserMetadata(row *domain.RPUserMetadata) *domain.RPUserMetadata { if row == nil { return nil } cloned := &domain.RPUserMetadata{ ClientID: row.ClientID, UserID: row.UserID, Metadata: domain.JSONMap{}, } for key, value := range row.Metadata { cloned.Metadata[key] = value } return cloned } func TestRPClaimsE2E_UpdatedClaimsAreScopedToCurrentRP(t *testing.T) { const userID = "user-rp-e2e" const clientA = "client-rp-a" const clientB = "client-rp-b" clients := map[string]map[string]any{ clientA: rpClaimsE2EClient(clientA, []map[string]any{ rpClaimsE2EClaim("approvalLevel", "text", "A", "user_and_admin", "user_and_admin"), rpClaimsE2EClaim("activeMember", "boolean", "true", "user_and_admin", "user_and_admin"), rpClaimsE2EClaim("score", "number", "1", "user_and_admin", "user_and_admin"), rpClaimsE2EClaim("featureList", "array", `["default"]`, "user_and_admin", "user_and_admin"), rpClaimsE2EClaim("preferences", "object", `{"theme":"light","density":"comfortable"}`, "user_and_admin", "user_and_admin"), rpClaimsE2EClaim("contractDate", "date", float64(1780930800), "user_and_admin", "user_and_admin"), rpClaimsE2EClaim("approvedAt", "datetime", float64(1780965000), "user_and_admin", "user_and_admin"), rpClaimsE2EClaim("adminManagedNote", "text", "admin-default", "user_and_admin", "admin_only"), }), clientB: rpClaimsE2EClient(clientB, []map[string]any{ rpClaimsE2EClaim("approvalLevel", "text", "B-default", "user_and_admin", "user_and_admin"), rpClaimsE2EClaim("activeMember", "boolean", "false", "user_and_admin", "user_and_admin"), }), } challenges := map[string]string{ "challenge-client-a-default": clientA, "challenge-client-a-admin-update": clientA, "challenge-client-a-self-update": clientA, "challenge-client-b-default": clientB, "challenge-client-b-update": clientB, } capturedClaims := map[string]map[string]any{} hydraClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { if strings.HasPrefix(r.URL.Path, "/clients/") { clientID := strings.TrimPrefix(r.URL.Path, "/clients/") client, ok := clients[clientID] if !ok { return httpJSONAny(r, http.StatusNotFound, nil), nil } return httpJSONAny(r, http.StatusOK, client), nil } if r.URL.Path == "/oauth2/auth/requests/consent" { challenge := r.URL.Query().Get("consent_challenge") clientID, ok := challenges[challenge] if !ok { return httpResponse(r, http.StatusNotFound, "not found"), nil } return httpJSONAny(r, http.StatusOK, map[string]any{ "challenge": challenge, "requested_scope": []string{"openid", "profile"}, "subject": userID, "client": clients[clientID], }), nil } if r.URL.Path == "/oauth2/auth/requests/consent/accept" { challenge := r.URL.Query().Get("consent_challenge") body, _ := io.ReadAll(r.Body) var acceptReq map[string]any _ = json.Unmarshal(body, &acceptReq) session, _ := acceptReq["session"].(map[string]any) idToken, _ := session["id_token"].(map[string]any) capturedClaims[challenge] = idToken return httpJSONAny(r, http.StatusOK, map[string]any{ "redirect_to": "http://rp/cb", }), nil } return httpResponse(r, http.StatusNotFound, "not found"), nil })} repo := newRPClaimsE2ERepo() kratos := new(MockKratosAdminService) kratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{ ID: userID, State: "active", Traits: map[string]any{ "email": "rp-e2e@example.com", "name": "RP E2E User", }, }, nil) authHandler := &AuthHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: hydraClient, }, KratosAdmin: kratos, RPUserMetadataRepo: repo, } devHandler := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: hydraClient, }, RPUserMetadataRepo: repo, } app := fiber.New() app.Put("/api/v1/dev/clients/:id/users/me/metadata", func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ID: userID, Role: domain.RoleUser}) return devHandler.SelfUpdateRPUserMetadata(c) }) app.Put("/api/v1/dev/clients/:id/users/:userId/metadata", func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin", Role: domain.RoleSuperAdmin}) return devHandler.UpsertRPUserMetadata(c) }) app.Post("/api/v1/auth/consent/accept", authHandler.AcceptConsentRequest) initialA := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-a-default") assert.Equal(t, "A", rpClaimValue(t, initialA, "approvalLevel")) assert.Equal(t, "user_and_admin", rpClaimPermission(t, initialA, "approvalLevel", "readPermission")) assert.Equal(t, true, rpClaimValue(t, initialA, "activeMember")) assert.Equal(t, float64(1), rpClaimValue(t, initialA, "score")) assert.Equal(t, []any{"default"}, rpClaimValue(t, initialA, "featureList")) assert.Equal(t, map[string]any{"theme": "light", "density": "comfortable"}, rpClaimValue(t, initialA, "preferences")) assert.Equal(t, float64(1780930800), rpClaimValue(t, initialA, "contractDate")) assert.Equal(t, float64(1780965000), rpClaimValue(t, initialA, "approvedAt")) upsertRPClaimsE2EMetadata(t, app, clientA, userID, map[string]any{ "approvalLevel": "B", "activeMember": false, "score": 42, "featureList": []string{"sso", "claims"}, "preferences": map[string]any{"theme": "dark", "density": "compact"}, "contractDate": float64(1781017200), "approvedAt": float64(1780968600), "adminManagedNote": "admin-updated", "approvalLevel_permissions": map[string]any{ "writePermission": "user_and_admin", }, }) updatedA := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-a-admin-update") assert.Equal(t, "B", rpClaimValue(t, updatedA, "approvalLevel")) assert.Equal(t, false, rpClaimValue(t, updatedA, "activeMember")) assert.Equal(t, float64(42), rpClaimValue(t, updatedA, "score")) assert.Equal(t, []any{"sso", "claims"}, rpClaimValue(t, updatedA, "featureList")) assert.Equal(t, map[string]any{"theme": "dark", "density": "compact"}, rpClaimValue(t, updatedA, "preferences")) assert.Equal(t, float64(1781017200), rpClaimValue(t, updatedA, "contractDate")) assert.Equal(t, float64(1780968600), rpClaimValue(t, updatedA, "approvedAt")) assert.Equal(t, "admin-updated", rpClaimValue(t, updatedA, "adminManagedNote")) assert.NotContains(t, updatedA, "approvalLevel_permissions") assert.NotContains(t, updatedA, "adminManagedNote_permissions") rejectedSelfUpdate := putRPClaimsE2EMetadata(t, app, http.MethodPut, "/api/v1/dev/clients/"+clientA+"/users/me/metadata", map[string]any{ "metadata": map[string]any{ "adminManagedNote": "user-should-not-overwrite", }, }) assert.Equal(t, http.StatusForbidden, rejectedSelfUpdate.StatusCode) allowedSelfUpdate := putRPClaimsE2EMetadata(t, app, http.MethodPut, "/api/v1/dev/clients/"+clientA+"/users/me/metadata", map[string]any{ "metadata": map[string]any{ "approvalLevel": "C", }, }) assert.Equal(t, http.StatusOK, allowedSelfUpdate.StatusCode) selfUpdatedA := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-a-self-update") assert.Equal(t, "C", rpClaimValue(t, selfUpdatedA, "approvalLevel")) assert.Equal(t, "admin-updated", rpClaimValue(t, selfUpdatedA, "adminManagedNote")) defaultB := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-b-default") assert.Equal(t, "B-default", rpClaimValue(t, defaultB, "approvalLevel")) assert.Equal(t, false, rpClaimValue(t, defaultB, "activeMember")) assert.NotContains(t, defaultB, "score") assert.NotContains(t, defaultB, "featureList") assert.NotContains(t, defaultB, "adminManagedNote") upsertRPClaimsE2EMetadata(t, app, clientB, userID, map[string]any{ "approvalLevel": "B-rp-only", "activeMember": true, }) updatedB := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-b-update") assert.Equal(t, "B-rp-only", rpClaimValue(t, updatedB, "approvalLevel")) assert.Equal(t, true, rpClaimValue(t, updatedB, "activeMember")) assert.NotEqual(t, rpClaimValue(t, selfUpdatedA, "approvalLevel"), rpClaimValue(t, updatedB, "approvalLevel")) assert.NotContains(t, updatedB, "score") assert.NotContains(t, updatedB, "featureList") require.True(t, repo.seenGet(clientA, userID)) require.True(t, repo.seenGet(clientB, userID)) require.Contains(t, capturedClaims, "challenge-client-a-admin-update") require.Contains(t, capturedClaims, "challenge-client-b-update") kratos.AssertExpectations(t) } func rpClaimsE2EClient(clientID string, claims []map[string]any) map[string]any { return map[string]any{ "client_id": clientID, "client_name": clientID, "metadata": map[string]any{ "tenant_id": "tenant-rp-e2e", "id_token_claims": claims, }, } } func rpClaimsE2EClaim(key string, valueType string, value any, readPermission string, writePermission string) map[string]any { return map[string]any{ "namespace": "rp_claims", "key": key, "valueType": valueType, "value": value, "readPermission": readPermission, "writePermission": writePermission, } } func acceptRPClaimsE2EConsent(t *testing.T, app *fiber.App, capturedClaims map[string]map[string]any, challenge string) map[string]any { t.Helper() body, _ := json.Marshal(map[string]any{ "consent_challenge": challenge, "grant_scope": []string{"openid", "profile"}, }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req, -1) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) idToken, ok := capturedClaims[challenge] require.True(t, ok) rpClaims, ok := idToken["rp_claims"].(map[string]any) require.True(t, ok) return rpClaims } func rpClaimValue(t *testing.T, claims map[string]any, key string) any { t.Helper() payload, ok := claims[key].(map[string]any) require.Truef(t, ok, "rp_claims.%s must be an object payload", key) return payload["value"] } func rpClaimPermission(t *testing.T, claims map[string]any, key string, permissionKey string) string { t.Helper() payload, ok := claims[key].(map[string]any) require.Truef(t, ok, "rp_claims.%s must be an object payload", key) value, ok := payload[permissionKey].(string) require.Truef(t, ok, "rp_claims.%s.%s must be a string", key, permissionKey) return value } func upsertRPClaimsE2EMetadata(t *testing.T, app *fiber.App, clientID, userID string, metadata map[string]any) { t.Helper() resp := putRPClaimsE2EMetadata(t, app, http.MethodPut, "/api/v1/dev/clients/"+clientID+"/users/"+userID+"/metadata", map[string]any{ "metadata": metadata, }) require.Equal(t, http.StatusOK, resp.StatusCode) } func putRPClaimsE2EMetadata(t *testing.T, app *fiber.App, method, path string, body map[string]any) *http.Response { t.Helper() payload, _ := json.Marshal(body) req := httptest.NewRequest(method, path, bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req, -1) require.NoError(t, err) return resp }