1
0
forked from baron/baron-sso
Files
baron-sso/backend/internal/handler/rp_claims_e2e_test.go

329 lines
11 KiB
Go

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", "2026-06-09", "user_and_admin", "user_and_admin"),
rpClaimsE2EClaim("approvedAt", "datetime", "2026-06-09T09:30", "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", initialA["approvalLevel"])
assert.Equal(t, true, initialA["activeMember"])
assert.Equal(t, float64(1), initialA["score"])
assert.Equal(t, []any{"default"}, initialA["featureList"])
assert.Equal(t, map[string]any{"theme": "light", "density": "comfortable"}, initialA["preferences"])
assert.Equal(t, "2026-06-09", initialA["contractDate"])
assert.Equal(t, "2026-06-09T09:30", 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": "2026-06-10",
"approvedAt": "2026-06-09T10:30",
"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", updatedA["approvalLevel"])
assert.Equal(t, false, updatedA["activeMember"])
assert.Equal(t, float64(42), updatedA["score"])
assert.Equal(t, []any{"sso", "claims"}, updatedA["featureList"])
assert.Equal(t, map[string]any{"theme": "dark", "density": "compact"}, updatedA["preferences"])
assert.Equal(t, "2026-06-10", updatedA["contractDate"])
assert.Equal(t, "2026-06-09T10:30", updatedA["approvedAt"])
assert.Equal(t, "admin-updated", 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", selfUpdatedA["approvalLevel"])
assert.Equal(t, "admin-updated", selfUpdatedA["adminManagedNote"])
defaultB := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-b-default")
assert.Equal(t, "B-default", defaultB["approvalLevel"])
assert.Equal(t, false, 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", updatedB["approvalLevel"])
assert.Equal(t, true, updatedB["activeMember"])
assert.NotEqual(t, selfUpdatedA["approvalLevel"], 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, valueType, value, readPermission, 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 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
}