1
0
forked from baron/baron-sso

custom claim 권한체크 확인

This commit is contained in:
2026-06-11 08:29:25 +09:00
parent 839ca9d407
commit 4d77060b5d
79 changed files with 4268 additions and 670 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 802 KiB

After

Width:  |  Height:  |  Size: 805 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 810 KiB

After

Width:  |  Height:  |  Size: 815 KiB

View File

@@ -37,6 +37,7 @@ import {
TableHeader,
TableRow,
} from "../../components/ui/table";
import { Textarea } from "../../components/ui/textarea";
import {
fetchClient,
fetchConsents,
@@ -154,7 +155,7 @@ function draftRowsToMetadata(rows: MetadataDraftRow[]) {
for (const row of rows) {
const key = row.key.trim();
if (!key) continue;
metadata[key] = row.value.trim();
metadata[key] = draftRowValueToMetadataValue(row);
metadata[`${key}_permissions`] = {
readPermission: row.readPermission,
writePermission: row.writePermission,
@@ -163,6 +164,38 @@ function draftRowsToMetadata(rows: MetadataDraftRow[]) {
return metadata;
}
function draftRowValueToMetadataValue(row: MetadataDraftRow) {
const value = row.value.trim();
switch (row.valueType) {
case "number": {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : value;
}
case "boolean":
return value === "true";
case "array":
if (value === "") return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : value;
} catch {
return value;
}
case "object":
if (value === "") return {};
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed
: value;
} catch {
return value;
}
default:
return value;
}
}
function isRPClaimValueType(value: string): value is RPClaimValueType {
return (
value === "text" ||
@@ -994,20 +1027,54 @@ function ClientConsentsPage() {
)}
/>
)}
<Input
type={rpClaimInputType(row.valueType)}
value={row.value}
onChange={(event) =>
updateMetadataDraftRow(row.id, {
value: event.target.value,
})
}
className="font-mono text-xs"
placeholder={t(
"ui.dev.clients.consents.rp_claims.value_placeholder",
"claim value",
)}
/>
{row.valueType === "boolean" ? (
<select
value={row.value === "false" ? "false" : "true"}
onChange={(event) =>
updateMetadataDraftRow(row.id, {
value: event.target.value,
})
}
className="h-10 rounded-md border border-input bg-background px-3 font-mono text-xs shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label={`${row.key} boolean`}
>
<option value="true">true</option>
<option value="false">false</option>
</select>
) : row.valueType === "array" ||
row.valueType === "object" ? (
<Textarea
value={row.value}
onChange={(event) =>
updateMetadataDraftRow(row.id, {
value: event.target.value,
})
}
className="min-h-10 font-mono text-xs"
placeholder={
row.valueType === "array"
? `["value"]`
: `{"key": "value"}`
}
aria-label={`${row.key} ${row.valueType}`}
/>
) : (
<Input
type={rpClaimInputType(row.valueType)}
value={row.value}
onChange={(event) =>
updateMetadataDraftRow(row.id, {
value: event.target.value,
})
}
className="font-mono text-xs"
placeholder={t(
"ui.dev.clients.consents.rp_claims.value_placeholder",
"claim value",
)}
aria-label={`${row.key} ${row.valueType}`}
/>
)}
<select
value={row.readPermission}
onChange={(event) =>

View File

@@ -0,0 +1,232 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ClientDetailResponse } from "../../lib/devApi";
import ClientGeneralPage from "./ClientGeneralPage";
const navigateMock = vi.fn();
const fetchClientMock = vi.fn();
const updateClientMock = vi.fn();
const fetchClientRelationsMock = vi.fn();
const fetchMyTenantsMock = vi.fn();
const fetchMeMock = vi.fn();
let authState = {
user: {
access_token: "access-token",
profile: {
sub: "admin-user",
role: "super_admin",
name: "Dev Admin",
},
},
};
vi.mock("react-oidc-context", () => ({
useAuth: () => authState,
}));
vi.mock("react-router-dom", async () => {
const actual =
await vi.importActual<typeof import("react-router-dom")>(
"react-router-dom",
);
return {
...actual,
useNavigate: () => navigateMock,
};
});
vi.mock("../../lib/devApi", () => ({
createClient: vi.fn(),
deleteClient: vi.fn(),
fetchClient: (...args: unknown[]) => fetchClientMock(...args),
fetchClientRelations: (...args: unknown[]) =>
fetchClientRelationsMock(...args),
fetchMyTenants: (...args: unknown[]) => fetchMyTenantsMock(...args),
refreshHeadlessJwksCache: vi.fn(),
revokeHeadlessJwksCache: vi.fn(),
updateClient: (...args: unknown[]) => updateClientMock(...args),
updateClientStatus: vi.fn(),
}));
vi.mock("../auth/authApi", () => ({
fetchMe: (...args: unknown[]) => fetchMeMock(...args),
}));
vi.mock("../../lib/i18n", () => ({
t: (key: string, fallback?: string, vars?: Record<string, unknown>) => {
let text = fallback ?? key;
for (const [name, value] of Object.entries(vars ?? {})) {
text = text.replaceAll(`{{${name}}}`, String(value));
}
return text;
},
}));
const roots: Root[] = [];
function makeClientDetail(claimKey: string): ClientDetailResponse {
return {
client: {
id: "client-claims",
name: "Claims App",
type: "private",
status: "active",
redirectUris: ["https://rp.example.com/callback"],
scopes: ["openid", "profile"],
tokenEndpointAuthMethod: "client_secret_basic",
metadata: {
description: "Claims app",
structured_scopes: [
{
id: "1",
name: "openid",
description: "",
mandatory: true,
},
],
id_token_claims: [
{
namespace: "rp_claims",
key: claimKey,
value: "A",
valueType: "text",
readPermission: "admin_only",
writePermission: "admin_only",
},
],
},
},
endpoints: {
discovery: "https://issuer/.well-known/openid-configuration",
issuer: "https://issuer",
authorization: "https://issuer/oauth2/auth",
token: "https://issuer/oauth2/token",
userinfo: "https://issuer/userinfo",
},
};
}
async function flush() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
async function setInputValue(input: HTMLInputElement, value: string) {
const descriptor = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
);
descriptor?.set?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
await flush();
}
async function renderPage() {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/clients/client-claims/settings"]}>
<Routes>
<Route
path="/clients/:id/settings"
element={<ClientGeneralPage />}
/>
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
});
await flush();
return { container, queryClient };
}
describe("ClientGeneralPage RP claims", () => {
beforeEach(() => {
authState = {
user: {
access_token: "access-token",
profile: {
sub: "admin-user",
role: "super_admin",
name: "Dev Admin",
},
},
};
fetchClientMock.mockResolvedValue(makeClientDetail("old_claim"));
updateClientMock.mockResolvedValue(makeClientDetail("new_claim"));
fetchClientRelationsMock.mockResolvedValue({ items: [] });
fetchMyTenantsMock.mockResolvedValue([]);
fetchMeMock.mockResolvedValue({
id: "admin-user",
role: "super_admin",
name: "Dev Admin",
});
navigateMock.mockReset();
});
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
vi.clearAllMocks();
document.body.innerHTML = "";
});
it("updates the client detail cache with saved RP claims before stale data can rehydrate the form", async () => {
const { container, queryClient } = await renderPage();
const claimKeyInput = container.querySelector<HTMLInputElement>(
'input[placeholder="e.g. locale"]',
);
expect(claimKeyInput).not.toBeNull();
expect(claimKeyInput?.value).toBe("old_claim");
await setInputValue(claimKeyInput as HTMLInputElement, "new_claim");
const saveButton = Array.from(container.querySelectorAll("button")).find(
(button) =>
button.textContent?.includes("저장") ||
button.textContent?.includes("Save"),
);
expect(saveButton).toBeDefined();
await act(async () => {
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const cached = queryClient.getQueryData<ClientDetailResponse>([
"client",
"client-claims",
]);
expect(cached?.client.metadata?.id_token_claims).toEqual([
{
namespace: "rp_claims",
key: "new_claim",
value: "A",
valueType: "text",
readPermission: "admin_only",
writePermission: "admin_only",
},
]);
});
});

View File

@@ -84,6 +84,7 @@ interface IdTokenClaimItem {
key: string;
value: string;
valueType: ClaimValueType;
nullable: boolean;
readPermission: CustomClaimPermission;
writePermission: CustomClaimPermission;
}
@@ -169,6 +170,7 @@ function createIdTokenClaimItem(id: string): IdTokenClaimItem {
key: "",
value: "",
valueType: "text",
nullable: false,
readPermission: "admin_only",
writePermission: "admin_only",
};
@@ -217,6 +219,7 @@ function readIdTokenClaimsMetadata(
key: keyValue,
value: valueValue,
valueType: valueTypeValue,
nullable: record.nullable === true,
readPermission: isCustomClaimPermission(record.readPermission)
? record.readPermission
: "admin_only",
@@ -231,8 +234,12 @@ function readIdTokenClaimsMetadata(
function normalizeClaimPreviewValue(
value: string,
valueType: ClaimValueType,
nullable: boolean,
): unknown {
const trimmed = value.trim();
if (nullable && trimmed === "") {
return null;
}
if (valueType === "number") {
if (trimmed === "") return "";
const parsed = Number(trimmed);
@@ -284,7 +291,11 @@ function buildIdTokenClaimsPreview(
continue;
}
rpClaims[key] = normalizeClaimPreviewValue(item.value, item.valueType);
rpClaims[key] = normalizeClaimPreviewValue(
item.value,
item.valueType,
item.nullable,
);
}
if (Object.keys(rpClaims).length > 0) {
@@ -755,6 +766,25 @@ function ClientGeneralPage() {
);
};
const setIdTokenClaimPermissionAllowed = (
id: string,
field: "readPermission" | "writePermission",
allowed: boolean,
) => {
const permission = allowed ? "user_and_admin" : "admin_only";
setIdTokenClaims((current) =>
current.map((claim) => {
if (claim.id !== id) {
return claim;
}
return {
...claim,
[field]: permission,
};
}),
);
};
const removeIdTokenClaim = (id: string) => {
setIdTokenClaims((current) => current.filter((claim) => claim.id !== id));
};
@@ -1090,6 +1120,7 @@ function ClientGeneralPage() {
return createClient(payload);
}
await queryClient.cancelQueries({ queryKey: ["client", clientId] });
const updated = await updateClient(clientId as string, payload);
if (status !== initialStatus) {
await updateClientStatus(clientId as string, status);
@@ -1097,6 +1128,10 @@ function ClientGeneralPage() {
return updated;
},
onSuccess: (result) => {
const resultClientId = result?.client?.id ?? clientId;
if (resultClientId) {
queryClient.setQueryData(["client", resultClientId], result);
}
queryClient.invalidateQueries({ queryKey: ["clients"] });
if (status !== initialStatus) {
setInitialStatus(status);
@@ -2109,20 +2144,26 @@ function ClientGeneralPage() {
</th>
<th className="px-4 py-3 text-left font-bold">
{t(
"ui.dev.clients.general.id_token_claims.table.read_permission",
"ui.dev.clients.general.id_token_claims.table.nullable",
"Nullable",
)}
</th>
<th className="px-4 py-3 text-left font-bold">
{t(
"ui.dev.clients.general.id_token_claims.table.read_user_allowed",
"Read",
)}
</th>
<th className="px-4 py-3 text-left font-bold">
{t(
"ui.dev.clients.general.id_token_claims.table.write_permission",
"ui.dev.clients.general.id_token_claims.table.write_user_allowed",
"Write",
)}
</th>
<th className="px-4 py-3 text-left font-bold">
{t(
"ui.dev.clients.general.id_token_claims.table.value",
"Value",
"ui.dev.clients.general.id_token_claims.table.default_value",
"Default Value",
)}
</th>
<th className="px-4 py-3 text-right font-bold">
@@ -2227,66 +2268,65 @@ function ClientGeneralPage() {
</select>
</td>
<td className="px-4 py-3 align-top">
<select
value={claim.readPermission}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"readPermission",
e.target.value as CustomClaimPermission,
)
}
aria-label={t(
"ui.dev.clients.general.id_token_claims.read_permission_label",
"읽기 권한",
)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
disabled={isGeneralSettingsReadOnly}
>
<option value="admin_only">
{t(
"ui.common.custom_claim_permission.admin_only",
"관리자만 가능",
<div className="flex h-9 items-center">
<Switch
checked={claim.nullable}
onCheckedChange={(checked) =>
updateIdTokenClaim(
claim.id,
"nullable",
checked,
)
}
aria-label={t(
"ui.dev.clients.general.id_token_claims.nullable_label",
"Nullable",
)}
</option>
<option value="user_and_admin">
{t(
"ui.common.custom_claim_permission.user_and_admin",
"사용자 및 관리자 가능",
)}
</option>
</select>
disabled={isGeneralSettingsReadOnly}
/>
</div>
</td>
<td className="px-4 py-3 align-top">
<select
value={claim.writePermission}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"writePermission",
e.target.value as CustomClaimPermission,
)
}
aria-label={t(
"ui.dev.clients.general.id_token_claims.write_permission_label",
"쓰기 권한",
)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
disabled={isGeneralSettingsReadOnly}
>
<option value="admin_only">
{t(
"ui.common.custom_claim_permission.admin_only",
"관리자만 가능",
<div className="flex h-9 items-center">
<Switch
checked={
claim.readPermission === "user_and_admin"
}
onCheckedChange={(checked) =>
setIdTokenClaimPermissionAllowed(
claim.id,
"readPermission",
checked,
)
}
aria-label={t(
"ui.dev.clients.general.id_token_claims.read_user_allowed_label",
"Read 사용자 허용",
)}
</option>
<option value="user_and_admin">
{t(
"ui.common.custom_claim_permission.user_and_admin",
"사용자 및 관리자 가능",
disabled={isGeneralSettingsReadOnly}
/>
</div>
</td>
<td className="px-4 py-3 align-top">
<div className="flex h-9 items-center">
<Switch
checked={
claim.writePermission === "user_and_admin"
}
onCheckedChange={(checked) =>
setIdTokenClaimPermissionAllowed(
claim.id,
"writePermission",
checked,
)
}
aria-label={t(
"ui.dev.clients.general.id_token_claims.write_user_allowed_label",
"Write 사용자 허용",
)}
</option>
</select>
disabled={isGeneralSettingsReadOnly}
/>
</div>
</td>
<td className="px-4 py-3 align-top">
<Input
@@ -2301,7 +2341,7 @@ function ClientGeneralPage() {
className="h-9 font-mono text-xs"
placeholder={t(
"ui.dev.clients.general.id_token_claims.value_placeholder",
"Enter the claim value",
"Enter the default value",
)}
disabled={isGeneralSettingsReadOnly}
/>

View File

@@ -182,6 +182,52 @@ async function waitForTextContent(container: HTMLElement, text: string) {
}
describe("ClientsPage", () => {
it("does not show the legacy tenant scope label for unrestricted clients", async () => {
fetchClientsMock.mockResolvedValue({
items: [
{
...makeClients(1)[0],
name: "Unrestricted App",
metadata: {
tenant_access_restricted: false,
allowed_tenants: [],
},
},
],
limit: 100,
offset: 0,
});
const container = await renderPage();
expect(container.textContent).toContain("Unrestricted App");
expect(container.textContent).not.toContain("Tenant-scoped");
expect(container.textContent).not.toContain("Tenant-limited");
});
it("shows Tenant-limited only when client tenant access is restricted", async () => {
fetchClientsMock.mockResolvedValue({
items: [
{
...makeClients(1)[0],
name: "Limited App",
metadata: {
tenant_access_restricted: true,
allowed_tenants: ["tenant-1"],
},
},
],
limit: 100,
offset: 0,
});
const container = await renderPage();
expect(container.textContent).toContain("Limited App");
expect(container.textContent).toContain("Tenant-limited");
expect(container.textContent).not.toContain("Tenant-scoped");
});
it("expands the list and applies search filters", async () => {
fetchClientsMock.mockResolvedValue({
items: makeClients(6),

View File

@@ -59,6 +59,19 @@ import { ClientLogo } from "./components/ClientLogo";
type ClientSortKey = "application" | "id" | "type" | "status" | "createdAt";
const clientListPreviewCount = 5;
function isClientTenantLimited(client: ClientSummary) {
const metadata = client.metadata ?? {};
if (metadata.tenant_access_restricted === true) {
return true;
}
if (!Array.isArray(metadata.allowed_tenants)) {
return false;
}
return metadata.allowed_tenants.some(
(tenantId) => typeof tenantId === "string" && tenantId.trim() !== "",
);
}
function ClientsPage() {
const navigate = useNavigate();
const auth = useAuth();
@@ -529,14 +542,16 @@ function ClientsPage() {
{client.name ||
t("ui.dev.clients.untitled", "Untitled")}
</p>
<p className="text-xs text-muted-foreground">
<span aria-hidden="true">
{t(
"ui.dev.clients.tenant_scoped",
"Tenant-scoped",
)}
</span>
</p>
{isClientTenantLimited(client) && (
<p className="text-xs text-muted-foreground">
<span aria-hidden="true">
{t(
"ui.dev.clients.tenant_limited",
"Tenant-limited",
)}
</span>
</p>
)}
</div>
</Link>
</TableCell>

View File

@@ -492,7 +492,8 @@ describe("devfront coverage smoke pages", () => {
expect(settings.textContent).not.toContain("top-level");
expect(settings.textContent).toContain("Date");
expect(settings.textContent).toContain("Datetime");
expect(settings.textContent).toContain("관리자만 가능");
expect(settings.textContent).toContain("Read");
expect(settings.textContent).toContain("Write");
const consents = await renderPage(<ClientConsentsPage />, {
path: "/clients/:id/consents",

View File

@@ -1431,7 +1431,7 @@ user = "General User"
[ui.dev.clients]
new = "Add Connected Application"
search_placeholder = "Search by app name or ID..."
tenant_scoped = "Tenant-scoped"
tenant_limited = "Tenant-limited"
untitled = "Untitled"
[ui.dev.clients.recent_changes]
@@ -1617,6 +1617,17 @@ preview_title = "Saved JSON Preview"
namespace_label = "Claim namespace"
namespace_top_level = "top-level"
namespace_rp_claims = "rp_claims"
nullable_label = "Nullable"
read_user_allowed_label = "Read user allowed"
write_user_allowed_label = "Write user allowed"
table.key = "Claim Key"
table.namespace = "Namespace"
table.value_type = "Value Type"
table.nullable = "Nullable"
table.read_user_allowed = "Read"
table.write_user_allowed = "Write"
table.default_value = "Default Value"
table.delete = "Delete"
value_type_label = "Claim value type"
value_type_text = "Text"
value_type_number = "Number"
@@ -1624,7 +1635,7 @@ value_type_boolean = "Boolean"
value_type_array = "Array"
value_type_object = "Object"
key_placeholder = "e.g. locale"
value_placeholder = "Enter the claim value"
value_placeholder = "Enter the default value"
[ui.dev.clients.general.security]
private = "Server Side App"

View File

@@ -1431,7 +1431,7 @@ user = "일반 사용자"
[ui.dev.clients]
new = "연동 앱 추가"
search_placeholder = "연동 앱 이름/ID로 검색..."
tenant_scoped = "Tenant-scoped"
tenant_limited = "Tenant-limited"
untitled = "Untitled"
[ui.dev.clients.recent_changes]
@@ -1616,6 +1616,17 @@ preview_title = "저장 JSON 미리보기"
namespace_label = "Claim 네임스페이스"
namespace_top_level = "top-level"
namespace_rp_claims = "rp_claims"
nullable_label = "Null 허용"
read_user_allowed_label = "Read 사용자 허용"
write_user_allowed_label = "Write 사용자 허용"
table.key = "Claim Key"
table.namespace = "Namespace"
table.value_type = "Value Type"
table.nullable = "Null 허용"
table.read_user_allowed = "Read"
table.write_user_allowed = "Write"
table.default_value = "기본값"
table.delete = "삭제"
value_type_label = "Claim 값 타입"
value_type_text = "텍스트"
value_type_number = "숫자"
@@ -1623,7 +1634,7 @@ value_type_boolean = "불리언"
value_type_array = "배열"
value_type_object = "객체"
key_placeholder = "예: locale"
value_placeholder = "Claim 값을 입력하세요"
value_placeholder = "기본값을 입력하세요"
[ui.dev.clients.general.security]
private = "Server side App"

View File

@@ -1484,7 +1484,7 @@ user = ""
[ui.dev.clients]
new = ""
search_placeholder = ""
tenant_scoped = ""
tenant_limited = ""
untitled = ""
[ui.dev.clients.recent_changes]
@@ -1665,6 +1665,17 @@ preview_title = ""
namespace_label = ""
namespace_top_level = ""
namespace_rp_claims = ""
nullable_label = ""
read_user_allowed_label = ""
write_user_allowed_label = ""
table.key = ""
table.namespace = ""
table.value_type = ""
table.nullable = ""
table.read_user_allowed = ""
table.write_user_allowed = ""
table.default_value = ""
table.delete = ""
value_type_label = ""
value_type_text = ""
value_type_number = ""

View File

@@ -50,6 +50,43 @@ test("clients page loads correctly", async ({ page }) => {
).toBeVisible();
});
test("clients page shows Tenant-limited only for tenant access restricted RP", async ({
page,
}) => {
await seedAuth(page, "super_admin");
await installDevApiMock(page, {
clients: [
makeClient("client-limited", {
name: "Limited RP",
createdAt: "2026-05-02T00:00:00.000Z",
metadata: {
tenant_access_restricted: true,
allowed_tenants: ["tenant-1"],
},
}),
makeClient("client-open", {
name: "Open RP",
createdAt: "2026-05-01T00:00:00.000Z",
metadata: {
tenant_access_restricted: false,
allowed_tenants: [],
},
}),
],
consents: [] as Consent[],
auditLogsByCursor: undefined,
});
await page.goto("/clients");
const limitedRow = page.locator("tbody tr", { hasText: "Limited RP" });
await expect(limitedRow).toContainText("Tenant-limited");
const openRow = page.locator("tbody tr", { hasText: "Open RP" });
await expect(openRow).not.toContainText("Tenant-limited");
await expect(page.getByText("Tenant-scoped")).toHaveCount(0);
});
test("overview page shows recent RP changes", async ({ page }) => {
await seedAuth(page, "super_admin");
await installDevApiMock(page, {

View File

@@ -0,0 +1,63 @@
import { expect, test } from "@playwright/test";
import {
type Consent,
installDevApiMock,
makeClient,
seedAuth,
} from "./helpers/devfront-fixtures";
import { installDevFrontStaticRoutes } from "./helpers/static-devfront";
test.describe("DevFront RP claim cache", () => {
test.beforeEach(async ({ page }) => {
await installDevFrontStaticRoutes(page);
await seedAuth(page, "super_admin");
});
test("keeps saved RP claim value visible after saving", async ({ page }) => {
const state = {
clients: [
makeClient("client-claims", {
name: "Claims app",
metadata: {
id_token_claims: [
{
namespace: "rp_claims",
key: "old_claim",
value: "A",
valueType: "text",
readPermission: "admin_only",
writePermission: "admin_only",
},
],
},
}),
],
consents: [] as Consent[],
auditLogsByCursor: undefined,
mockRole: "super_admin",
};
await installDevApiMock(page, state);
await page.goto("http://devfront.test/clients/client-claims/settings");
const claimKeyInput = page
.getByPlaceholder(/e\.g\. locale|예: locale/i)
.first();
await expect(claimKeyInput).toHaveValue("old_claim");
await claimKeyInput.fill("new_claim");
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
await expect
.poll(
() =>
(
state.clients[0]?.metadata?.id_token_claims as
| Array<{ key?: string }>
| undefined
)?.[0]?.key,
)
.toBe("new_claim");
await expect(claimKeyInput).toHaveValue("new_claim");
});
});

View File

@@ -155,14 +155,21 @@ test.describe("DevFront clients lifecycle", () => {
.getByLabel(/Claim value type|Claim 값 타입/i)
.first()
.selectOption("date");
await expect(
page.getByRole("columnheader", { name: /Default Value|기본값/i }),
).toBeVisible();
await page
.getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i)
.getByPlaceholder(/기본값을 입력하세요|Enter the default value/i)
.first()
.fill("2026-06-09");
await page
.getByLabel(/읽기 권한|Read permission/i)
.getByLabel(/Nullable|Null 허용/i)
.first()
.selectOption("user_and_admin");
.click();
await page
.getByLabel(/Read 사용자 허용|Read user allowed/i)
.first()
.click();
await page.getByRole("button", { name: /Claim 추가|Add Claim/i }).click();
await page
@@ -174,7 +181,7 @@ test.describe("DevFront clients lifecycle", () => {
.nth(1)
.selectOption("number");
await page
.getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i)
.getByPlaceholder(/기본값을 입력하세요|Enter the default value/i)
.nth(1)
.fill("2");
@@ -238,6 +245,7 @@ test.describe("DevFront clients lifecycle", () => {
key?: string;
value?: string;
valueType?: string;
nullable?: boolean;
readPermission?: string;
writePermission?: string;
}>
@@ -245,6 +253,18 @@ test.describe("DevFront clients lifecycle", () => {
)?.[0]?.valueType,
)
.toBe("date");
await expect
.poll(
() =>
(
state.clients[0]?.metadata?.id_token_claims as
| Array<{
nullable?: boolean;
}>
| undefined
)?.[0]?.nullable,
)
.toBe(true);
await expect
.poll(
() =>
@@ -313,18 +333,25 @@ test.describe("DevFront clients lifecycle", () => {
page.getByPlaceholder(/e\.g\. locale|예: locale/i).nth(1),
).toHaveValue("tier");
await expect(
page.getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i),
page.getByPlaceholder(/기본값을 입력하세요|Enter the default value/i),
).toHaveCount(2);
await expect(
page
.getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i)
.getByPlaceholder(/기본값을 입력하세요|Enter the default value/i)
.first(),
).toHaveValue("2026-06-09");
await expect(
page
.getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i)
.getByPlaceholder(/기본값을 입력하세요|Enter the default value/i)
.nth(1),
).toHaveValue("2");
await expect(page.getByLabel(/Nullable|Null 허용/i).first()).toBeChecked();
await expect(
page.getByLabel(/Read 사용자 허용|Read user allowed/i).first(),
).toBeChecked();
await expect(
page.getByLabel(/Write 사용자 허용|Write user allowed/i).first(),
).not.toBeChecked();
});
test("headless login uses jwks uri only and shows cache actions", async ({

View File

@@ -40,6 +40,18 @@ test.describe("DevFront consents", () => {
valueType: "datetime",
value: "2026-06-09T09:30",
},
{
namespace: "rp_claims",
key: "active_member",
valueType: "boolean",
value: "true",
},
{
namespace: "rp_claims",
key: "score",
valueType: "number",
value: "1",
},
],
},
}),
@@ -78,9 +90,14 @@ test.describe("DevFront consents", () => {
await expect(page.getByText("RP Custom Claims")).toBeVisible();
await expect(page.getByText("contract_date")).toBeVisible();
await expect(page.getByText("approved_at")).toBeVisible();
await expect(page.getByText("active_member")).toBeVisible();
await expect(page.locator('input[type="date"]')).toHaveValue("2026-06-09");
await page.locator('input[type="date"]').fill("2026-06-10");
await page.locator('input[type="datetime-local"]').fill("2026-06-09T10:30");
await page
.getByLabel(/active_member.*boolean|boolean.*active_member/i)
.selectOption("false");
await page.getByLabel(/score.*number|number.*score/i).fill("42");
await page
.getByLabel(/쓰기 권한|Write permission/i)
.first()
@@ -92,6 +109,10 @@ test.describe("DevFront consents", () => {
await expect
.poll(() => state.consents[0]?.rpMetadata?.approved_at)
.toBe("2026-06-09T10:30");
await expect
.poll(() => state.consents[0]?.rpMetadata?.active_member)
.toBe(false);
await expect.poll(() => state.consents[0]?.rpMetadata?.score).toBe(42);
await expect
.poll(
() =>

View File

@@ -466,6 +466,7 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
createdAt: client.createdAt,
redirectUris: client.redirectUris,
scopes: client.scopes,
metadata: client.metadata ?? {},
})),
limit: 50,
offset: 0,
@@ -612,6 +613,7 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
token: "https://issuer/oauth2/token",
userinfo: "https://issuer/userinfo",
},
headlessJwksCache: found.headlessJwksCache,
});
}
@@ -635,6 +637,7 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
token: "https://issuer/oauth2/token",
userinfo: "https://issuer/userinfo",
},
headlessJwksCache: found.headlessJwksCache,
});
}
@@ -720,6 +723,7 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
token: "https://issuer/oauth2/token",
userinfo: "https://issuer/userinfo",
},
headlessJwksCache: found.headlessJwksCache,
});
}

View File

@@ -0,0 +1,93 @@
import { readFile, stat } from "node:fs/promises";
import { extname, join, normalize, resolve } from "node:path";
import type { Page } from "@playwright/test";
const contentTypes: Record<string, string> = {
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".ico": "image/x-icon",
".js": "application/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".map": "application/json; charset=utf-8",
".mjs": "application/javascript; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
".txt": "text/plain; charset=utf-8",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2",
};
function safeDistPath(distDir: string, pathname: string) {
const decoded = decodeURIComponent(pathname);
const relative = decoded.replace(/^\/+/, "");
const safe = normalize(relative).replace(/^(\.\.(?:[\\/]|$))+/, "");
return join(distDir, safe);
}
async function resolveStaticFile(distDir: string, pathname: string) {
const indexPath = join(distDir, "index.html");
let filePath = safeDistPath(
distDir,
pathname === "/" ? "/index.html" : pathname,
);
try {
const fileStat = await stat(filePath);
if (fileStat.isDirectory()) {
filePath = join(filePath, "index.html");
}
} catch {
filePath = indexPath;
}
try {
return {
body: await readFile(filePath),
contentType:
contentTypes[extname(filePath).toLowerCase()] ??
"application/octet-stream",
};
} catch {
return null;
}
}
export async function installDevFrontStaticRoutes(
page: Page,
options: {
distDir?: string;
origin?: string;
} = {},
) {
const origin = options.origin ?? "http://devfront.test";
const distDir = resolve(
options.distDir ??
process.env.DEVFRONT_DIST_DIR ??
"/tmp/baron-sso-devfront-dist",
);
await page.route(`${origin}/**`, async (route) => {
const url = new URL(route.request().url());
if (url.pathname === "/api" || url.pathname.startsWith("/api/")) {
await route.fallback();
return;
}
const file = await resolveStaticFile(distDir, url.pathname);
if (!file) {
await route.fulfill({
status: 500,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({ error: "devfront_dist_not_found" }),
});
return;
}
await route.fulfill({
status: 200,
contentType: file.contentType,
body: file.body,
});
});
}