diff --git a/apps/api/internal/auth/auth.go b/apps/api/internal/auth/auth.go index d4204ca..6b824dd 100644 --- a/apps/api/internal/auth/auth.go +++ b/apps/api/internal/auth/auth.go @@ -36,6 +36,10 @@ const ( type User struct { ID string `json:"sub"` Username string `json:"username"` + DisplayName string `json:"-"` + Email string `json:"-"` + Phone string `json:"-"` + AvatarURL string `json:"-"` Roles []string `json:"role,omitempty"` ContextType string `json:"contextType,omitempty"` TenantID string `json:"tenantId,omitempty"` diff --git a/apps/api/internal/auth/oidc.go b/apps/api/internal/auth/oidc.go index 348cb56..51ef253 100644 --- a/apps/api/internal/auth/oidc.go +++ b/apps/api/internal/auth/oidc.go @@ -252,18 +252,51 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) { return nil, oidcUnauthorized("TOKEN_INACTIVE", "token is inactive", err) } } - username := stringClaim(claims, "preferred_username") + username := oidcProfileText(claims, "preferred_username", 320) if username == "" { - username = stringClaim(claims, "username") + username = oidcProfileText(claims, "username", 320) } return &User{ - ID: stringClaim(claims, "sub"), Username: username, Roles: roles, + ID: stringClaim(claims, "sub"), Username: username, + DisplayName: oidcProfileText(claims, "name", 200), + Email: oidcVerifiedProfileText(claims, "email", "email_verified", 320), + Phone: oidcVerifiedProfileText(claims, "phone_number", "phone_number_verified", 64), + AvatarURL: safeOIDCProfileURL(oidcProfileText(claims, "picture", 2048)), + Roles: roles, ContextType: contextType, TenantID: tenantID, Source: "oidc", TokenExpiresAt: expiresAt, TokenIssuedAt: issuedAt, Issuer: v.config.Issuer, ApplicationID: v.config.ApplicationID, OIDCClientID: clientID, }, nil } +func oidcProfileText(claims jwt.MapClaims, key string, limit int) string { + value := strings.TrimSpace(stringClaim(claims, key)) + if value == "" { + return "" + } + runes := []rune(value) + if len(runes) > limit { + return string(runes[:limit]) + } + return value +} + +func oidcVerifiedProfileText(claims jwt.MapClaims, key, verifiedKey string, limit int) string { + verified, ok := claims[verifiedKey].(bool) + if !ok || !verified { + return "" + } + return oidcProfileText(claims, key, limit) +} + +func safeOIDCProfileURL(value string) string { + parsed, err := url.Parse(value) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil { + return "" + } + return parsed.String() +} + type oidcValidationError struct { category string reason string diff --git a/apps/api/internal/auth/oidc_test.go b/apps/api/internal/auth/oidc_test.go index 5c65954..9232c4a 100644 --- a/apps/api/internal/auth/oidc_test.go +++ b/apps/api/internal/auth/oidc_test.go @@ -66,11 +66,27 @@ func TestOIDCVerifierAcceptsRS256AndES256StableClaims(t *testing.T) { t.Fatal(err) } if user.ID != "platform-subject" || user.TenantID != "tenant-1" || user.Source != "oidc" || + user.Username != "acceptance" || user.DisplayName != "王小明" || + user.Email != "real.user@example.test" || user.Phone != "+8613800000000" || + user.AvatarURL != "https://static.example.test/avatar.png" || len(user.Roles) != 1 || user.Roles[0] != "admin" { t.Fatalf("unexpected OIDC user: %#v", user) } }) } + + unverified := signedOIDCToken(t, issuer, "rsa-key", jwt.SigningMethodRS256, rsaKey, func(claims jwt.MapClaims) { + claims["email_verified"] = false + claims["phone_number_verified"] = false + claims["picture"] = "javascript:alert(1)" + }) + user, err := verifier.Verify(context.Background(), unverified) + if err != nil { + t.Fatal(err) + } + if user.Email != "" || user.Phone != "" || user.AvatarURL != "" { + t.Fatalf("untrusted profile claims were accepted: %#v", user) + } } func TestOIDCVerifierRejectsMissingOrMismatchedSecurityClaims(t *testing.T) { @@ -400,7 +416,10 @@ func signedOIDCToken(t *testing.T, issuer, kid string, method jwt.SigningMethod, "iss": issuer, "aud": "gateway-api", "sub": "platform-subject", "tid": "tenant-1", "context_type": "tenant", "preferred_username": "acceptance", "roles": []string{"gateway.admin"}, - "scope": "openid gateway.access", "iat": now.Unix(), "nbf": now.Add(-time.Second).Unix(), + "name": "王小明", "email": "real.user@example.test", "email_verified": true, + "phone_number": "+8613800000000", "phone_number_verified": true, + "picture": "https://static.example.test/avatar.png", + "scope": "openid gateway.access", "iat": now.Unix(), "nbf": now.Add(-time.Second).Unix(), "exp": now.Add(time.Hour).Unix(), } if mutate != nil { diff --git a/apps/api/internal/httpapi/oidc_user_middleware.go b/apps/api/internal/httpapi/oidc_user_middleware.go index 6b16ca6..d2b1f69 100644 --- a/apps/api/internal/httpapi/oidc_user_middleware.go +++ b/apps/api/internal/httpapi/oidc_user_middleware.go @@ -167,6 +167,10 @@ func (s *Server) resolveOIDCUserProjectionWithTenantContext( ApplicationID: revision.ApplicationID, Subject: user.ID, Username: user.Username, + DisplayName: user.DisplayName, + Email: user.Email, + Phone: user.Phone, + AvatarURL: user.AvatarURL, Roles: user.Roles, ContextType: user.ContextType, TenantID: user.TenantID, diff --git a/apps/api/internal/httpapi/oidc_user_middleware_test.go b/apps/api/internal/httpapi/oidc_user_middleware_test.go index 506943d..3cfe2c8 100644 --- a/apps/api/internal/httpapi/oidc_user_middleware_test.go +++ b/apps/api/internal/httpapi/oidc_user_middleware_test.go @@ -89,6 +89,10 @@ func TestResolveGatewayUserAddsLocalOIDCContext(t *testing.T) { request = request.WithContext(auth.WithUser(request.Context(), &auth.User{ ID: "platform-user", Username: "alice", + DisplayName: "王小明", + Email: "alice@example.test", + Phone: "+8613800000000", + AvatarURL: "https://static.example.test/avatar.png", Roles: []string{"basic"}, ContextType: "tenant", TenantID: "external-tenant", @@ -100,7 +104,9 @@ func TestResolveGatewayUserAddsLocalOIDCContext(t *testing.T) { if recorder.Code != http.StatusOK { t.Fatalf("status = %d, want 200", recorder.Code) } - if resolver.calls != 1 || resolver.input.Subject != "platform-user" || resolver.input.GatewayTenantKey != "default" || !resolver.input.ProvisioningEnabled { + if resolver.calls != 1 || resolver.input.Subject != "platform-user" || resolver.input.GatewayTenantKey != "default" || !resolver.input.ProvisioningEnabled || + resolver.input.DisplayName != "王小明" || resolver.input.Email != "alice@example.test" || + resolver.input.Phone != "+8613800000000" || resolver.input.AvatarURL != "https://static.example.test/avatar.png" { t.Fatalf("unexpected resolver call: calls=%d input=%+v", resolver.calls, resolver.input) } } diff --git a/apps/api/internal/store/oidc_multi_tenant_users.go b/apps/api/internal/store/oidc_multi_tenant_users.go index fc531be..f2210f4 100644 --- a/apps/api/internal/store/oidc_multi_tenant_users.go +++ b/apps/api/internal/store/oidc_multi_tenant_users.go @@ -91,9 +91,15 @@ func (s *Store) resolveOrProvisionOIDCMultiTenantUser(ctx context.Context, input } rolesJSON, _ := json.Marshal(input.Roles) user, err = scanUser(tx.QueryRow(ctx, `UPDATE gateway_users SET - username=COALESCE(NULLIF($2,''),username),roles=$3::jsonb,last_login_at=now(),synced_at=now(),source_updated_at=now(),updated_at=now() + username=COALESCE(NULLIF($2,''),username), + display_name=CASE WHEN metadata->>'manualProfile'='true' THEN display_name ELSE COALESCE(NULLIF($3,''),display_name) END, + email=CASE WHEN metadata->>'manualProfile'='true' THEN email ELSE COALESCE(NULLIF($4,''),email) END, + phone=CASE WHEN metadata->>'manualProfile'='true' THEN phone ELSE COALESCE(NULLIF($5,''),phone) END, + avatar_url=CASE WHEN metadata->>'manualProfile'='true' THEN avatar_url ELSE COALESCE(NULLIF($6,''),avatar_url) END, + roles=$7::jsonb,last_login_at=now(),synced_at=now(),source_updated_at=now(),updated_at=now() WHERE id=$1::uuid AND source='oidc_v2' AND status='active' AND deleted_at IS NULL - RETURNING `+userColumns, user.ID, input.Username, string(rolesJSON))) + RETURNING `+userColumns, user.ID, input.Username, input.DisplayName, input.Email, + input.Phone, input.AvatarURL, string(rolesJSON))) if err != nil { return ResolveOrProvisionOIDCUserResult{}, err } @@ -203,13 +209,16 @@ func (s *Store) createOIDCMultiTenantUser(ctx context.Context, tx pgx.Tx, bindin rolesJSON, _ := json.Marshal(input.Roles) metadata, _ := json.Marshal(map[string]any{"provisioningMode": "oidc-multi-tenant-jit"}) user, err := scanUser(tx.QueryRow(ctx, `INSERT INTO gateway_users( - user_key,source,external_user_id,username,gateway_tenant_id,tenant_id,tenant_key, + user_key,source,external_user_id,username,display_name,email,phone,avatar_url, + gateway_tenant_id,tenant_id,tenant_key, default_user_group_id,roles,auth_profile,metadata,status,last_login_at,synced_at,source_updated_at - ) VALUES($1,'oidc_v2',NULL,$2,$3::uuid,$4,$5,$6::uuid,$7::jsonb,'{}'::jsonb,$8::jsonb, + ) VALUES($1,'oidc_v2',NULL,$2,NULLIF($3,''),NULLIF($4,''),NULLIF($5,''),NULLIF($6,''), + $7::uuid,$8,$9,$10::uuid,$11::jsonb,'{}'::jsonb,$12::jsonb, 'active',now(),now(),now()) ON CONFLICT(user_key) DO UPDATE SET updated_at=gateway_users.updated_at RETURNING `+userColumns, - userKey, username, gatewayTenantID, input.TenantID, tenantKey, groupID, string(rolesJSON), string(metadata), + userKey, username, input.DisplayName, input.Email, input.Phone, input.AvatarURL, + gatewayTenantID, input.TenantID, tenantKey, groupID, string(rolesJSON), string(metadata), )) if err != nil { return GatewayUser{}, "", false, err diff --git a/apps/api/internal/store/oidc_platform_users.go b/apps/api/internal/store/oidc_platform_users.go index 2430a12..d5bcf0b 100644 --- a/apps/api/internal/store/oidc_platform_users.go +++ b/apps/api/internal/store/oidc_platform_users.go @@ -64,7 +64,12 @@ func (s *Store) resolveOrProvisionOIDCPlatformUser( return ResolveOrProvisionOIDCUserResult{}, err } user, err = scanUser(tx.QueryRow(ctx, `UPDATE gateway_users SET - username=COALESCE(NULLIF($2,''),username),roles=$3::jsonb, + username=COALESCE(NULLIF($2,''),username), + display_name=CASE WHEN metadata->>'manualProfile'='true' THEN display_name ELSE COALESCE(NULLIF($3,''),display_name) END, + email=CASE WHEN metadata->>'manualProfile'='true' THEN email ELSE COALESCE(NULLIF($4,''),email) END, + phone=CASE WHEN metadata->>'manualProfile'='true' THEN phone ELSE COALESCE(NULLIF($5,''),phone) END, + avatar_url=CASE WHEN metadata->>'manualProfile'='true' THEN avatar_url ELSE COALESCE(NULLIF($6,''),avatar_url) END, + roles=$7::jsonb, last_login_at=now(),synced_at=now(),source_updated_at=now(), updated_at=now() WHERE id=$1::uuid AND source='oidc_v2_platform' @@ -72,6 +77,10 @@ func (s *Store) resolveOrProvisionOIDCPlatformUser( RETURNING `+userColumns, user.ID, input.Username, + input.DisplayName, + input.Email, + input.Phone, + input.AvatarURL, string(rolesJSON), )) if err != nil { @@ -189,17 +198,21 @@ func (s *Store) createOIDCPlatformUser( strings.TrimPrefix(userKey, "oidc2-platform:")[:12] } user, err := scanUser(tx.QueryRow(ctx, `INSERT INTO gateway_users( - user_key,source,external_user_id,username,gateway_tenant_id, + user_key,source,external_user_id,username,display_name,email,phone,avatar_url,gateway_tenant_id, tenant_id,tenant_key,default_user_group_id,roles,auth_profile, metadata,status,last_login_at,synced_at,source_updated_at ) VALUES( - $1,'oidc_v2_platform',NULL,$2,$3::uuid,NULL,$4,$5::uuid, - $6::jsonb,'{}'::jsonb,$7::jsonb,'active',now(),now(),now() + $1,'oidc_v2_platform',NULL,$2,NULLIF($3,''),NULLIF($4,''),NULLIF($5,''),NULLIF($6,''), + $7::uuid,NULL,$8,$9::uuid,$10::jsonb,'{}'::jsonb,$11::jsonb,'active',now(),now(),now() ) ON CONFLICT(user_key) DO NOTHING RETURNING `+userColumns, userKey, username, + input.DisplayName, + input.Email, + input.Phone, + input.AvatarURL, gatewayTenantID, oidcPlatformTenantKey, groupID, diff --git a/apps/api/internal/store/oidc_projection_test.go b/apps/api/internal/store/oidc_projection_test.go index d3406d5..60b57e9 100644 --- a/apps/api/internal/store/oidc_projection_test.go +++ b/apps/api/internal/store/oidc_projection_test.go @@ -2,6 +2,31 @@ package store import "testing" +func TestAuthUserFromOIDCProjectionIncludesProfile(t *testing.T) { + projected := authUserFromOIDCProjection(GatewayUser{ + ID: "gateway-user", ExternalUserID: "external-user", Username: "alice", + DisplayName: "王小明", Email: "alice@example.test", Phone: "+8613800000000", + AvatarURL: "https://static.example.test/avatar.png", + }, "default") + if projected.DisplayName != "王小明" || projected.Email != "alice@example.test" || + projected.Phone != "+8613800000000" || projected.AvatarURL != "https://static.example.test/avatar.png" { + t.Fatalf("OIDC profile was not projected: %#v", projected) + } +} + +func TestNormalizeOIDCUserInputBoundsProfileClaims(t *testing.T) { + input := normalizeOIDCUserInput(ResolveOrProvisionOIDCUserInput{ + DisplayName: " 王小明 ", + Email: " alice@example.test ", + Phone: " +8613800000000 ", + AvatarURL: " https://static.example.test/avatar.png ", + }) + if input.DisplayName != "王小明" || input.Email != "alice@example.test" || + input.Phone != "+8613800000000" || input.AvatarURL != "https://static.example.test/avatar.png" { + t.Fatalf("OIDC profile was not normalized: %#v", input) + } +} + func TestOIDCProjectionKindRequiresExplicitCompatibleContext(t *testing.T) { applicationID := "11111111-1111-4111-8111-111111111111" tests := []struct { diff --git a/apps/api/internal/store/oidc_users.go b/apps/api/internal/store/oidc_users.go index 120dd53..ecafd40 100644 --- a/apps/api/internal/store/oidc_users.go +++ b/apps/api/internal/store/oidc_users.go @@ -26,6 +26,10 @@ type ResolveOrProvisionOIDCUserInput struct { ApplicationID string Subject string Username string + DisplayName string + Email string + Phone string + AvatarURL string Roles []string ContextType string TenantID string @@ -122,16 +126,22 @@ func (s *Store) ResolveOrProvisionOIDCUser(ctx context.Context, input ResolveOrP createdUser, err := scanUser(tx.QueryRow(ctx, ` INSERT INTO gateway_users ( - user_key, source, external_user_id, username, gateway_tenant_id, tenant_id, tenant_key, + user_key, source, external_user_id, username, display_name, email, phone, avatar_url, + gateway_tenant_id, tenant_id, tenant_key, default_user_group_id, roles, auth_profile, metadata, status, last_login_at, synced_at, source_updated_at ) -VALUES ($1, 'oidc', $2, $3, $4::uuid, $5, $6, $7::uuid, $8::jsonb, '{}'::jsonb, $9::jsonb, +VALUES ($1, 'oidc', $2, $3, NULLIF($4, ''), NULLIF($5, ''), NULLIF($6, ''), NULLIF($7, ''), + $8::uuid, $9, $10, $11::uuid, $12::jsonb, '{}'::jsonb, $13::jsonb, 'active', now(), now(), now()) ON CONFLICT DO NOTHING RETURNING `+userColumns, userKey, input.Subject, username, + input.DisplayName, + input.Email, + input.Phone, + input.AvatarURL, tenantID, input.TenantID, input.GatewayTenantKey, @@ -244,7 +254,11 @@ func (s *Store) syncExistingOIDCUser(ctx context.Context, tx pgx.Tx, projection updated, err := scanUser(tx.QueryRow(ctx, ` UPDATE gateway_users SET username = COALESCE(NULLIF($2, ''), username), - roles = $3::jsonb, + display_name = CASE WHEN metadata->>'manualProfile' = 'true' THEN display_name ELSE COALESCE(NULLIF($3, ''), display_name) END, + email = CASE WHEN metadata->>'manualProfile' = 'true' THEN email ELSE COALESCE(NULLIF($4, ''), email) END, + phone = CASE WHEN metadata->>'manualProfile' = 'true' THEN phone ELSE COALESCE(NULLIF($5, ''), phone) END, + avatar_url = CASE WHEN metadata->>'manualProfile' = 'true' THEN avatar_url ELSE COALESCE(NULLIF($6, ''), avatar_url) END, + roles = $7::jsonb, last_login_at = now(), synced_at = now(), source_updated_at = now(), @@ -256,6 +270,10 @@ WHERE id = $1::uuid RETURNING `+userColumns, projection.user.ID, input.Username, + input.DisplayName, + input.Email, + input.Phone, + input.AvatarURL, string(rolesJSON), )) if err != nil { @@ -343,6 +361,10 @@ func normalizeOIDCUserInput(input ResolveOrProvisionOIDCUserInput) ResolveOrProv input.Issuer = strings.TrimRight(strings.TrimSpace(input.Issuer), "/") input.Subject = strings.TrimSpace(input.Subject) input.Username = strings.TrimSpace(input.Username) + input.DisplayName = limitOIDCProfileText(input.DisplayName, 200) + input.Email = limitOIDCProfileText(input.Email, 320) + input.Phone = limitOIDCProfileText(input.Phone, 64) + input.AvatarURL = limitOIDCProfileText(input.AvatarURL, 2048) input.ContextType = strings.TrimSpace(input.ContextType) input.TenantID = strings.TrimSpace(input.TenantID) input.ApplicationID = strings.TrimSpace(input.ApplicationID) @@ -359,6 +381,15 @@ func normalizeOIDCUserInput(input ResolveOrProvisionOIDCUserInput) ResolveOrProv return input } +func limitOIDCProfileText(value string, limit int) string { + value = strings.TrimSpace(value) + runes := []rune(value) + if len(runes) > limit { + return string(runes[:limit]) + } + return value +} + func normalizeOIDCRoles(roles []string) []string { result := make([]string, 0, len(roles)) seen := make(map[string]struct{}, len(roles)) @@ -390,6 +421,10 @@ func authUserFromOIDCProjection(user GatewayUser, userGroupKey string) *auth.Use return &auth.User{ ID: user.ExternalUserID, Username: user.Username, + DisplayName: user.DisplayName, + Email: user.Email, + Phone: user.Phone, + AvatarURL: user.AvatarURL, Roles: user.Roles, ContextType: "tenant", TenantID: user.TenantID, diff --git a/apps/api/internal/store/oidc_users_integration_test.go b/apps/api/internal/store/oidc_users_integration_test.go index 37c5b58..9ed762e 100644 --- a/apps/api/internal/store/oidc_users_integration_test.go +++ b/apps/api/internal/store/oidc_users_integration_test.go @@ -39,7 +39,9 @@ func TestResolveOrProvisionOIDCMultiTenantUserIsIdempotentIsolatedAndReusable(t input := func(tenantID, name, slug string) ResolveOrProvisionOIDCUserInput { return ResolveOrProvisionOIDCUserInput{ Issuer: issuer, ApplicationID: applicationID, Subject: subject, Username: "shared-subject", - Roles: []string{"basic"}, ContextType: "tenant", + DisplayName: "Shared User", Email: "shared@example.test", Phone: "+8613800000000", + AvatarURL: "https://static.example.test/shared.png", + Roles: []string{"basic"}, ContextType: "tenant", TenantID: tenantID, TenantMode: "multi_tenant", TenantName: name, TenantSlug: slug, TenantMetadataStatus: "synced", TenantMetadataVersion: "v1", TenantMetadataETag: `"v1"`, @@ -69,7 +71,9 @@ func TestResolveOrProvisionOIDCMultiTenantUserIsIdempotentIsolatedAndReusable(t if userA == nil { userA = result.User } - if result.User.GatewayUserID != userA.GatewayUserID || result.User.GatewayTenantID != userA.GatewayTenantID { + if result.User.GatewayUserID != userA.GatewayUserID || result.User.GatewayTenantID != userA.GatewayTenantID || + result.User.DisplayName != "Shared User" || result.User.Email != "shared@example.test" || + result.User.Phone != "+8613800000000" || result.User.AvatarURL != "https://static.example.test/shared.png" { t.Fatalf("concurrent tenant A projection diverged: first=%#v current=%#v", userA, result.User) } if result.Created { @@ -177,6 +181,10 @@ func TestResolveOrProvisionOIDCUserLifecycleAndConcurrency(t *testing.T) { Issuer: "https://auth.test.example/issuer/shared", Subject: subject, Username: "jit-user-" + suffix, + DisplayName: "JIT User", + Email: "jit-user@example.test", + Phone: "+8613900000000", + AvatarURL: "https://static.example.test/jit-user.png", Roles: []string{"basic"}, ContextType: "tenant", TenantID: "auth-center-test-tenant", @@ -214,7 +222,9 @@ WHERE target_gateway_user_id IN ( t.Fatalf("concurrent resolve %d: %v", index, err) } result := results[index] - if result.User == nil || result.User.GatewayUserID == "" { + if result.User == nil || result.User.GatewayUserID == "" || + result.User.DisplayName != input.DisplayName || result.User.Email != input.Email || + result.User.Phone != input.Phone || result.User.AvatarURL != input.AvatarURL { t.Fatalf("concurrent resolve %d returned no local user: %+v", index, result) } if firstID == "" { @@ -262,12 +272,18 @@ WHERE id = $1::uuid`, auditID).Scan(&auditProjection); err != nil { UPDATE gateway_users SET display_name = 'Manual Display Name', email = 'manual-profile@example.test', + phone = '+8613000000000', + avatar_url = 'https://static.example.test/manual-profile.png', metadata = metadata || '{"manualProfile":true}'::jsonb WHERE id = $1::uuid`, firstID); err != nil { t.Fatalf("seed manually managed profile fields: %v", err) } input.Username = "jit-user-renamed-" + suffix + input.DisplayName = "Remote Display Name" + input.Email = "remote-profile@example.test" + input.Phone = "+8613700000000" + input.AvatarURL = "https://static.example.test/remote-profile.png" input.Roles = []string{"basic", "admin"} input.ProvisioningEnabled = false repeated, err := db.ResolveOrProvisionOIDCUser(ctx, input) @@ -280,14 +296,16 @@ WHERE id = $1::uuid`, firstID); err != nil { if repeated.User.Username != input.Username || !containsOIDCTestRole(repeated.User.Roles, "admin") { t.Fatalf("repeat resolve did not sync token projection: %+v", repeated.User) } - var displayName, email string + var displayName, email, phone, avatarURL string var manualProfile bool if err := db.pool.QueryRow(ctx, ` -SELECT COALESCE(display_name, ''), COALESCE(email, ''), COALESCE((metadata->>'manualProfile')::boolean, false) -FROM gateway_users WHERE id = $1::uuid`, firstID).Scan(&displayName, &email, &manualProfile); err != nil { +SELECT COALESCE(display_name, ''), COALESCE(email, ''), COALESCE(phone, ''), COALESCE(avatar_url, ''), + COALESCE((metadata->>'manualProfile')::boolean, false) +FROM gateway_users WHERE id = $1::uuid`, firstID).Scan(&displayName, &email, &phone, &avatarURL, &manualProfile); err != nil { t.Fatalf("read manually managed profile fields: %v", err) } - if displayName != "Manual Display Name" || email != "manual-profile@example.test" || !manualProfile { + if displayName != "Manual Display Name" || email != "manual-profile@example.test" || + phone != "+8613000000000" || avatarURL != "https://static.example.test/manual-profile.png" || !manualProfile { t.Fatalf("repeat resolve overwrote manually managed profile fields") } @@ -352,6 +370,10 @@ WHERE source='oidc_v2_platform' ApplicationID: applicationID, Subject: subject, Username: "platform-user-" + suffix, + DisplayName: "Platform User", + Email: "platform-user@example.test", + Phone: "+8613600000000", + AvatarURL: "https://static.example.test/platform-user.png", Roles: []string{"admin"}, ContextType: "platform", TenantMode: "multi_tenant", @@ -367,6 +389,10 @@ WHERE source='oidc_v2_platform' result.User.TenantID != "" || result.User.TenantKey != oidcPlatformTenantKey || result.User.GatewayTenantID == "" || + result.User.DisplayName != "Platform User" || + result.User.Email != "platform-user@example.test" || + result.User.Phone != "+8613600000000" || + result.User.AvatarURL != "https://static.example.test/platform-user.png" || result.AuditID == "" { t.Fatalf("platform projection=%#v", result) } @@ -407,6 +433,10 @@ WHERE source='oidc_v2_platform' ApplicationID: applicationID, Subject: subject, Username: "platform-user-renamed-" + suffix, + DisplayName: "Platform User Renamed", + Email: "platform-user-renamed@example.test", + Phone: "+8613500000000", + AvatarURL: "https://static.example.test/platform-user-renamed.png", Roles: []string{"viewer"}, ContextType: "platform", TenantMode: "multi_tenant", @@ -415,6 +445,10 @@ WHERE source='oidc_v2_platform' ) if err != nil || repeated.Created || repeated.User.GatewayUserID != result.User.GatewayUserID || + repeated.User.DisplayName != "Platform User Renamed" || + repeated.User.Email != "platform-user-renamed@example.test" || + repeated.User.Phone != "+8613500000000" || + repeated.User.AvatarURL != "https://static.example.test/platform-user-renamed.png" || !reflect.DeepEqual(repeated.User.Roles, []string{"viewer"}) { t.Fatalf("repeated platform projection=%#v err=%v", repeated, err) }