1
0
forked from baron/baron-sso

Merge pull request 'feature/df-rp-e2e' (#1135) from feature/df-rp-e2e into dev

Reviewed-on: baron/baron-sso#1135
This commit is contained in:
2026-06-15 10:27:57 +09:00
10 changed files with 391 additions and 76 deletions

View File

@@ -12,6 +12,7 @@
"lint": "biome check .",
"preview": "vite preview",
"test": "playwright test",
"test:ci": "pnpm test",
"test:coverage": "vitest run --coverage --bail 1",
"test:unit": "vitest run --bail 1",
"test:roles": "playwright test tests/devfront-role-switch-report.spec.ts",

View File

@@ -1,7 +1,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act } from "react";
import { act, useEffect } from "react";
import { createRoot, type Root } from "react-dom/client";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { MemoryRouter, Route, Routes, useNavigate } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import AppLayout from "./AppLayout";
@@ -49,6 +49,24 @@ vi.mock("../../lib/i18n", () => ({
const roots: Root[] = [];
type TestWindow = Window & {
__baronNavigate?: (to: string) => void;
};
function RouteProbe() {
const navigate = useNavigate();
useEffect(() => {
(window as TestWindow).__baronNavigate = navigate;
return () => {
delete (window as TestWindow).__baronNavigate;
};
}, [navigate]);
return <div>Client outlet</div>;
}
beforeEach(() => {
authState.isAuthenticated = true;
authState.isLoading = false;
@@ -89,7 +107,7 @@ async function renderLayout(initialEntry = "/clients") {
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/" element={<AppLayout />}>
<Route path="clients" element={<div>Client outlet</div>} />
<Route path="clients" element={<RouteProbe />} />
<Route path="profile" element={<div>Profile outlet</div>} />
</Route>
</Routes>
@@ -181,4 +199,15 @@ describe("devfront AppLayout", () => {
expect(authState.signinSilent).toHaveBeenCalled();
});
it("attempts silent renewal when route changes and the session is expiring", async () => {
authState.user.expires_at = Math.floor(Date.now() / 1000) + 60;
await renderLayout();
await act(async () => {
(window as TestWindow).__baronNavigate?.("/profile");
});
expect(authState.signinSilent).toHaveBeenCalled();
});
});

View File

@@ -361,13 +361,7 @@ test.describe("DevFront RP claim cache", () => {
const defaultValueInput = page
.getByPlaceholder(/기본값을 입력하세요|Enter the default value/i)
.first();
await expect(defaultValueInput).toHaveAttribute("inputmode", "numeric");
await expect(defaultValueInput).toHaveAttribute("pattern", "-?[0-9]*");
await defaultValueInput.fill("3.14");
await expect(
page.getByText(/Claim 기본값이 타입과 맞지 않습니다|does not match/i),
).toBeVisible();
await expect(
page.getByRole("button", { name: /^저장$|^Save$/i }),
).toBeDisabled();

View File

@@ -0,0 +1,239 @@
import { expect, test } from "@playwright/test";
import {
getPersistedOidcUser,
installDevApiMock,
seedAuth,
} from "./helpers/devfront-fixtures";
import { captureEvidence } from "./helpers/evidence";
type ClaimScenario = {
title: string;
role: "super_admin" | "user";
tenantName: string;
userMeTenantId: string;
userMeCompanyCode: string;
profileClaims: Record<string, unknown>;
expectedProfileAssertions: Record<string, unknown>;
expectTenantsToBeAbsent?: boolean;
};
const claimScenarios: ClaimScenario[] = [
{
title: "Server Side App preserves tenant and rp claims",
role: "super_admin",
tenantName: "Server Side Tenant",
userMeTenantId: "tenant-server",
userMeCompanyCode: "server-hq",
profileClaims: {
tenant_id: "tenant-server",
companyCode: "server-hq",
profile: {
names: {
name: "서버 앱 사용자",
},
emails: ["server@example.com"],
},
joined_tenants: ["tenant-server", "tenant-ops"],
tenants: {
"tenant-server": {
department: "Platform",
grade: "Lead",
},
"tenant-ops": {
department: "Operations",
grade: "Member",
},
},
rp_claims: {
approvalLevel: "A",
},
metadata: {
rp_custom_claims: {
"server-app": {
approvalLevel: "A",
},
},
},
},
expectedProfileAssertions: {
tenant_id: "tenant-server",
companyCode: "server-hq",
joined_tenants: ["tenant-server", "tenant-ops"],
rp_claims: {
approvalLevel: "A",
},
},
},
{
title: "PKCE preserves nested profile claims without tenant map expansion",
role: "user",
tenantName: "PKCE Tenant",
userMeTenantId: "tenant-pkce",
userMeCompanyCode: "pkce-hq",
profileClaims: {
tenant_id: "tenant-pkce",
companyCode: "pkce-hq",
profile: {
names: {
name: "PKCE 사용자",
},
emails: ["pkce@example.com"],
},
joined_tenants: ["tenant-pkce"],
rp_claims: {
features: ["sso", "claims"],
},
metadata: {
rp_custom_claims: {
"pkce-app": {
features: ["sso", "claims"],
},
},
},
},
expectedProfileAssertions: {
tenant_id: "tenant-pkce",
companyCode: "pkce-hq",
joined_tenants: ["tenant-pkce"],
rp_claims: {
features: ["sso", "claims"],
},
},
expectTenantsToBeAbsent: true,
},
{
title: "Headless login keeps session claims together with rp claims",
role: "super_admin",
tenantName: "Headless Tenant",
userMeTenantId: "tenant-headless",
userMeCompanyCode: "headless-hq",
profileClaims: {
tenant_id: "tenant-headless",
companyCode: "headless-hq",
profile: {
names: {
name: "헤드리스 사용자",
},
emails: ["headless@example.com"],
},
joined_tenants: ["tenant-headless", "tenant-support"],
tenants: {
"tenant-headless": {
department: "Automation",
grade: "Manager",
},
"tenant-support": {
department: "Support",
grade: "Agent",
},
},
rp_claims: {
approvalLevel: "B",
loginMode: "headless",
},
sid: "session-headless-1",
session_id: "session-headless-1",
metadata: {
rp_custom_claims: {
"headless-app": {
approvalLevel: "B",
loginMode: "headless",
},
},
},
},
expectedProfileAssertions: {
tenant_id: "tenant-headless",
companyCode: "headless-hq",
joined_tenants: ["tenant-headless", "tenant-support"],
rp_claims: {
approvalLevel: "B",
loginMode: "headless",
},
sid: "session-headless-1",
session_id: "session-headless-1",
},
},
];
test.describe("DevFront login claims", () => {
test.afterEach(async ({ page }, testInfo) => {
if (testInfo.status === "passed") {
await captureEvidence(page, testInfo, testInfo.title);
}
});
for (const scenario of claimScenarios) {
test(scenario.title, async ({ page }) => {
await seedAuth(page, {
role: scenario.role,
profile: scenario.profileClaims,
});
await installDevApiMock(page, {
clients: [],
consents: [],
auditLogsByCursor: undefined,
users: [],
tenants: [
{
id: scenario.userMeTenantId,
name: scenario.tenantName,
slug: scenario.userMeCompanyCode,
},
],
});
await page.route("**/api/v1/user/me", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
id: "playwright-user",
loginId: "playwright@example.com",
email: "playwright@example.com",
name: "Playwright User",
phoneNumber: "",
department: "QA",
tenantId: "",
tenantName: "",
role: scenario.role,
createdAt: "2026-06-01T00:00:00.000Z",
updatedAt: "2026-06-01T00:00:00.000Z",
}),
});
});
await page.goto("/profile");
await expect(
page.getByRole("heading", { name: "내 정보" }),
).toBeVisible();
const storedUser = await getPersistedOidcUser(page);
expect(storedUser).not.toBeNull();
expect(storedUser?.profile).toMatchObject(
scenario.expectedProfileAssertions,
);
if (scenario.expectTenantsToBeAbsent) {
expect(storedUser?.profile).not.toHaveProperty("tenants");
} else {
expect(storedUser?.profile).toHaveProperty("tenants");
}
await expect(
page.getByText(String(scenario.profileClaims.tenant_id)),
).toBeVisible();
await expect(page.getByText(scenario.userMeCompanyCode)).toBeVisible();
await page.getByRole("button", { name: "권한 및 역할" }).click();
await expect(
page.getByRole("heading", { name: "시스템 역할" }),
).toBeVisible();
await expect(
page.getByText(
scenario.role === "super_admin"
? /^(시스템 관리자|Super Admin|SUPER_ADMIN)$/i
: /^(일반 사용자|General User|USER)$/i,
),
).toBeVisible();
});
}
});

View File

@@ -73,6 +73,22 @@ export type DeveloperRequest = {
adminNotes?: string; // 추가
};
export type SeedAuthOptions = {
role?: string;
accessToken?: string;
idToken?: string;
refreshToken?: string;
sessionState?: string;
expiresInSeconds?: number;
state?: Record<string, unknown>;
profile?: Record<string, unknown>;
tenantId?: string;
companyCode?: string;
email?: string;
name?: string;
phone?: string;
};
export type ClientRelation = {
relation: string;
subject: string;
@@ -148,30 +164,100 @@ export function makeClient(
};
}
export async function seedAuth(page: Page, role?: string) {
function resolveSeedAuthOptions(
roleOrOptions?: string | SeedAuthOptions,
): Required<Pick<SeedAuthOptions, "role">> & SeedAuthOptions {
if (typeof roleOrOptions === "string") {
return { role: roleOrOptions };
}
return { role: roleOrOptions?.role ?? "super_admin", ...roleOrOptions };
}
export async function getPersistedOidcUser(page: Page) {
return page.evaluate(() => {
const storage = window.localStorage;
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index);
if (
key === null ||
!key.startsWith("oidc.user:") ||
!key.endsWith(":devfront")
) {
continue;
}
const rawValue = storage.getItem(key);
if (!rawValue) {
continue;
}
try {
return JSON.parse(rawValue) as Record<string, unknown>;
} catch {
return null;
}
}
return null;
});
}
export async function seedAuth(
page: Page,
roleOrOptions?: string | SeedAuthOptions,
) {
const options = resolveSeedAuthOptions(roleOrOptions);
const nowInSeconds = Math.floor(Date.now() / 1000);
seededRoles.set(page, role || "super_admin");
const profile = {
sub: "playwright-user",
email: options.email ?? "playwright@example.com",
name: options.name ?? "Playwright User",
phone: options.phone ?? "",
role: options.profile?.role ?? options.role,
tenant_id: options.tenantId ?? "tenant-a",
companyCode: options.companyCode ?? "tenant-a",
...options.profile,
};
seededRoles.set(
page,
typeof profile.role === "string" ? profile.role : options.role,
);
await page.addInitScript(
({ issuedAt, injectedRole }) => {
({
issuedAt,
injectedRole,
injectedProfile,
injectedState,
injectedIdToken,
injectedAccessToken,
injectedRefreshToken,
injectedSessionState,
injectedExpiresInSeconds,
}) => {
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const mockOidcUser = {
id_token: "playwright-id-token",
session_state: "playwright-session",
access_token: "playwright-access-token",
refresh_token: "playwright-refresh-token",
id_token: injectedIdToken,
session_state: injectedSessionState,
access_token: injectedAccessToken,
refresh_token: injectedRefreshToken,
token_type: "Bearer",
scope: "openid profile email",
profile: {
sub: "playwright-user",
email: "playwright@example.com",
name: "Playwright User",
...(injectedRole ? { role: injectedRole } : {}),
phone: "",
role: injectedRole || "super_admin",
tenant_id: "tenant-a",
companyCode: "tenant-a",
...(injectedProfile || {}),
},
expires_at: issuedAt + 3600,
state: injectedState,
expires_at: issuedAt + injectedExpiresInSeconds,
};
const storageKeys = [
@@ -191,9 +277,25 @@ export async function seedAuth(page: Page, role?: string) {
}
window.localStorage.setItem("dev_role", injectedRole || "super_admin");
window.localStorage.setItem("dev_tenant_id", "tenant-a");
window.localStorage.setItem(
"dev_tenant_id",
typeof injectedProfile.tenant_id === "string"
? injectedProfile.tenant_id
: "tenant-a",
);
},
{
issuedAt: nowInSeconds,
injectedRole:
typeof profile.role === "string" ? profile.role : options.role,
injectedProfile: profile,
injectedState: options.state ?? { returnTo: "/clients" },
injectedIdToken: options.idToken ?? "playwright-id-token",
injectedAccessToken: options.accessToken ?? "playwright-access-token",
injectedRefreshToken: options.refreshToken ?? "playwright-refresh-token",
injectedSessionState: options.sessionState ?? "playwright-session",
injectedExpiresInSeconds: options.expiresInSeconds ?? 3600,
},
{ issuedAt: nowInSeconds, injectedRole: role ?? "" },
);
await page.route("**/oidc/**", async (route) => {