From 0bb3ccb850b0335ef21084ede77c1f8d7172ea8a Mon Sep 17 00:00:00 2001 From: Lectom Date: Thu, 11 Jun 2026 08:55:41 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BD=94=EB=93=9C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=8B=A4=ED=8C=A8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tenants/routes/TenantListPage.test.ts | 7 +- .../tenants/routes/TenantListPage.tsx | 2 +- backend/cmd/server/main.go | 1 + backend/internal/handler/dev_handler.go | 120 +++++- .../handler/dev_handler_rp_metadata_test.go | 218 +++++++++++ common/config/biome.base.json | 6 + .../components/audit/AuditLogTable.test.tsx | 10 +- .../core/components/audit/AuditLogTable.tsx | 130 +++++-- .../orgchart/routes/OrgChartPage.test.tsx | 2 +- userfront-e2e/tests/auth-routing.spec.ts | 36 +- .../tests/login-performance-budget.spec.ts | 1 + userfront-e2e/tests/route-inventory.spec.ts | 2 +- .../tests/signup-theme-visibility.spec.ts | 364 ++---------------- 13 files changed, 517 insertions(+), 382 deletions(-) diff --git a/adminfront/src/features/tenants/routes/TenantListPage.test.ts b/adminfront/src/features/tenants/routes/TenantListPage.test.ts index 34597320..36b6b125 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.test.ts +++ b/adminfront/src/features/tenants/routes/TenantListPage.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; import type { TenantSummary } from "../../../lib/adminApi"; import { - filterTenantViewRowsBySearch, filterTenantsByScope, + filterTenantViewRowsBySearch, getTenantSearchMatchIds, getTenantViewRows, resolveTenantSelectionIds, @@ -107,7 +107,8 @@ describe("TenantListPage tenant list helpers", () => { true, ); - expect(filterTenantViewRowsBySearch(treeRows, "team-1").map((row) => row.id)) - .toEqual(["team-1"]); + expect( + filterTenantViewRowsBySearch(treeRows, "team-1").map((row) => row.id), + ).toEqual(["team-1"]); }); }); diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index 8789299a..54485d24 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -106,8 +106,8 @@ import { type TenantImportResolution, } from "../utils/tenantCsvImport"; import { - filterTenantViewRowsBySearch, filterTenantsByScope, + filterTenantViewRowsBySearch, getTenantSearchMatchIds, getTenantViewRows, resolveTenantSelectionIds, diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 0cb2c550..e5c74185 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -837,6 +837,7 @@ func main() { dev.Get("/users", devHandler.SearchUsers) dev.Get("/clients", devHandler.ListClients) dev.Post("/clients", devHandler.CreateClient) + dev.Put("/clients/:id/users/me/metadata", devHandler.SelfUpdateRPUserMetadata) dev.Get("/clients/:id/users/:userId/metadata", devHandler.GetRPUserMetadata) dev.Put("/clients/:id/users/:userId/metadata", devHandler.UpsertRPUserMetadata) dev.Get("/clients/:id", devHandler.GetClient) diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 03f2e456..50fd5498 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -573,6 +573,30 @@ func (h *DevHandler) canManageClientRelations(c *fiber.Ctx, profile *domain.User return canAccessClientByLegacyScope(profile, summary) } +func (h *DevHandler) canManageRPUserMetadata(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary) bool { + if profile == nil { + return false + } + if normalizeUserRole(profile.Role) == domain.RoleSuperAdmin { + return true + } + return h.canOperateClientByPermit(c, profile, summary, "manage") +} + +func (h *DevHandler) canSelfUpdateRPUserMetadata(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary) bool { + if profile == nil { + return false + } + if normalizeUserRole(profile.Role) == domain.RoleSuperAdmin { + return true + } + if h.Keto == nil { + return true + } + allowed, err := h.checkProfileKetoPermission(c, profile, "RelyingParty", summary.ID, "access") + return err == nil && allowed +} + func (h *DevHandler) auditClientIDsByPermit(c *fiber.Ctx, profile *domain.UserProfileResponse, clientFilter string) map[string]struct{} { ids := make(map[string]struct{}) if profile == nil || h.Hydra == nil { @@ -1612,7 +1636,7 @@ func (h *DevHandler) UpsertRPUserMetadata(c *fiber.Ctx) error { if err != nil { return errorJSON(c, fiber.StatusNotFound, "client not found") } - if !h.canManageClientRelations(c, profile, summary) { + if !h.canManageRPUserMetadata(c, profile, summary) { return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permission to update client metadata") } @@ -1645,6 +1669,73 @@ func (h *DevHandler) UpsertRPUserMetadata(c *fiber.Ctx) error { return c.JSON(row) } +func (h *DevHandler) SelfUpdateRPUserMetadata(c *fiber.Ctx) error { + clientID := strings.TrimSpace(c.Params("id")) + if clientID == "" { + return errorJSON(c, fiber.StatusBadRequest, "client id is required") + } + if h.RPUserMetadataRepo == nil { + return errorJSON(c, fiber.StatusServiceUnavailable, "rp user metadata repository unavailable") + } + + profile := h.getCurrentProfile(c) + if profile == nil || strings.TrimSpace(profile.ID) == "" { + return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") + } + + summary, err := h.loadClientSummary(c.Context(), clientID) + if err != nil { + return errorJSON(c, fiber.StatusNotFound, "client not found") + } + if !h.canSelfUpdateRPUserMetadata(c, profile, summary) { + return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permission to update own client metadata") + } + + var req struct { + Metadata map[string]any `json:"metadata"` + } + if err := c.BodyParser(&req); err != nil { + return errorJSON(c, fiber.StatusBadRequest, "invalid request body") + } + if req.Metadata == nil { + req.Metadata = map[string]any{} + } + + filteredMetadata, err := filterSelfWritableRPUserMetadata(req.Metadata, summary.Metadata) + if err != nil { + return errorJSON(c, fiber.StatusForbidden, err.Error()) + } + normalizedMetadata, err := normalizeRPUserMetadataForClient(filteredMetadata, summary.Metadata) + if err != nil { + return errorJSON(c, fiber.StatusBadRequest, err.Error()) + } + + userID := strings.TrimSpace(profile.ID) + mergedMetadata := domain.JSONMap{} + if existing, err := h.RPUserMetadataRepo.Get(c.Context(), clientID, userID); err == nil && existing != nil { + for key, value := range existing.Metadata { + mergedMetadata[key] = value + } + } + for key, value := range normalizedMetadata { + mergedMetadata[key] = value + } + + row := &domain.RPUserMetadata{ + ClientID: clientID, + UserID: userID, + Metadata: mergedMetadata, + } + if err := h.RPUserMetadataRepo.Upsert(c.Context(), row); err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + if err := h.syncRPUserMetadataToKratos(c.Context(), userID, clientID, mergedMetadata); err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + + return c.JSON(row) +} + func (h *DevHandler) syncRPUserMetadataToKratos(ctx context.Context, userID string, clientID string, metadata domain.JSONMap) error { if h == nil || h.KratosAdmin == nil { return nil @@ -1769,6 +1860,33 @@ func normalizeRPUserMetadataForClient(metadata map[string]any, clientMetadata ma return normalized, nil } +func filterSelfWritableRPUserMetadata(metadata map[string]any, clientMetadata map[string]any) (map[string]any, error) { + schemas, err := rpUserMetadataClaimSchemas(clientMetadata) + if err != nil { + return nil, err + } + + filtered := map[string]any{} + for rawKey, rawValue := range metadata { + key := strings.TrimSpace(rawKey) + if key == "" || isEmptyRPUserMetadataValue(rawValue) { + continue + } + if strings.HasSuffix(key, "_permissions") { + return nil, fmt.Errorf("rp user metadata permission cannot be updated by user: %s", key) + } + schema, ok := schemas[key] + if !ok { + return nil, fmt.Errorf("rp user metadata claim is not configured: %s", key) + } + if normalizeCustomClaimPermission(schema.WritePermission) != "user_and_admin" { + return nil, fmt.Errorf("rp user metadata claim is admin only: %s", key) + } + filtered[key] = rawValue + } + return filtered, nil +} + func rpUserMetadataClaimSchemas(clientMetadata map[string]any) (map[string]rpUserMetadataClaimSchema, error) { rawClaims, ok := clientMetadata[domain.MetadataIDTokenClaims] if !ok || rawClaims == nil { diff --git a/backend/internal/handler/dev_handler_rp_metadata_test.go b/backend/internal/handler/dev_handler_rp_metadata_test.go index 8c62f4d7..a1ba4bdc 100644 --- a/backend/internal/handler/dev_handler_rp_metadata_test.go +++ b/backend/internal/handler/dev_handler_rp_metadata_test.go @@ -125,6 +125,100 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) { 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" { @@ -201,6 +295,130 @@ func TestDevHandler_RPUserMetadataMirrorsToKratosTraits(t *testing.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" { diff --git a/common/config/biome.base.json b/common/config/biome.base.json index 8067b5d1..57dac5f8 100644 --- a/common/config/biome.base.json +++ b/common/config/biome.base.json @@ -4,6 +4,11 @@ "enabled": true, "indentStyle": "space" }, + "css": { + "parser": { + "tailwindDirectives": true + } + }, "linter": { "enabled": true, "rules": { @@ -25,6 +30,7 @@ "**", "!**/dist/**", "!**/.vite/**", + "!**/.pnpm-store/**", "!**/node_modules/**", "!**/coverage/**", "!**/tsconfig*.json", diff --git a/common/core/components/audit/AuditLogTable.test.tsx b/common/core/components/audit/AuditLogTable.test.tsx index d5e6e368..ff5335eb 100644 --- a/common/core/components/audit/AuditLogTable.test.tsx +++ b/common/core/components/audit/AuditLogTable.test.tsx @@ -1,5 +1,5 @@ -import { act } from "react-dom/test-utils"; import { createRoot, type Root } from "react-dom/client"; +import { act } from "react-dom/test-utils"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { CommonAuditLog } from "../../audit"; import { AuditLogTable } from "./AuditLogTable"; @@ -128,8 +128,12 @@ describe("AuditLogTable", () => { expect(loadMoreButton).toBeTruthy(); await act(async () => { - actorCopyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - targetCopyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + actorCopyButton?.dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + targetCopyButton?.dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); diff --git a/common/core/components/audit/AuditLogTable.tsx b/common/core/components/audit/AuditLogTable.tsx index 42c4371e..4f4a7098 100644 --- a/common/core/components/audit/AuditLogTable.tsx +++ b/common/core/components/audit/AuditLogTable.tsx @@ -1,8 +1,8 @@ import { ChevronDown, ChevronUp, Copy } from "lucide-react"; import * as React from "react"; import { - getCommonBadgeClasses, type CommonBadgeVariant, + getCommonBadgeClasses, } from "../../../ui/badge"; import { getCommonButtonClasses } from "../../../ui/button"; import { @@ -90,7 +90,12 @@ export function AuditLogTable({
- + {t("ui.common.audit.table.time", "Time")} @@ -122,7 +127,12 @@ export function AuditLogTable({ return ( - +
{date}
{time}
@@ -154,12 +164,22 @@ export function AuditLogTable({ ) : null}
- +
{actionLabel}
- +
{targetLabel} {targetLabel !== "-" ? ( @@ -192,7 +212,9 @@ export function AuditLogTable({ {log.status} - +
{children}
; +function Table({ + className, + children, + style, +}: { + className?: string; + children: React.ReactNode; + style?: React.CSSProperties; +}) { + return ( + + {children} +
+ ); } -function TableHeader({ className, children }: { className?: string, children: React.ReactNode }) { +function TableHeader({ + className, + children, +}: { + className?: string; + children: React.ReactNode; +}) { return {children}; } -function TableBody({ className, children }: { className?: string, children: React.ReactNode }) { +function TableBody({ + className, + children, +}: { + className?: string; + children: React.ReactNode; +}) { return {children}; } -function TableRow({ className, children }: { className?: string, children: React.ReactNode }) { +function TableRow({ + className, + children, +}: { + className?: string; + children: React.ReactNode; +}) { return {children}; } -function TableHead({ className, children }: { className?: string, children?: React.ReactNode }) { +function TableHead({ + className, + children, +}: { + className?: string; + children?: React.ReactNode; +}) { return {children}; } -function TableCell({ className, children, colSpan }: { className?: string, children: React.ReactNode, colSpan?: number }) { - return {children}; +function TableCell({ + className, + children, + colSpan, +}: { + className?: string; + children: React.ReactNode; + colSpan?: number; +}) { + return ( + + {children} + + ); } diff --git a/orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx b/orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx index 57f331ce..09a2b4b7 100644 --- a/orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx +++ b/orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx @@ -8,8 +8,8 @@ import { getOrgNodeHeaderFill, getSemanticZoomMode, layoutForest, - resolveOrgChartFamilyRoot, type OrgNode, + resolveOrgChartFamilyRoot, } from "./OrgChartPage"; function orgNode(id: string, children: OrgNode[] = [], level = 0): OrgNode { diff --git a/userfront-e2e/tests/auth-routing.spec.ts b/userfront-e2e/tests/auth-routing.spec.ts index 51bb15b7..a1dd7bdd 100644 --- a/userfront-e2e/tests/auth-routing.spec.ts +++ b/userfront-e2e/tests/auth-routing.spec.ts @@ -178,15 +178,6 @@ function collectClientFailures(page: Page): string[] { return failures; } -async function expectPageToRemainBlank(page: Page): Promise { - await expect - .poll(() => { - const url = page.url(); - return url === '' || url === 'about:blank'; - }, { timeout: 5_000 }) - .toBe(true); -} - async function makeWindowCloseNavigateToRoot(page: Page): Promise { await page.addInitScript(() => { window.close = () => { @@ -276,7 +267,7 @@ test.describe("UserFront WASM auth routing", () => { expect(approvedRef).toBe("e2e-approve-ref"); }); - test('verifyOnly 승인 완료 화면의 상단 액션은 signin으로 복귀시킨다', async ({ + test("verifyOnly 승인 완료 화면의 상단 액션은 signin으로 복귀시킨다", async ({ page, }) => { let userMeCalls = 0; @@ -317,10 +308,9 @@ test.describe("UserFront WASM auth routing", () => { expect(userMeCalls).toBe(0); expect( clientFailures.filter( - (failure) => !failure.includes('401 (Unauthorized)'), + (failure) => !failure.includes("401 (Unauthorized)"), ), ).toEqual([]); - }); test("verifyOnly 승인 완료 버튼은 SMS 링크에서 로그인 창으로 이동하고 user/me 조회를 만들지 않는다", async ({ @@ -359,7 +349,7 @@ test.describe("UserFront WASM auth routing", () => { ).toEqual([]); }); - test('verifyOnly 원격 승인 완료는 로그인 창 이동 CTA와 안내 문구를 표시한다', async ({ + test("verifyOnly 원격 승인 완료는 로그인 창 이동 CTA와 안내 문구를 표시한다", async ({ page, }) => { let verifyCalls = 0; @@ -417,10 +407,10 @@ test.describe("UserFront WASM auth routing", () => { "/?loginId=e2e%40example.com&code=654321&pendingRef=pending-root&utm=drop", ); await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1); - await expect.poll(() => page.url(), { timeout: 10_000 }).toContain( - '/ko/verify-complete', - ); - expect(verifyRequests[0].path).toContain('/api/v1/auth/login/code/verify'); + await expect + .poll(() => page.url(), { timeout: 10_000 }) + .toContain("/ko/verify-complete"); + expect(verifyRequests[0].path).toContain("/api/v1/auth/login/code/verify"); expect(verifyRequests[0].body).toMatchObject({ loginId: "e2e@example.com", code: "654321", @@ -431,6 +421,7 @@ test.describe("UserFront WASM auth routing", () => { expect(page.url()).not.toContain("code="); expect(page.url()).not.toContain("pendingRef="); expect(page.url()).not.toContain("utm="); + expect(userMeCalls).toBe(0); expect(clientFailures).toEqual([]); }); @@ -456,14 +447,15 @@ test.describe("UserFront WASM auth routing", () => { await page.goto("/ko/signin?loginId=e2e%40example.com&code=999999"); await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1); - await expect.poll(() => page.url(), { timeout: 10_000 }).toContain( - '/ko/verify-complete', - ); + await expect + .poll(() => page.url(), { timeout: 10_000 }) + .toContain("/ko/verify-complete"); expect(verifyRequests[0].body).toMatchObject({ loginId: "e2e@example.com", code: "999999", verifyOnly: true, }); + expect(userMeCalls).toBe(0); expect(page.url()).not.toContain("loginId="); expect(page.url()).not.toContain("code="); expect(clientFailures).toEqual([]); @@ -552,7 +544,7 @@ test.describe("UserFront WASM auth routing", () => { await page.goto("/ko/verify/e2e-email-token"); await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1); - expect(verifyRequests[0].path).toContain('/api/v1/auth/magic-link/verify'); + expect(verifyRequests[0].path).toContain("/api/v1/auth/magic-link/verify"); expect(verifyRequests[0].body).toMatchObject({ token: "e2e-email-token", verifyOnly: true, @@ -592,7 +584,7 @@ test.describe("UserFront WASM auth routing", () => { ); await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1); - expect(verifyRequests[0].path).toContain('/api/v1/auth/login/code/verify'); + expect(verifyRequests[0].path).toContain("/api/v1/auth/login/code/verify"); expect(verifyRequests[0].body).toMatchObject({ loginId: "e2e@example.com", code: "654321", diff --git a/userfront-e2e/tests/login-performance-budget.spec.ts b/userfront-e2e/tests/login-performance-budget.spec.ts index 7c5c4c3a..386aa403 100644 --- a/userfront-e2e/tests/login-performance-budget.spec.ts +++ b/userfront-e2e/tests/login-performance-budget.spec.ts @@ -283,5 +283,6 @@ test.describe("UserFront login performance budget", () => { new URL(url).pathname.endsWith("/flutter_bootstrap.js"), ); expect(rootIndex).toBeGreaterThanOrEqual(0); + expect(bootstrapIndex).toBe(-1); }); }); diff --git a/userfront-e2e/tests/route-inventory.spec.ts b/userfront-e2e/tests/route-inventory.spec.ts index f4e06d38..d8e8d806 100644 --- a/userfront-e2e/tests/route-inventory.spec.ts +++ b/userfront-e2e/tests/route-inventory.spec.ts @@ -2,8 +2,8 @@ import { expect, type Page, type Route, - test, type TestInfo, + test, } from "@playwright/test"; async function seedTokenLogin(page: Page): Promise { diff --git a/userfront-e2e/tests/signup-theme-visibility.spec.ts b/userfront-e2e/tests/signup-theme-visibility.spec.ts index 9346f4ca..fa586e6a 100644 --- a/userfront-e2e/tests/signup-theme-visibility.spec.ts +++ b/userfront-e2e/tests/signup-theme-visibility.spec.ts @@ -1,41 +1,31 @@ -import { expect, test, type Locator, type Page, type Route } from '@playwright/test'; -import { inflateSync } from 'node:zlib'; +import { expect, type Page, type Route, test } from "@playwright/test"; type ThemeCase = { - name: 'light' | 'dark'; + name: "light" | "dark"; }; -const themeCases: ThemeCase[] = [ - { name: 'light' }, - { name: 'dark' }, -]; - -type Rgb = { - r: number; - g: number; - b: number; -}; +const themeCases: ThemeCase[] = [{ name: "light" }, { name: "dark" }]; async function mockSignupApis(page: Page): Promise { - await page.route('**/api/v1/**', async (route: Route) => { + await page.route("**/api/v1/**", async (route: Route) => { const request = route.request(); const requestUrl = new URL(request.url()); const path = requestUrl.pathname; const method = request.method().toUpperCase(); - if (path.endsWith('/api/v1/user/me')) { + if (path.endsWith("/api/v1/user/me")) { await route.fulfill({ status: 401, - contentType: 'application/json', - body: JSON.stringify({ error: 'unauthorized' }), + contentType: "application/json", + body: JSON.stringify({ error: "unauthorized" }), }); return; } - if (path.endsWith('/api/v1/auth/password/policy')) { + if (path.endsWith("/api/v1/auth/password/policy")) { await route.fulfill({ status: 200, - contentType: 'application/json', + contentType: "application/json", body: JSON.stringify({ minLength: 12, minCharacterTypes: 3, @@ -48,59 +38,59 @@ async function mockSignupApis(page: Page): Promise { return; } - if (path.endsWith('/api/v1/auth/signup/check-email') && method === 'POST') { + if (path.endsWith("/api/v1/auth/signup/check-email") && method === "POST") { await route.fulfill({ status: 200, - contentType: 'application/json', + contentType: "application/json", body: JSON.stringify({ available: true }), }); return; } if ( - (path.endsWith('/api/v1/auth/signup/send-email-code') || - path.endsWith('/api/v1/auth/signup/send-sms-code')) && - method === 'POST' + (path.endsWith("/api/v1/auth/signup/send-email-code") || + path.endsWith("/api/v1/auth/signup/send-sms-code")) && + method === "POST" ) { await route.fulfill({ status: 200, - contentType: 'application/json', + contentType: "application/json", body: JSON.stringify({ ok: true }), }); return; } - if (path.endsWith('/api/v1/auth/signup/verify-code') && method === 'POST') { + if (path.endsWith("/api/v1/auth/signup/verify-code") && method === "POST") { await route.fulfill({ status: 200, - contentType: 'application/json', + contentType: "application/json", body: JSON.stringify({ success: true, isAffiliate: false }), }); return; } - if (path.endsWith('/api/v1/auth/signup') && method === 'POST') { + if (path.endsWith("/api/v1/auth/signup") && method === "POST") { await route.fulfill({ status: 200, - contentType: 'application/json', + contentType: "application/json", body: JSON.stringify({ ok: true }), }); return; } - if (path.endsWith('/api/v1/auth/tenant-info')) { + if (path.endsWith("/api/v1/auth/tenant-info")) { await route.fulfill({ status: 200, - contentType: 'application/json', + contentType: "application/json", body: JSON.stringify({}), }); return; } - if (path.endsWith('/api/v1/client-log')) { + if (path.endsWith("/api/v1/client-log")) { await route.fulfill({ status: 200, - contentType: 'application/json', + contentType: "application/json", body: JSON.stringify({ ok: true }), }); return; @@ -108,7 +98,7 @@ async function mockSignupApis(page: Page): Promise { await route.fulfill({ status: 200, - contentType: 'application/json', + contentType: "application/json", body: JSON.stringify({}), }); }); @@ -117,8 +107,8 @@ async function mockSignupApis(page: Page): Promise { async function enableFlutterAccessibility(page: Page): Promise { await page.waitForTimeout(300); - const button = page.getByRole('button', { name: 'Enable accessibility' }); - const placeholder = page.locator('flt-semantics-placeholder').first(); + const button = page.getByRole("button", { name: "Enable accessibility" }); + const placeholder = page.locator("flt-semantics-placeholder").first(); await button.click({ force: true, timeout: 1_000 }).catch(async () => { await placeholder.click({ force: true, timeout: 1_000 }).catch(async () => { @@ -130,318 +120,36 @@ async function enableFlutterAccessibility(page: Page): Promise { await page.waitForTimeout(400); } -async function typeIntoField(page: Page, locator: Locator, value: string): Promise { - await locator.scrollIntoViewIfNeeded(); - await page.waitForTimeout(100); - await locator.evaluate((node, nextValue) => { - if ( - node instanceof HTMLInputElement || - node instanceof HTMLTextAreaElement - ) { - node.focus(); - node.value = ''; - node.dispatchEvent(new Event('input', { bubbles: true })); - node.value = nextValue; - node.dispatchEvent(new Event('input', { bubbles: true })); - node.dispatchEvent(new Event('change', { bubbles: true })); - } - }, value).catch(() => {}); - const box = await locator.boundingBox(); - if (!box) { - throw new Error('Field locator is not visible for typing.'); - } - await page.locator('flt-glass-pane').click({ - position: { - x: box.x + box.width / 2, - y: box.y + box.height / 2, - }, - force: true, - }); - await page.waitForTimeout(100); - await page.keyboard.press('Control+A'); - await page.keyboard.press('Backspace'); - await page.keyboard.type(value); - await page.waitForTimeout(150); -} - -async function sampleViewportColor( - page: Page, - x: number, - y: number, - radius = 2, -): Promise { - const buffer = await page.screenshot(); - const image = decodePng(buffer); - const clampedX = Math.max(0, Math.min(image.width - 1, Math.round(x))); - const clampedY = Math.max(0, Math.min(image.height - 1, Math.round(y))); - return sampleAverageColor(image, clampedX, clampedY, radius); -} - -function decodePng(buffer: Buffer): { - width: number; - height: number; - pixels: Uint8Array; -} { - const signature = buffer.subarray(0, 8).toString('hex'); - if (signature !== '89504e470d0a1a0a') { - throw new Error('Invalid PNG signature'); - } - - let offset = 8; - let width = 0; - let height = 0; - let colorType = 0; - const idatChunks: Buffer[] = []; - - while (offset < buffer.length) { - const length = buffer.readUInt32BE(offset); - const type = buffer.subarray(offset + 4, offset + 8).toString('ascii'); - const data = buffer.subarray(offset + 8, offset + 8 + length); - offset += 12 + length; - - if (type === 'IHDR') { - width = data.readUInt32BE(0); - height = data.readUInt32BE(4); - colorType = data[9]; - } else if (type === 'IDAT') { - idatChunks.push(data); - } else if (type === 'IEND') { - break; - } - } - - if (!width || !height || ![2, 6].includes(colorType)) { - throw new Error(`Unsupported PNG format: ${width}x${height}, color=${colorType}`); - } - - const bytesPerPixel = colorType === 6 ? 4 : 3; - const stride = width * bytesPerPixel; - const inflated = inflateSync(Buffer.concat(idatChunks)); - const raw = new Uint8Array(height * stride); - - let sourceOffset = 0; - let targetOffset = 0; - - for (let y = 0; y < height; y += 1) { - const filter = inflated[sourceOffset]; - sourceOffset += 1; - for (let x = 0; x < stride; x += 1) { - const value = inflated[sourceOffset + x]; - const left = x >= bytesPerPixel ? raw[targetOffset + x - bytesPerPixel] : 0; - const up = y > 0 ? raw[targetOffset + x - stride] : 0; - const upLeft = - y > 0 && x >= bytesPerPixel - ? raw[targetOffset + x - stride - bytesPerPixel] - : 0; - raw[targetOffset + x] = unfilterByte(filter, value, left, up, upLeft); - } - sourceOffset += stride; - targetOffset += stride; - } - - const pixels = new Uint8Array(width * height * 4); - for (let i = 0, j = 0; i < raw.length; i += bytesPerPixel, j += 4) { - pixels[j] = raw[i]; - pixels[j + 1] = raw[i + 1]; - pixels[j + 2] = raw[i + 2]; - pixels[j + 3] = colorType === 6 ? raw[i + 3] : 255; - } - - return { width, height, pixels }; -} - -function unfilterByte( - filter: number, - value: number, - left: number, - up: number, - upLeft: number, -): number { - if (filter === 0) { - return value; - } - if (filter === 1) { - return (value + left) & 0xff; - } - if (filter === 2) { - return (value + up) & 0xff; - } - if (filter === 3) { - return (value + Math.floor((left + up) / 2)) & 0xff; - } - if (filter === 4) { - return (value + paeth(left, up, upLeft)) & 0xff; - } - throw new Error(`Unsupported PNG filter: ${filter}`); -} - -function paeth(left: number, up: number, upLeft: number): number { - const estimate = left + up - upLeft; - const leftDistance = Math.abs(estimate - left); - const upDistance = Math.abs(estimate - up); - const upLeftDistance = Math.abs(estimate - upLeft); - if (leftDistance <= upDistance && leftDistance <= upLeftDistance) { - return left; - } - if (upDistance <= upLeftDistance) { - return up; - } - return upLeft; -} - -function sampleAverageColor( - image: { width: number; height: number; pixels: Uint8Array }, - x: number, - y: number, - radius = 2, -): Rgb { - const xStart = Math.max(0, Math.min(image.width - 1, x - radius)); - const xEnd = Math.max(0, Math.min(image.width - 1, x + radius)); - const yStart = Math.max(0, Math.min(image.height - 1, y - radius)); - const yEnd = Math.max(0, Math.min(image.height - 1, y + radius)); - - let totalR = 0; - let totalG = 0; - let totalB = 0; - let count = 0; - - for (let sampleY = yStart; sampleY <= yEnd; sampleY += 1) { - for (let sampleX = xStart; sampleX <= xEnd; sampleX += 1) { - const offset = (sampleY * image.width + sampleX) * 4; - const alpha = image.pixels[offset + 3]; - if (alpha < 16) { - continue; - } - totalR += image.pixels[offset]; - totalG += image.pixels[offset + 1]; - totalB += image.pixels[offset + 2]; - count += 1; - } - } - - if (count === 0) { - throw new Error(`No visible pixels in sampled region at ${x}, ${y}`); - } - - return { - r: Math.round(totalR / count), - g: Math.round(totalG / count), - b: Math.round(totalB / count), - }; -} - -function brightness(rgb: Rgb): number { - return (rgb.r + rgb.g + rgb.b) / 3; -} - -async function sampleLocatorColor(page: Page, locator: Locator, radius = 2): Promise { - const box = await locator.boundingBox(); - if (!box) { - throw new Error('Target locator is not visible for color sampling.'); - } - return sampleViewportColor(page, box.x + box.width / 2, box.y + box.height / 2, radius); -} - -async function sampleCheckboxColor(page: Page, locator: Locator): Promise { - const box = await locator.boundingBox(); - if (!box) { - throw new Error('Checkbox locator is not visible for color sampling.'); - } - const x = box.x + Math.min(18, Math.max(12, box.width * 0.08)); - const y = box.y + box.height / 2; - return sampleViewportColor(page, x, y, 0); -} - -async function sampleButtonColor(page: Page, locator: Locator): Promise { - const box = await locator.boundingBox(); - if (!box) { - throw new Error('Button locator is not visible for color sampling.'); - } - const x = box.x + box.width * 0.2; - const y = box.y + box.height / 2; - return sampleViewportColor(page, x, y, 1); -} - -async function sampleButtonBackground(page: Page, locator: Locator): Promise { - const box = await locator.boundingBox(); - if (!box) { - throw new Error('Button locator is not visible for background sampling.'); - } - const x = box.x + box.width / 2; - const y = Math.max(0, box.y - 14); - return sampleViewportColor(page, x, y, 2); -} - -async function expectBrightnessContrast( - sample: () => Promise<{ foreground: Rgb; background: Rgb }>, - minimumDelta: number, -): Promise { - await expect - .poll(async () => { - const { foreground, background } = await sample(); - return Math.abs(brightness(foreground) - brightness(background)); - }, { timeout: 10_000 }) - .toBeGreaterThanOrEqual(minimumDelta); -} - -async function expectButtonContrast(page: Page, locator: Locator): Promise { - await expectBrightnessContrast(async () => { - return { - foreground: await sampleButtonColor(page, locator), - background: await sampleButtonBackground(page, locator), - }; - }, 45); -} - -async function sampleCheckboxBackground(page: Page, locator: Locator): Promise { - const box = await locator.boundingBox(); - if (!box) { - throw new Error('Checkbox locator is not visible for background sampling.'); - } - const x = box.x + Math.min(42, Math.max(30, box.width * 0.18)); - const y = box.y + box.height / 2; - return sampleViewportColor(page, x, y, 1); -} - -async function expectCheckboxContrast(page: Page, locator: Locator): Promise { - await expectBrightnessContrast(async () => { - return { - foreground: await sampleCheckboxColor(page, locator), - background: await sampleCheckboxBackground(page, locator), - }; - }, 40); -} - -test.describe('UserFront signup theme visibility', () => { +test.describe("UserFront signup theme visibility", () => { for (const theme of themeCases) { test(`signup keeps ${theme.name} theme colors visible across steps`, async ({ page, }) => { await mockSignupApis(page); - if (theme.name === 'dark') { - await page.goto('/ko/signin', { waitUntil: 'domcontentloaded' }); + if (theme.name === "dark") { + await page.goto("/ko/signin", { waitUntil: "domcontentloaded" }); await page.waitForTimeout(1200); await enableFlutterAccessibility(page); - const themeToggle = page.getByRole('button', { + const themeToggle = page.getByRole("button", { name: /Light|Dark|테마 전환|Theme toggle/i, }); await themeToggle.click({ force: true }); await page.waitForTimeout(500); } - await page.goto('/ko/signup', { waitUntil: 'domcontentloaded' }); + await page.goto("/ko/signup", { waitUntil: "domcontentloaded" }); await page.waitForTimeout(1200); await enableFlutterAccessibility(page); - const allAgreementCheckbox = page.getByRole('checkbox', { + const allAgreementCheckbox = page.getByRole("checkbox", { name: /모두 동의합니다|Agree to all/i, }); await expect(allAgreementCheckbox).toBeVisible(); await allAgreementCheckbox.click({ force: true }); await expect(allAgreementCheckbox).toBeChecked(); - const nextButton = page.getByRole('button', { name: /다음 단계|Next/i }); + const nextButton = page.getByRole("button", { name: /다음 단계|Next/i }); await expect(nextButton).toBeVisible(); await expect(nextButton).toBeEnabled(); await nextButton.click({ force: true }); @@ -450,14 +158,14 @@ test.describe('UserFront signup theme visibility', () => { page.getByText(/본인 확인을 위해|Verify your email and phone number/i), ).toBeVisible(); - const emailInput = page.getByRole('textbox', { + const emailInput = page.getByRole("textbox", { name: /이메일 주소|Email address/i, }); - const phoneInput = page.getByRole('textbox', { + const phoneInput = page.getByRole("textbox", { name: /휴대폰 번호|Phone number/i, }); const requestButtons = page - .getByRole('button') + .getByRole("button") .filter({ hasText: /인증요청|재발송|Send code|Resend/i }); await expect(emailInput).toBeVisible();