forked from baron/baron-sso
custom claim 타입보정 UI. 대표테넌트 노출 보정
This commit is contained in:
@@ -132,6 +132,7 @@ jobs:
|
||||
global='^(\.gitea/workflows/code_check\.yml|Makefile|scripts/|tools/|test/code_check_)'
|
||||
front_shared='^(common/|scripts/playwrightPackageVersion\.cjs|scripts/summarize_vitest_coverage\.mjs|scripts/run_adminfront_ci_tests\.sh|\.gitea/workflows/code_check\.yml|Makefile)'
|
||||
i18n_shared='^(locales/|common/locales/|userfront/assets/translations/|scripts/sync_userfront_locales\.sh|tools/i18n-scanner/)'
|
||||
react_i18n='^(adminfront/src/locales/|devfront/src/locales/|orgfront/src/locales/)'
|
||||
|
||||
backend=false
|
||||
userfront=false
|
||||
@@ -154,7 +155,7 @@ jobs:
|
||||
if matches "$front_shared|^adminfront/|^devfront/|^orgfront/"; then biome=true; fi
|
||||
|
||||
lint=false
|
||||
if [ "$backend" = true ] || [ "$userfront" = true ] || [ "$adminfront" = true ] || [ "$devfront" = true ] || [ "$orgfront" = true ] || matches "$i18n_shared"; then
|
||||
if [ "$backend" = true ] || [ "$userfront" = true ] || matches "$global|$i18n_shared|$react_i18n"; then
|
||||
lint=true
|
||||
fi
|
||||
|
||||
@@ -213,42 +214,6 @@ jobs:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
|
||||
- name: Install adminfront dependencies
|
||||
run: |
|
||||
cd adminfront
|
||||
npx pnpm install -C ../common --no-frozen-lockfile
|
||||
npx pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Biome check adminfront (lint + format)
|
||||
run: |
|
||||
cd adminfront
|
||||
npx biome check . --formatter-enabled=false --assist-enabled=false
|
||||
npx biome check . --linter-enabled=false --assist-enabled=false
|
||||
|
||||
- name: Install devfront dependencies
|
||||
run: |
|
||||
cd devfront
|
||||
npx pnpm install -C ../common --no-frozen-lockfile
|
||||
npx pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Biome check devfront (lint + format)
|
||||
run: |
|
||||
cd devfront
|
||||
npx biome check . --formatter-enabled=false --assist-enabled=false
|
||||
npx biome check . --linter-enabled=false --assist-enabled=false
|
||||
|
||||
- name: Install orgfront dependencies
|
||||
run: |
|
||||
cd orgfront
|
||||
npx pnpm install -C ../common --no-frozen-lockfile
|
||||
npx pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Biome check orgfront (lint + format)
|
||||
run: |
|
||||
cd orgfront
|
||||
npx biome check . --formatter-enabled=false --assist-enabled=false
|
||||
npx biome check . --linter-enabled=false --assist-enabled=false
|
||||
|
||||
- name: Lint Go backend
|
||||
run: |
|
||||
docker run --rm \
|
||||
@@ -879,7 +844,7 @@ jobs:
|
||||
adminfront-vitest-coverage:
|
||||
needs:
|
||||
- changes
|
||||
- lint
|
||||
- biome-check
|
||||
if: ${{ always() && needs.changes.outputs.adminfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -1010,7 +975,7 @@ jobs:
|
||||
devfront-vitest-coverage:
|
||||
needs:
|
||||
- changes
|
||||
- lint
|
||||
- biome-check
|
||||
if: ${{ always() && needs.changes.outputs.devfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -1141,7 +1106,7 @@ jobs:
|
||||
orgfront-vitest-coverage:
|
||||
needs:
|
||||
- changes
|
||||
- lint
|
||||
- biome-check
|
||||
if: ${{ always() && needs.changes.outputs.orgfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -1272,7 +1237,7 @@ jobs:
|
||||
adminfront-tests:
|
||||
needs:
|
||||
- changes
|
||||
- lint
|
||||
- biome-check
|
||||
if: ${{ always() && needs.changes.outputs.adminfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_adminfront_tests == true) }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
@@ -1367,7 +1332,7 @@ jobs:
|
||||
devfront-tests:
|
||||
needs:
|
||||
- changes
|
||||
- lint
|
||||
- biome-check
|
||||
if: ${{ always() && needs.changes.outputs.devfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_devfront_tests == true) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -1550,7 +1515,7 @@ jobs:
|
||||
orgfront-tests:
|
||||
needs:
|
||||
- changes
|
||||
- lint
|
||||
- biome-check
|
||||
if: ${{ always() && needs.changes.outputs.orgfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_orgfront_tests == true) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -18,6 +18,11 @@ const notify = () => {
|
||||
};
|
||||
|
||||
const toastBase = (message: string, type: ToastType = "success") => {
|
||||
if (
|
||||
toasts.some((toast) => toast.message === message && toast.type === type)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const id = Math.random().toString(36).substring(2, 9);
|
||||
toasts = [...toasts, { id, message, type }];
|
||||
notify();
|
||||
|
||||
@@ -656,7 +656,7 @@ export function TenantWorksmobilePage() {
|
||||
actionDisabled={isCreatingUsers || createSelectedMutation.isPending}
|
||||
updateActionLabel="선택 구성원 업데이트 적용"
|
||||
onCreateSelected={(ids, initialPassword) =>
|
||||
createSelectedMutation.mutate({
|
||||
createSelectedMutation.mutateAsync({
|
||||
resourceKind: "users",
|
||||
ids,
|
||||
initialPassword,
|
||||
@@ -1031,7 +1031,7 @@ function ComparisonTable({
|
||||
actionLabel: string;
|
||||
updateActionLabel?: string;
|
||||
actionDisabled: boolean;
|
||||
onCreateSelected: (ids: string[], initialPassword?: string) => void;
|
||||
onCreateSelected: (ids: string[], initialPassword?: string) => unknown;
|
||||
onUpdateSelected?: (ids: string[]) => void;
|
||||
onRunSelected?: (actionIds: string[], deleteIds: string[]) => void;
|
||||
deleteActionLabel?: string;
|
||||
@@ -1222,13 +1222,17 @@ function ComparisonTable({
|
||||
onUpdateSelected(selectedUpdateUserIds);
|
||||
};
|
||||
|
||||
const confirmInitialPassword = () => {
|
||||
const confirmInitialPassword = async () => {
|
||||
const password = initialPassword.trim();
|
||||
if (!password) {
|
||||
toast.error("WORKS 초기 비밀번호를 입력해 주세요.");
|
||||
return;
|
||||
}
|
||||
onCreateSelected(pendingInitialPasswordIds, password);
|
||||
try {
|
||||
await onCreateSelected(pendingInitialPasswordIds, password);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setInitialPasswordOpen(false);
|
||||
setInitialPassword("");
|
||||
setPendingInitialPasswordIds([]);
|
||||
@@ -1383,7 +1387,11 @@ function ComparisonTable({
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="button" onClick={confirmInitialPassword}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={confirmInitialPassword}
|
||||
disabled={actionDisabled}
|
||||
>
|
||||
생성 작업 등록
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getSeedTenantSlugs, isSeedTenant } from "./protectedTenants";
|
||||
import { getSeedTenantIds, isSeedTenant } from "./protectedTenants";
|
||||
|
||||
describe("protectedTenants", () => {
|
||||
it("marks tenants from seed-tenant.csv as protected", () => {
|
||||
expect(getSeedTenantSlugs()).toEqual(
|
||||
expect.arrayContaining(["hanmac-family", "personal"]),
|
||||
it("marks tenants from seed-tenant.csv as protected by UUID", () => {
|
||||
expect(getSeedTenantIds()).toEqual(
|
||||
expect.arrayContaining([
|
||||
"038326b6-954a-48a7-a85f-efd83f62b82a",
|
||||
"5a03efd2-e62f-4243-800d-58334bf48b2f",
|
||||
"9607eb7b-04d2-42ab-80fe-780fe21c7e8f",
|
||||
]),
|
||||
);
|
||||
expect(isSeedTenant({ slug: "hanmac-family" })).toBe(true);
|
||||
expect(isSeedTenant({ slug: "normal-tenant" })).toBe(false);
|
||||
expect(
|
||||
isSeedTenant({
|
||||
id: "5a03efd2-e62f-4243-800d-58334bf48b2f",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSeedTenant({
|
||||
id: "5A03EFD2-E62F-4243-800D-58334BF48B2F",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(isSeedTenant({ id: "normal-tenant" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,16 +4,15 @@ import seedTenantCSVRaw from "../../../../seed-tenant.csv?raw";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import { parseTenantCSV } from "./tenantCsvImport";
|
||||
|
||||
const seedTenantSlugs = new Set(
|
||||
parseTenantCSV(seedTenantCSVRaw)
|
||||
.map((row) => row.slug.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
const seedTenants = parseTenantCSV(seedTenantCSVRaw);
|
||||
const seedTenantIds = new Set(
|
||||
seedTenants.map((row) => row.tenantId.trim().toLowerCase()).filter(Boolean),
|
||||
);
|
||||
|
||||
export function isSeedTenant(tenant: Pick<TenantSummary, "slug">): boolean {
|
||||
return seedTenantSlugs.has(tenant.slug.trim().toLowerCase());
|
||||
export function isSeedTenant(tenant: Pick<TenantSummary, "id">): boolean {
|
||||
return seedTenantIds.has(tenant.id.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function getSeedTenantSlugs(): string[] {
|
||||
return Array.from(seedTenantSlugs);
|
||||
export function getSeedTenantIds(): string[] {
|
||||
return Array.from(seedTenantIds);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import GlobalCustomClaimsPage from "./GlobalCustomClaimsPage";
|
||||
|
||||
const fetchGlobalCustomClaimDefinitionsMock = vi.hoisted(() => vi.fn());
|
||||
const updateGlobalCustomClaimDefinitionsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchGlobalCustomClaimDefinitions: fetchGlobalCustomClaimDefinitionsMock,
|
||||
updateGlobalCustomClaimDefinitions: updateGlobalCustomClaimDefinitionsMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../components/ui/use-toast", () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
function renderGlobalCustomClaimsPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<GlobalCustomClaimsPage />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("GlobalCustomClaimsPage", () => {
|
||||
beforeEach(() => {
|
||||
fetchGlobalCustomClaimDefinitionsMock.mockReset();
|
||||
fetchGlobalCustomClaimDefinitionsMock.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
key: "locale",
|
||||
label: "Locale",
|
||||
valueType: "text",
|
||||
readPermission: "admin_only",
|
||||
writePermission: "admin_only",
|
||||
description: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
updateGlobalCustomClaimDefinitionsMock.mockReset();
|
||||
updateGlobalCustomClaimDefinitionsMock.mockResolvedValue({ items: [] });
|
||||
});
|
||||
|
||||
it("forces user read permission on when user write permission is enabled", async () => {
|
||||
renderGlobalCustomClaimsPage();
|
||||
|
||||
const readSelect = await screen.findByTestId(
|
||||
"global-claim-definition-read-permission-locale",
|
||||
);
|
||||
const writeSelect = await screen.findByTestId(
|
||||
"global-claim-definition-write-permission-locale",
|
||||
);
|
||||
|
||||
expect(readSelect).toHaveValue("admin_only");
|
||||
expect(writeSelect).toHaveValue("admin_only");
|
||||
|
||||
fireEvent.change(writeSelect, { target: { value: "user_and_admin" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(readSelect).toHaveValue("user_and_admin");
|
||||
expect(writeSelect).toHaveValue("user_and_admin");
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /저장|Save/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateGlobalCustomClaimDefinitionsMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(updateGlobalCustomClaimDefinitionsMock.mock.calls[0][0]).toEqual({
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
key: "locale",
|
||||
readPermission: "user_and_admin",
|
||||
writePermission: "user_and_admin",
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,7 @@ function toDrafts(items: GlobalCustomClaimDefinition[]): ClaimDraft[] {
|
||||
|
||||
function toDefinitions(drafts: ClaimDraft[]): GlobalCustomClaimDefinition[] {
|
||||
return drafts
|
||||
.map((draft) => normalizeClaimDraftPermissions(draft))
|
||||
.map((draft) => ({
|
||||
key: draft.key.trim(),
|
||||
label: draft.label.trim(),
|
||||
@@ -63,6 +64,16 @@ function toDefinitions(drafts: ClaimDraft[]): GlobalCustomClaimDefinition[] {
|
||||
.filter((draft) => draft.key.length > 0);
|
||||
}
|
||||
|
||||
function normalizeClaimDraftPermissions(draft: ClaimDraft): ClaimDraft {
|
||||
if (draft.writePermission !== "user_and_admin") {
|
||||
return draft;
|
||||
}
|
||||
return {
|
||||
...draft,
|
||||
readPermission: "user_and_admin",
|
||||
};
|
||||
}
|
||||
|
||||
function permissionLabel(permission: GlobalCustomClaimPermission) {
|
||||
return permission === "user_and_admin"
|
||||
? t(
|
||||
@@ -116,7 +127,9 @@ export default function GlobalCustomClaimsPage() {
|
||||
const updateClaim = (id: string, patch: Partial<ClaimDraft>) => {
|
||||
setDrafts((current) =>
|
||||
current.map((draft) =>
|
||||
draft.id === id ? { ...draft, ...patch } : draft,
|
||||
draft.id === id
|
||||
? normalizeClaimDraftPermissions({ ...draft, ...patch })
|
||||
: draft,
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -140,7 +153,7 @@ export default function GlobalCustomClaimsPage() {
|
||||
)}
|
||||
description={t(
|
||||
"msg.admin.users.global_custom_claims.description",
|
||||
"모든 RP에 공통 적용할 사용자 claim 정의와 읽기/쓰기 권한 기본값을 관리합니다.",
|
||||
"모든 RP에 공통 적용할 사용자 claim 정의와 사용자의 읽기/쓰기 권한 기본값을 관리합니다. 쓰기 허용 시 읽기도 자동으로 허용됩니다.",
|
||||
)}
|
||||
actions={
|
||||
<>
|
||||
@@ -185,7 +198,7 @@ export default function GlobalCustomClaimsPage() {
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.admin.users.global_custom_claims.registry",
|
||||
"정의된 claim key만 사용자 상세의 전역 claim 값 관리 대상이 됩니다.",
|
||||
"정의된 claim key만 사용자 상세의 전역 claim 값 관리 대상이 됩니다. 읽기/쓰기는 관리자 권한이 아니라 사용자가 본인 claim 값을 조회하거나 수정할 수 있는지에 대한 설정입니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -2051,7 +2051,7 @@ function UserDetailPage() {
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.admin.users.detail.custom_claims.description",
|
||||
"전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. Claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다.",
|
||||
"전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. 읽기/쓰기 표시는 사용자가 본인 claim 값을 조회하거나 직접 수정할 수 있는지에 대한 권한이며, claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ const users = Array.from({ length: 200 }, (_, index) => ({
|
||||
}));
|
||||
|
||||
const fetchUsersMock = vi.hoisted(() => vi.fn());
|
||||
const fetchAllTenantsMock = vi.hoisted(() => vi.fn());
|
||||
const searchRenderBudgetMs =
|
||||
process.env.npm_lifecycle_event === "test:coverage" ? 500 : 300;
|
||||
|
||||
@@ -34,10 +35,7 @@ vi.mock("../../lib/adminApi", () => ({
|
||||
name: "Admin",
|
||||
email: "admin@example.com",
|
||||
})),
|
||||
fetchAllTenants: vi.fn(async () => ({
|
||||
items: [{ id: "tenant-1", name: "한맥", slug: "hanmac" }],
|
||||
total: 1,
|
||||
})),
|
||||
fetchAllTenants: fetchAllTenantsMock,
|
||||
fetchTenant: vi.fn(async () => ({
|
||||
id: "tenant-1",
|
||||
name: "한맥",
|
||||
@@ -108,6 +106,11 @@ describe("UserListPage search rendering", () => {
|
||||
beforeEach(() => {
|
||||
selectRenderCounter.count = 0;
|
||||
fetchUsersMock.mockReset();
|
||||
fetchAllTenantsMock.mockReset();
|
||||
fetchAllTenantsMock.mockResolvedValue({
|
||||
items: [{ id: "tenant-1", name: "한맥", slug: "hanmac" }],
|
||||
total: 1,
|
||||
});
|
||||
fetchUsersMock.mockImplementation(
|
||||
async (_limit: number, _offset: number, search?: string) => {
|
||||
const normalizedSearch = search?.trim().toLowerCase();
|
||||
@@ -157,7 +160,7 @@ describe("UserListPage search rendering", () => {
|
||||
expect(content).toHaveClass("flex", "h-full", "items-center");
|
||||
});
|
||||
|
||||
it("renders additional tenant appointments in the tenant column", async () => {
|
||||
it("does not render private additional tenant appointments in the tenant column", async () => {
|
||||
fetchUsersMock.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
@@ -183,7 +186,63 @@ describe("UserListPage search rendering", () => {
|
||||
expect(
|
||||
await screen.findByText("Additional Tenant User"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("비공개 팀")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("한맥").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.queryByText("비공개 팀")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("excludes private tenants when choosing the representative tenant for the user list", async () => {
|
||||
fetchAllTenantsMock.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
id: "tenant-private",
|
||||
name: "비공개 팀",
|
||||
slug: "private-team",
|
||||
config: { visibility: "private" },
|
||||
},
|
||||
{
|
||||
id: "tenant-public",
|
||||
name: "공개 팀",
|
||||
slug: "public-team",
|
||||
config: { visibility: "public" },
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
});
|
||||
fetchUsersMock.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
...users[0],
|
||||
name: "Private Primary User",
|
||||
tenantSlug: "private-team",
|
||||
tenant: {
|
||||
id: "tenant-private",
|
||||
name: "비공개 팀",
|
||||
slug: "private-team",
|
||||
config: { visibility: "private" },
|
||||
},
|
||||
joinedTenants: [
|
||||
{
|
||||
id: "tenant-public",
|
||||
name: "공개 팀",
|
||||
slug: "public-team",
|
||||
config: { visibility: "public" },
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
primaryTenantId: "tenant-private",
|
||||
primaryTenantSlug: "private-team",
|
||||
primaryTenantName: "비공개 팀",
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
});
|
||||
|
||||
renderUserListPage();
|
||||
|
||||
expect(await screen.findByText("Private Primary User")).toBeInTheDocument();
|
||||
expect(screen.getByText("공개 팀")).toBeInTheDocument();
|
||||
expect(screen.queryByText("비공개 팀")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("centers the initial loading message across the user table", async () => {
|
||||
|
||||
@@ -151,50 +151,111 @@ function assignableSystemRoleValue(role?: string | null) {
|
||||
return isSuperAdminRole(role) ? "super_admin" : "user";
|
||||
}
|
||||
|
||||
function collectAdditionalTenantLabels(user: UserSummary) {
|
||||
const primaryKeys = new Set(
|
||||
[user.tenant?.id, user.tenant?.slug, user.tenantSlug]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((value) => value.toLowerCase()),
|
||||
type RepresentativeTenantCandidate = {
|
||||
id?: string;
|
||||
slug?: string;
|
||||
name?: string;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function tenantVisibility(tenant?: RepresentativeTenantCandidate) {
|
||||
const visibility = tenant?.config?.visibility;
|
||||
return typeof visibility === "string" ? visibility.trim() : "";
|
||||
}
|
||||
|
||||
function findTenantCandidate(
|
||||
candidate: RepresentativeTenantCandidate,
|
||||
tenants: TenantSummary[],
|
||||
) {
|
||||
const id = candidate.id?.toLowerCase() ?? "";
|
||||
const slug = candidate.slug?.toLowerCase() ?? "";
|
||||
if (!id && !slug) return undefined;
|
||||
return tenants.find(
|
||||
(tenant) =>
|
||||
(id && tenant.id.toLowerCase() === id) ||
|
||||
(slug && tenant.slug.toLowerCase() === slug),
|
||||
);
|
||||
const labels: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const addLabel = (
|
||||
tenantId?: unknown,
|
||||
tenantSlug?: unknown,
|
||||
tenantName?: unknown,
|
||||
) => {
|
||||
const id = typeof tenantId === "string" ? tenantId.trim() : "";
|
||||
const slug = typeof tenantSlug === "string" ? tenantSlug.trim() : "";
|
||||
const name = typeof tenantName === "string" ? tenantName.trim() : "";
|
||||
const key = (id || slug || name).toLowerCase();
|
||||
if (!key || primaryKeys.has(key) || seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
labels.push(name || slug || id);
|
||||
};
|
||||
}
|
||||
|
||||
function isPrivateTenantCandidate(
|
||||
candidate: RepresentativeTenantCandidate,
|
||||
tenants: TenantSummary[],
|
||||
) {
|
||||
const tenant = findTenantCandidate(candidate, tenants) ?? candidate;
|
||||
return tenantVisibility(tenant) === "private";
|
||||
}
|
||||
|
||||
function candidateLabel(candidate: RepresentativeTenantCandidate) {
|
||||
return candidate.name || candidate.slug || candidate.id || "";
|
||||
}
|
||||
|
||||
function metadataTenantCandidate(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): RepresentativeTenantCandidate | null {
|
||||
const id = stringValue(metadata?.primaryTenantId);
|
||||
const slug = stringValue(metadata?.primaryTenantSlug);
|
||||
const name = stringValue(metadata?.primaryTenantName);
|
||||
if (!id && !slug && !name) return null;
|
||||
return { id, slug, name };
|
||||
}
|
||||
|
||||
function appointmentTenantCandidate(
|
||||
appointment: unknown,
|
||||
): RepresentativeTenantCandidate | null {
|
||||
if (!appointment || typeof appointment !== "object") return null;
|
||||
const value = appointment as Record<string, unknown>;
|
||||
const id = stringValue(value.tenantId);
|
||||
const slug = stringValue(value.tenantSlug ?? value.slug);
|
||||
const name = stringValue(value.tenantName ?? value.name);
|
||||
if (!id && !slug && !name) return null;
|
||||
return { id, slug, name };
|
||||
}
|
||||
|
||||
function resolveRepresentativeTenantLabel(
|
||||
user: UserSummary,
|
||||
tenants: TenantSummary[],
|
||||
) {
|
||||
const candidates: RepresentativeTenantCandidate[] = [];
|
||||
const knownTenants = [
|
||||
...(user.tenant ? [user.tenant] : []),
|
||||
...(user.joinedTenants ?? []),
|
||||
...tenants,
|
||||
];
|
||||
const primaryFromMetadata = metadataTenantCandidate(user.metadata);
|
||||
if (primaryFromMetadata) candidates.push(primaryFromMetadata);
|
||||
if (user.tenant) candidates.push(user.tenant);
|
||||
|
||||
for (const tenant of user.joinedTenants ?? []) {
|
||||
addLabel(tenant.id, tenant.slug, tenant.name);
|
||||
candidates.push(tenant);
|
||||
}
|
||||
|
||||
const appointments = user.metadata?.additionalAppointments;
|
||||
if (Array.isArray(appointments)) {
|
||||
for (const appointment of appointments) {
|
||||
if (!appointment || typeof appointment !== "object") {
|
||||
if (
|
||||
appointment &&
|
||||
typeof appointment === "object" &&
|
||||
(appointment as Record<string, unknown>).isPrimary !== true
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const value = appointment as Record<string, unknown>;
|
||||
addLabel(
|
||||
value.tenantId,
|
||||
value.tenantSlug ?? value.slug,
|
||||
value.tenantName ?? value.name,
|
||||
);
|
||||
const candidate = appointmentTenantCandidate(appointment);
|
||||
if (candidate) candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
if (user.tenantSlug) candidates.push({ slug: user.tenantSlug });
|
||||
|
||||
return labels;
|
||||
const representative = candidates.find(
|
||||
(candidate) =>
|
||||
candidateLabel(candidate) &&
|
||||
!isPrivateTenantCandidate(candidate, knownTenants),
|
||||
);
|
||||
|
||||
return candidateLabel(representative ?? {});
|
||||
}
|
||||
|
||||
function normalizeUserTableRect(rect: Rect, fallbackWidth: number): Rect {
|
||||
@@ -467,10 +528,10 @@ function UserListPage() {
|
||||
name_email: (user) =>
|
||||
`${user.name ?? ""} ${user.email ?? ""} ${user.phone ?? ""}`,
|
||||
tenant_dept: (user) =>
|
||||
`${user.tenant?.name ?? user.tenantSlug ?? ""} ${collectAdditionalTenantLabels(user).join(" ")} ${user.department ?? ""}`,
|
||||
`${resolveRepresentativeTenantLabel(user, tenants)} ${user.department ?? ""}`,
|
||||
},
|
||||
),
|
||||
[userSchema],
|
||||
[tenants, userSchema],
|
||||
);
|
||||
const items = React.useMemo(() => {
|
||||
if (!sortConfig) {
|
||||
@@ -1019,8 +1080,9 @@ function UserListPage() {
|
||||
virtualRows.map((virtualRow) => {
|
||||
const user = items[virtualRow.index];
|
||||
if (!user) return null;
|
||||
const additionalTenantLabels =
|
||||
collectAdditionalTenantLabels(user);
|
||||
const representativeTenantLabel =
|
||||
resolveRepresentativeTenantLabel(user, tenants) ||
|
||||
t("ui.common.unassigned", "미배정");
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
@@ -1151,27 +1213,13 @@ function UserListPage() {
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium">
|
||||
{user.tenant?.name ||
|
||||
user.tenantSlug ||
|
||||
t("ui.common.unassigned", "미배정")}
|
||||
{representativeTenantLabel}
|
||||
</span>
|
||||
{user.department && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.department}
|
||||
</span>
|
||||
)}
|
||||
{additionalTenantLabels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{additionalTenantLabels.map((label) => (
|
||||
<span
|
||||
key={label}
|
||||
className="max-w-40 truncate rounded border bg-muted/40 px-1.5 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* Dynamic Metadata Cells */}
|
||||
|
||||
@@ -348,9 +348,13 @@ update_error = "Failed to User Edit."
|
||||
update_success = "Update Success"
|
||||
|
||||
[msg.admin.users.detail.custom_claims]
|
||||
description = "Manage this user's values for globally defined custom claims. Add claim definitions and change types only from the global settings screen."
|
||||
description = "Manage this user's values for globally defined custom claims. Read/Write indicates whether the user may view or update their own claim value. Add claim definitions and change types only from the global settings screen."
|
||||
empty = "No global custom claims have been defined."
|
||||
|
||||
[msg.admin.users.global_custom_claims]
|
||||
description = "Manage user claim definitions shared across all RPs and the default user read/write permissions. Enabling write also enables read."
|
||||
registry = "Only defined claim keys are available in per-user global claim values. Read/Write is a user self-service permission, not an administrator permission."
|
||||
|
||||
[msg.admin.users.detail.form]
|
||||
field_required = "Required."
|
||||
invalid_format = "Invalid format."
|
||||
|
||||
@@ -353,9 +353,13 @@ update_success = "사용자 정보가 수정되었습니다."
|
||||
self_delete_blocked = "본인 계정은 삭제할 수 없습니다."
|
||||
|
||||
[msg.admin.users.detail.custom_claims]
|
||||
description = "전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. Claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다."
|
||||
description = "전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. 읽기/쓰기 표시는 사용자가 본인 claim 값을 조회하거나 직접 수정할 수 있는지에 대한 권한이며, claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다."
|
||||
empty = "전역으로 정의된 custom claim이 없습니다."
|
||||
|
||||
[msg.admin.users.global_custom_claims]
|
||||
description = "모든 RP에 공통 적용할 사용자 claim 정의와 사용자의 읽기/쓰기 권한 기본값을 관리합니다. 쓰기 허용 시 읽기도 자동으로 허용됩니다."
|
||||
registry = "정의된 claim key만 사용자 상세의 전역 claim 값 관리 대상이 됩니다. 읽기/쓰기는 관리자 권한이 아니라 사용자가 본인 claim 값을 조회하거나 수정할 수 있는지에 대한 설정입니다."
|
||||
|
||||
[msg.admin.users.detail.form]
|
||||
field_required = "필수입니다."
|
||||
invalid_format = "형식이 올바르지 않습니다."
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test";
|
||||
|
||||
const tenants = [
|
||||
{
|
||||
id: "seed-hanmac",
|
||||
id: "038326b6-954a-48a7-a85f-efd83f62b82a",
|
||||
name: "한맥가족",
|
||||
slug: "hanmac-family",
|
||||
type: "COMPANY_GROUP",
|
||||
@@ -13,6 +13,19 @@ const tenants = [
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
{
|
||||
id: "5a03efd2-e62f-4243-800d-58334bf48b2f",
|
||||
name: "한라산업개발",
|
||||
slug: "hallasanup",
|
||||
type: "COMPANY",
|
||||
description: "네이버웍스 한라 HALLA_DOMAIN_ID",
|
||||
status: "active",
|
||||
domains: ["hallasanup.com"],
|
||||
memberCount: 0,
|
||||
parentId: "038326b6-954a-48a7-a85f-efd83f62b82a",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
{
|
||||
id: "normal-tenant",
|
||||
name: "일반 테넌트",
|
||||
@@ -96,11 +109,21 @@ test.describe("Seed tenant protection", () => {
|
||||
}) => {
|
||||
await page.goto("/tenants");
|
||||
|
||||
const seedRow = page.getByRole("row", { name: /한맥가족/ });
|
||||
const seedRow = page.getByRole("row").filter({
|
||||
has: page.getByRole("link", { name: "한맥가족", exact: true }),
|
||||
});
|
||||
await expect(seedRow.getByRole("checkbox")).toHaveCount(0);
|
||||
await expect(seedRow.getByText("초기 설정")).toBeVisible();
|
||||
|
||||
const normalRow = page.getByRole("row", { name: /일반 테넌트/ });
|
||||
const hallaRow = page.getByRole("row").filter({
|
||||
has: page.getByRole("link", { name: "한라산업개발", exact: true }),
|
||||
});
|
||||
await expect(hallaRow.getByRole("checkbox")).toHaveCount(0);
|
||||
await expect(hallaRow.getByText("초기 설정")).toBeVisible();
|
||||
|
||||
const normalRow = page.getByRole("row").filter({
|
||||
has: page.getByRole("link", { name: "일반 테넌트", exact: true }),
|
||||
});
|
||||
await expect(normalRow.getByRole("checkbox")).toBeEnabled();
|
||||
});
|
||||
|
||||
|
||||
@@ -293,6 +293,94 @@ test.describe("User Management", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("should hide private representative tenants in the user list row", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route(/\/admin\/tenants(\?.*)?$/, async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
return route.fallback();
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
id: "tenant-private",
|
||||
name: "비공개 팀",
|
||||
slug: "private-team",
|
||||
type: "USER_GROUP",
|
||||
status: "active",
|
||||
config: { visibility: "private" },
|
||||
},
|
||||
{
|
||||
id: "tenant-public",
|
||||
name: "공개 팀",
|
||||
slug: "public-team",
|
||||
type: "USER_GROUP",
|
||||
status: "active",
|
||||
config: { visibility: "public" },
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.route(/\/admin\/users(\?.*)?$/, async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
return route.fallback();
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
id: "u-private",
|
||||
name: "Private Primary User",
|
||||
email: "private-primary@example.com",
|
||||
phone: "010-0000-0000",
|
||||
loginId: "private-primary",
|
||||
role: "user",
|
||||
status: "active",
|
||||
tenantSlug: "private-team",
|
||||
tenant: {
|
||||
id: "tenant-private",
|
||||
name: "비공개 팀",
|
||||
slug: "private-team",
|
||||
config: { visibility: "private" },
|
||||
},
|
||||
joinedTenants: [
|
||||
{
|
||||
id: "tenant-public",
|
||||
name: "공개 팀",
|
||||
slug: "public-team",
|
||||
config: { visibility: "public" },
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
primaryTenantId: "tenant-private",
|
||||
primaryTenantSlug: "private-team",
|
||||
primaryTenantName: "비공개 팀",
|
||||
},
|
||||
createdAt: "2026-04-01T00:00:00Z",
|
||||
updatedAt: "2026-04-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/users");
|
||||
|
||||
const row = page.getByRole("row").filter({
|
||||
hasText: "Private Primary User",
|
||||
});
|
||||
await expect(row).toContainText("공개 팀");
|
||||
await expect(row).not.toContainText("비공개 팀");
|
||||
});
|
||||
|
||||
test("should successfully edit a user's Login ID", async ({ page }) => {
|
||||
await page.goto("/users/u-1");
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -51,6 +53,10 @@ func SeedTenants(db *gorm.DB) error {
|
||||
return errors.New("seed tenant csv has no tenant rows")
|
||||
}
|
||||
|
||||
if err := syncExistingSeedTenantConfigs(db, configs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existingSlugs, existingIDs, err := loadExistingTenantIdentitySet(db)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -71,6 +77,69 @@ func SeedTenants(db *gorm.DB) error {
|
||||
return seedTenantConfigs(db, missingConfigs)
|
||||
}
|
||||
|
||||
func syncExistingSeedTenantConfigs(db *gorm.DB, configs []InitialTenantConfig) error {
|
||||
for _, config := range configs {
|
||||
id := strings.TrimSpace(config.TenantID)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
targetSlug := strings.TrimSpace(strings.ToLower(config.Slug))
|
||||
if targetSlug == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var tenant domain.Tenant
|
||||
if err := tx.First(&tenant, "id = ?", id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("load existing seed tenant %q: %w", id, err)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(strings.ToLower(tenant.Slug)) == targetSlug {
|
||||
return nil
|
||||
}
|
||||
|
||||
var conflict domain.Tenant
|
||||
err := tx.Select("id").
|
||||
Where("LOWER(TRIM(slug)) = ? AND id <> ?", targetSlug, tenant.ID).
|
||||
First(&conflict).Error
|
||||
if err == nil {
|
||||
return fmt.Errorf("seed tenant slug %q for id %q conflicts with tenant id %q", targetSlug, id, conflict.ID)
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Errorf("check seed tenant slug conflict %q: %w", targetSlug, err)
|
||||
}
|
||||
|
||||
suffix := "-deleted-" + strconv.FormatInt(time.Now().UTC().UnixNano(), 10)
|
||||
if err := tx.Unscoped().
|
||||
Model(&domain.Tenant{}).
|
||||
Where("slug = ? AND id <> ? AND deleted_at IS NOT NULL", targetSlug, tenant.ID).
|
||||
Update("slug", gorm.Expr("slug || ?", suffix)).Error; err != nil {
|
||||
return fmt.Errorf("rename deleted tenant slug %q before seed repair: %w", targetSlug, err)
|
||||
}
|
||||
|
||||
slog.Info(
|
||||
"[Bootstrap] Repairing existing seed tenant slug",
|
||||
"id", tenant.ID,
|
||||
"oldSlug", tenant.Slug,
|
||||
"newSlug", targetSlug,
|
||||
)
|
||||
if err := tx.Model(&domain.Tenant{}).
|
||||
Where("id = ?", tenant.ID).
|
||||
Update("slug", targetSlug).Error; err != nil {
|
||||
return fmt.Errorf("repair seed tenant slug %q for id %q: %w", targetSlug, id, err)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadExistingTenantIdentitySet(db *gorm.DB) (map[string]bool, map[string]bool, error) {
|
||||
var tenants []domain.Tenant
|
||||
if err := db.Select("id", "slug").Find(&tenants).Error; err != nil {
|
||||
|
||||
@@ -273,7 +273,7 @@ func TestFilterMissingSeedTenantConfigsSkipsExistingSlugs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedTenantsCreatesMissingSeedRowsWithoutTouchingExistingSlugs(t *testing.T) {
|
||||
func TestSeedTenantsCreatesMissingSeedRowsAndRepairsExistingSeedSlug(t *testing.T) {
|
||||
if !testsupport.DockerAvailable() {
|
||||
t.Skip("Docker provider is unavailable in this environment")
|
||||
}
|
||||
@@ -326,18 +326,30 @@ func TestSeedTenantsCreatesMissingSeedRowsWithoutTouchingExistingSlugs(t *testin
|
||||
Type: domain.TenantTypeCompany,
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
existingSeedTenantWithTypoSlug := domain.Tenant{
|
||||
ID: "5a03efd2-e62f-4243-800d-58334bf48b2f",
|
||||
Name: "한라산업개발",
|
||||
Slug: "hanlla",
|
||||
Type: domain.TenantTypeCompany,
|
||||
Description: "seed tenant with a typo slug must be repaired by UUID",
|
||||
Status: domain.TenantStatusActive,
|
||||
ParentID: &existingRoot.ID,
|
||||
}
|
||||
if err := db.Create(&existingRoot).Error; err != nil {
|
||||
t.Fatalf("failed to create existing root tenant: %v", err)
|
||||
}
|
||||
if err := db.Create(&nonSeedTenant).Error; err != nil {
|
||||
t.Fatalf("failed to create non-seed tenant: %v", err)
|
||||
}
|
||||
if err := db.Create(&existingSeedTenantWithTypoSlug).Error; err != nil {
|
||||
t.Fatalf("failed to create existing seed tenant with typo slug: %v", err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "seed-tenant.csv")
|
||||
csv := "id,name,type,parent_tenant_slug,slug,memo,email_domain\n" +
|
||||
"10000000-0000-0000-0000-000000000001,Seed Root Name,COMPANY_GROUP,,existing-root,seed must be skipped,\n" +
|
||||
"00000000-0000-0000-0000-000000000002,Conflicting ID,COMPANY,existing-root,conflicting-id,seed id must be skipped,\n" +
|
||||
"5a03efd2-e62f-4243-800d-58334bf48b2f,한라산업개발,COMPANY,existing-root,halla,seed typo slug must be repaired,hallasanup.com\n" +
|
||||
"10000000-0000-0000-0000-000000000002,Missing Child,COMPANY,existing-root,missing-child,created from seed,child.example.com\n"
|
||||
if err := os.WriteFile(path, []byte(csv), 0o600); err != nil {
|
||||
t.Fatalf("failed to write seed csv: %v", err)
|
||||
@@ -359,6 +371,22 @@ func TestSeedTenantsCreatesMissingSeedRowsWithoutTouchingExistingSlugs(t *testin
|
||||
t.Fatalf("existing root name = %q, want untouched %q", root.Name, existingRoot.Name)
|
||||
}
|
||||
|
||||
var manual domain.Tenant
|
||||
if err := db.First(&manual, "id = ?", nonSeedTenant.ID).Error; err != nil {
|
||||
t.Fatalf("failed to load non-seed tenant after seed: %v", err)
|
||||
}
|
||||
if manual.Slug != nonSeedTenant.Slug {
|
||||
t.Fatalf("non-seed tenant slug = %q, want untouched %q", manual.Slug, nonSeedTenant.Slug)
|
||||
}
|
||||
|
||||
var repairedSeed domain.Tenant
|
||||
if err := db.First(&repairedSeed, "id = ?", existingSeedTenantWithTypoSlug.ID).Error; err != nil {
|
||||
t.Fatalf("failed to load existing seed tenant after seed: %v", err)
|
||||
}
|
||||
if repairedSeed.Slug != "halla" {
|
||||
t.Fatalf("existing seed tenant slug = %q, want halla", repairedSeed.Slug)
|
||||
}
|
||||
|
||||
var child domain.Tenant
|
||||
if err := db.Preload("Domains").First(&child, "slug = ?", "missing-child").Error; err != nil {
|
||||
t.Fatalf("missing seed child was not created: %v", err)
|
||||
@@ -378,11 +406,4 @@ func TestSeedTenantsCreatesMissingSeedRowsWithoutTouchingExistingSlugs(t *testin
|
||||
t.Fatalf("existing-root row count = %d, want 1", rootCount)
|
||||
}
|
||||
|
||||
var conflictingIDCount int64
|
||||
if err := db.Model(&domain.Tenant{}).Where("slug = ?", "conflicting-id").Count(&conflictingIDCount).Error; err != nil {
|
||||
t.Fatalf("failed to count conflicting-id rows: %v", err)
|
||||
}
|
||||
if conflictingIDCount != 0 {
|
||||
t.Fatalf("conflicting-id row count = %d, want 0", conflictingIDCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"math"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -1646,6 +1647,154 @@ func applyConfiguredIDTokenClaims(baseClaims map[string]any, metadata map[string
|
||||
return baseClaims
|
||||
}
|
||||
|
||||
func (h *AuthHandler) withRPUserMetadataClaims(ctx context.Context, claims map[string]any, client domain.HydraClient, subject string) map[string]any {
|
||||
if claims == nil {
|
||||
claims = map[string]any{}
|
||||
}
|
||||
if h == nil || h.RPUserMetadataRepo == nil {
|
||||
return claims
|
||||
}
|
||||
|
||||
clientID := strings.TrimSpace(client.ClientID)
|
||||
subject = strings.TrimSpace(subject)
|
||||
if clientID == "" || subject == "" {
|
||||
return claims
|
||||
}
|
||||
|
||||
rpClaimDefinitions := extractRPClaimDefinitions(client.Metadata)
|
||||
if len(rpClaimDefinitions) == 0 {
|
||||
return claims
|
||||
}
|
||||
|
||||
row, err := h.RPUserMetadataRepo.Get(ctx, clientID, subject)
|
||||
if err != nil || row == nil || len(row.Metadata) == 0 {
|
||||
return claims
|
||||
}
|
||||
|
||||
rpClaims, _ := claims["rp_claims"].(map[string]any)
|
||||
if rpClaims == nil {
|
||||
rpClaims = map[string]any{}
|
||||
}
|
||||
|
||||
for _, claim := range rpClaimDefinitions {
|
||||
raw, ok := row.Metadata[claim.Key]
|
||||
if !ok || raw == nil {
|
||||
continue
|
||||
}
|
||||
value, ok := coerceRPUserMetadataClaimValue(raw, claim.ValueType)
|
||||
if !ok {
|
||||
slog.Warn("failed to coerce rp user metadata claim", "client_id", clientID, "subject", subject, "key", claim.Key, "value_type", claim.ValueType)
|
||||
continue
|
||||
}
|
||||
rpClaims[claim.Key] = value
|
||||
}
|
||||
|
||||
if len(rpClaims) > 0 {
|
||||
claims["rp_claims"] = rpClaims
|
||||
}
|
||||
return claims
|
||||
}
|
||||
|
||||
func extractRPClaimDefinitions(metadata map[string]any) []normalizedIDTokenClaim {
|
||||
if metadata == nil {
|
||||
return nil
|
||||
}
|
||||
rawClaims, ok := metadata[domain.MetadataIDTokenClaims]
|
||||
if !ok || rawClaims == nil {
|
||||
return nil
|
||||
}
|
||||
normalizedClaims, err := normalizeIDTokenClaims(rawClaims)
|
||||
if err != nil {
|
||||
slog.Warn("failed to normalize rp claim definitions", "error", err)
|
||||
return nil
|
||||
}
|
||||
definitions := make([]normalizedIDTokenClaim, 0, len(normalizedClaims))
|
||||
seen := make(map[string]struct{}, len(normalizedClaims))
|
||||
for _, claim := range normalizedClaims {
|
||||
if claim.Namespace != "rp_claims" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[claim.Key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[claim.Key] = struct{}{}
|
||||
definitions = append(definitions, claim)
|
||||
}
|
||||
return definitions
|
||||
}
|
||||
|
||||
func coerceRPUserMetadataClaimValue(raw any, valueType string) (any, bool) {
|
||||
switch value := raw.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil, false
|
||||
}
|
||||
parsed, err := parseConfiguredClaimValue(value, valueType)
|
||||
return parsed, err == nil
|
||||
case []any:
|
||||
if valueType == "array" {
|
||||
return value, true
|
||||
}
|
||||
case []string:
|
||||
if valueType == "array" {
|
||||
items := make([]any, 0, len(value))
|
||||
for _, item := range value {
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, true
|
||||
}
|
||||
case map[string]any:
|
||||
if valueType == "object" {
|
||||
return value, true
|
||||
}
|
||||
case bool:
|
||||
if valueType == "boolean" {
|
||||
return value, true
|
||||
}
|
||||
case float64:
|
||||
if valueType == "float" {
|
||||
return value, true
|
||||
}
|
||||
if valueType == "number" && value == math.Trunc(value) {
|
||||
return value, true
|
||||
}
|
||||
case float32:
|
||||
floatValue := float64(value)
|
||||
if valueType == "float" {
|
||||
return floatValue, true
|
||||
}
|
||||
if valueType == "number" && floatValue == math.Trunc(floatValue) {
|
||||
return floatValue, true
|
||||
}
|
||||
case int:
|
||||
if valueType == "number" {
|
||||
return float64(value), true
|
||||
}
|
||||
if valueType == "float" {
|
||||
return float64(value), true
|
||||
}
|
||||
case int64:
|
||||
if valueType == "number" {
|
||||
return float64(value), true
|
||||
}
|
||||
if valueType == "float" {
|
||||
return float64(value), true
|
||||
}
|
||||
case json.Number:
|
||||
if valueType == "number" {
|
||||
parsed, err := value.Int64()
|
||||
return float64(parsed), err == nil
|
||||
}
|
||||
if valueType == "float" {
|
||||
parsed, err := value.Float64()
|
||||
return parsed, err == nil
|
||||
}
|
||||
}
|
||||
|
||||
parsed, err := parseConfiguredClaimValue(fmt.Sprint(raw), valueType)
|
||||
return parsed, err == nil
|
||||
}
|
||||
|
||||
func (h *AuthHandler) withRPProfileClaims(ctx context.Context, claims map[string]any, client domain.HydraClient, subject string) map[string]any {
|
||||
if claims == nil {
|
||||
claims = map[string]any{}
|
||||
@@ -6046,6 +6195,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
|
||||
currentSessionID,
|
||||
)
|
||||
sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
|
||||
sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||
acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims)
|
||||
if err == nil {
|
||||
@@ -6084,6 +6234,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
|
||||
currentSessionID,
|
||||
)
|
||||
sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
|
||||
sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||
|
||||
// [Debug] 실제 생성된 클레임 출력 (요청사항 확인용 - 자동 승인 시)
|
||||
@@ -6275,6 +6426,7 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
|
||||
currentSessionID,
|
||||
)
|
||||
sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
|
||||
sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||
|
||||
// [Debug] 실제 생성된 클레임 출력 (요청사항 확인용)
|
||||
|
||||
@@ -827,3 +827,148 @@ func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) {
|
||||
assert.Equal(t, []any{"sso", "claims"}, rpClaims["features"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptConsentRequest_UsesUpdatedRPUserMetadataForRPClaims(t *testing.T) {
|
||||
var capturedClaims map[string]any
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-rp-user-claims" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"challenge": "challenge-rp-user-claims",
|
||||
"requested_scope": []string{"openid", "profile"},
|
||||
"subject": "user-rp-claims",
|
||||
"client": map[string]any{
|
||||
"client_id": "client-rp-claims",
|
||||
"metadata": map[string]any{
|
||||
"id_token_claims": []map[string]any{
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "approvalLevel",
|
||||
"value": "A",
|
||||
"valueType": "text",
|
||||
},
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "activeMember",
|
||||
"value": "true",
|
||||
"valueType": "boolean",
|
||||
},
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "score",
|
||||
"value": "1",
|
||||
"valueType": "number",
|
||||
},
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "featureList",
|
||||
"value": `["default"]`,
|
||||
"valueType": "array",
|
||||
},
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "preferences",
|
||||
"value": `{"theme":"light","density":"comfortable"}`,
|
||||
"valueType": "object",
|
||||
},
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "contractDate",
|
||||
"value": "2026-06-09",
|
||||
"valueType": "date",
|
||||
},
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "approvedAt",
|
||||
"value": "2026-06-09T09:30",
|
||||
"valueType": "datetime",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-rp-user-claims" {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var acceptReq map[string]any
|
||||
json.Unmarshal(body, &acceptReq)
|
||||
if session, ok := acceptReq["session"].(map[string]any); ok {
|
||||
capturedClaims = session["id_token"].(map[string]any)
|
||||
}
|
||||
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"redirect_to": "http://rp/cb",
|
||||
}), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})
|
||||
|
||||
client := &http.Client{Transport: transport}
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
KratosAdmin: new(MockKratosAdminService),
|
||||
}
|
||||
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-rp-claims").Return(&service.KratosIdentity{
|
||||
ID: "user-rp-claims",
|
||||
Traits: map[string]any{
|
||||
"email": "rp-user@example.com",
|
||||
"name": "RP User",
|
||||
},
|
||||
}, nil)
|
||||
repo := new(devMockRPUserMetadataRepo)
|
||||
repo.On("Get", mock.Anything, "client-rp-claims", "user-rp-claims").Return(&domain.RPUserMetadata{
|
||||
ClientID: "client-rp-claims",
|
||||
UserID: "user-rp-claims",
|
||||
Metadata: domain.JSONMap{
|
||||
"approvalLevel": "B",
|
||||
"activeMember": false,
|
||||
"score": float64(42),
|
||||
"featureList": []any{"sso", "claims"},
|
||||
"preferences": map[string]any{
|
||||
"theme": "dark",
|
||||
"density": "compact",
|
||||
},
|
||||
"contractDate": "2026-06-10",
|
||||
"approvedAt": "2026-06-09T10:30",
|
||||
"internalMemo": "must-not-leak",
|
||||
"approvalLevel_permissions": map[string]any{
|
||||
"readPermission": "admin_only",
|
||||
"writePermission": "user_and_admin",
|
||||
},
|
||||
},
|
||||
}, nil).Once()
|
||||
h.RPUserMetadataRepo = repo
|
||||
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest)
|
||||
|
||||
reqBody, _ := json.Marshal(map[string]any{
|
||||
"consent_challenge": "challenge-rp-user-claims",
|
||||
"grant_scope": []string{"openid", "profile"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
assert.NotNil(t, capturedClaims)
|
||||
rpClaims, ok := capturedClaims["rp_claims"].(map[string]any)
|
||||
if assert.True(t, ok) {
|
||||
assert.Equal(t, "B", rpClaims["approvalLevel"])
|
||||
assert.Equal(t, false, rpClaims["activeMember"])
|
||||
assert.Equal(t, float64(42), rpClaims["score"])
|
||||
assert.Equal(t, []any{"sso", "claims"}, rpClaims["featureList"])
|
||||
assert.Equal(t, map[string]any{"theme": "dark", "density": "compact"}, rpClaims["preferences"])
|
||||
assert.Equal(t, "2026-06-10", rpClaims["contractDate"])
|
||||
assert.Equal(t, "2026-06-09T10:30", rpClaims["approvedAt"])
|
||||
assert.NotContains(t, rpClaims, "internalMemo")
|
||||
assert.NotContains(t, rpClaims, "approvalLevel_permissions")
|
||||
}
|
||||
assert.NotContains(t, capturedClaims, "rp_profiles")
|
||||
repo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
@@ -3588,7 +3588,7 @@ func normalizeIDTokenClaimsWithOptions(rawClaims any, allowTopLevel bool) ([]nor
|
||||
valueType = "text"
|
||||
}
|
||||
switch valueType {
|
||||
case "text", "number", "boolean", "array", "object", "date", "datetime":
|
||||
case "text", "number", "float", "boolean", "array", "object", "date", "datetime":
|
||||
default:
|
||||
return nil, fmt.Errorf("metadata.id_token_claims valueType is invalid: %s", valueType)
|
||||
}
|
||||
@@ -3641,9 +3641,24 @@ func parseConfiguredClaimValue(rawValue string, valueType string) (any, error) {
|
||||
if trimmed == "" {
|
||||
return nil, errors.New("number value is required")
|
||||
}
|
||||
if !isIntegerClaimLiteral(trimmed) {
|
||||
return nil, errors.New("number value must be an integer")
|
||||
}
|
||||
parsed, err := strconv.ParseFloat(trimmed, 64)
|
||||
if err != nil {
|
||||
return nil, errors.New("number value must be a finite number")
|
||||
return nil, errors.New("number value must be an integer")
|
||||
}
|
||||
return parsed, nil
|
||||
case "float":
|
||||
if trimmed == "" {
|
||||
return nil, errors.New("float value is required")
|
||||
}
|
||||
if !isFloatClaimLiteral(trimmed) {
|
||||
return nil, errors.New("float value must be a finite decimal number")
|
||||
}
|
||||
parsed, err := strconv.ParseFloat(trimmed, 64)
|
||||
if err != nil {
|
||||
return nil, errors.New("float value must be a finite decimal number")
|
||||
}
|
||||
return parsed, nil
|
||||
case "boolean":
|
||||
@@ -3708,6 +3723,54 @@ func parseConfiguredClaimValue(rawValue string, valueType string) (any, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func isIntegerClaimLiteral(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
start := 0
|
||||
if value[0] == '-' {
|
||||
if len(value) == 1 {
|
||||
return false
|
||||
}
|
||||
start = 1
|
||||
}
|
||||
for _, char := range value[start:] {
|
||||
if char < '0' || char > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isFloatClaimLiteral(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
start := 0
|
||||
if value[0] == '-' {
|
||||
if len(value) == 1 {
|
||||
return false
|
||||
}
|
||||
start = 1
|
||||
}
|
||||
hasDigit := false
|
||||
hasDot := false
|
||||
for _, char := range value[start:] {
|
||||
switch {
|
||||
case char >= '0' && char <= '9':
|
||||
hasDigit = true
|
||||
case char == '.':
|
||||
if hasDot {
|
||||
return false
|
||||
}
|
||||
hasDot = true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasDigit
|
||||
}
|
||||
|
||||
func requestIncludesInlineHeadlessJWKS(req clientUpsertRequest) bool {
|
||||
if req.Jwks != nil {
|
||||
return true
|
||||
|
||||
@@ -60,6 +60,30 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
|
||||
"valueType": "number",
|
||||
"value": "1",
|
||||
},
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "featureList",
|
||||
"valueType": "array",
|
||||
"value": `["default"]`,
|
||||
},
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "preferences",
|
||||
"valueType": "object",
|
||||
"value": `{"theme":"light","density":"comfortable"}`,
|
||||
},
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "contractDate",
|
||||
"valueType": "date",
|
||||
"value": "2026-06-09",
|
||||
},
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "approvedAt",
|
||||
"valueType": "datetime",
|
||||
"value": "2026-06-09T09:30",
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
@@ -74,8 +98,14 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
|
||||
row.Metadata["approvalLevel"] == "A" &&
|
||||
row.Metadata["activeMember"] == false &&
|
||||
row.Metadata["score"] == float64(42) &&
|
||||
assert.ObjectsAreEqual([]any{"sso", "claims"}, row.Metadata["featureList"]) &&
|
||||
assert.ObjectsAreEqual(map[string]any{"theme": "dark", "density": "compact"}, row.Metadata["preferences"]) &&
|
||||
row.Metadata["contractDate"] == "2026-06-10" &&
|
||||
row.Metadata["approvedAt"] == "2026-06-09T10:30" &&
|
||||
row.Metadata["approvalLevel_permissions"].(map[string]any)["readPermission"] == "admin_only" &&
|
||||
row.Metadata["approvalLevel_permissions"].(map[string]any)["writePermission"] == "user_and_admin"
|
||||
row.Metadata["approvalLevel_permissions"].(map[string]any)["writePermission"] == "user_and_admin" &&
|
||||
row.Metadata["featureList_permissions"].(map[string]any)["readPermission"] == "admin_only" &&
|
||||
row.Metadata["featureList_permissions"].(map[string]any)["writePermission"] == "admin_only"
|
||||
})).Return(nil).Once()
|
||||
repo.On("Get", mock.Anything, "client-1", "user-1").Return(&domain.RPUserMetadata{
|
||||
ClientID: "client-1",
|
||||
@@ -103,6 +133,13 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
|
||||
"approvalLevel": "A",
|
||||
"activeMember": false,
|
||||
"score": 42,
|
||||
"featureList": []string{"sso", "claims"},
|
||||
"preferences": map[string]any{
|
||||
"theme": "dark",
|
||||
"density": "compact",
|
||||
},
|
||||
"contractDate": "2026-06-10",
|
||||
"approvedAt": "2026-06-09T10:30",
|
||||
"approvalLevel_permissions": map[string]any{
|
||||
"writePermission": "user_and_admin",
|
||||
},
|
||||
|
||||
@@ -2520,6 +2520,13 @@ func TestCreateClient_NormalizesIDTokenClaimsMetadata(t *testing.T) {
|
||||
"value": "2",
|
||||
"valueType": "number",
|
||||
},
|
||||
{
|
||||
"id": "claim-3",
|
||||
"namespace": "rp_claims",
|
||||
"key": "ratio",
|
||||
"value": "3.14",
|
||||
"valueType": "float",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -2530,7 +2537,7 @@ func TestCreateClient_NormalizesIDTokenClaimsMetadata(t *testing.T) {
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
claims, ok := captured.Metadata[domain.MetadataIDTokenClaims].([]any)
|
||||
if assert.True(t, ok) && assert.Len(t, claims, 2) {
|
||||
if assert.True(t, ok) && assert.Len(t, claims, 3) {
|
||||
first, ok := claims[0].(map[string]any)
|
||||
if assert.True(t, ok) {
|
||||
assert.Equal(t, "rp_claims", first["namespace"])
|
||||
@@ -2548,6 +2555,14 @@ func TestCreateClient_NormalizesIDTokenClaimsMetadata(t *testing.T) {
|
||||
assert.Equal(t, "2", second["value"])
|
||||
assert.Equal(t, "number", second["valueType"])
|
||||
}
|
||||
|
||||
third, ok := claims[2].(map[string]any)
|
||||
if assert.True(t, ok) {
|
||||
assert.Equal(t, "rp_claims", third["namespace"])
|
||||
assert.Equal(t, "ratio", third["key"])
|
||||
assert.Equal(t, "3.14", third["value"])
|
||||
assert.Equal(t, "float", third["valueType"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
328
backend/internal/handler/rp_claims_e2e_test.go
Normal file
328
backend/internal/handler/rp_claims_e2e_test.go
Normal file
@@ -0,0 +1,328 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type rpClaimsE2ERepo struct {
|
||||
mu sync.Mutex
|
||||
rows map[string]*domain.RPUserMetadata
|
||||
getKeys []string
|
||||
}
|
||||
|
||||
func newRPClaimsE2ERepo() *rpClaimsE2ERepo {
|
||||
return &rpClaimsE2ERepo{rows: map[string]*domain.RPUserMetadata{}}
|
||||
}
|
||||
|
||||
func (r *rpClaimsE2ERepo) Get(ctx context.Context, clientID, userID string) (*domain.RPUserMetadata, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
key := rpClaimsE2ERepoKey(clientID, userID)
|
||||
r.getKeys = append(r.getKeys, key)
|
||||
row, ok := r.rows[key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("rp user metadata not found")
|
||||
}
|
||||
return cloneRPUserMetadata(row), nil
|
||||
}
|
||||
|
||||
func (r *rpClaimsE2ERepo) Upsert(ctx context.Context, metadata *domain.RPUserMetadata) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
r.rows[rpClaimsE2ERepoKey(metadata.ClientID, metadata.UserID)] = cloneRPUserMetadata(metadata)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rpClaimsE2ERepo) seenGet(clientID, userID string) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
key := rpClaimsE2ERepoKey(clientID, userID)
|
||||
for _, seen := range r.getKeys {
|
||||
if seen == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func rpClaimsE2ERepoKey(clientID, userID string) string {
|
||||
return strings.TrimSpace(clientID) + "\x00" + strings.TrimSpace(userID)
|
||||
}
|
||||
|
||||
func cloneRPUserMetadata(row *domain.RPUserMetadata) *domain.RPUserMetadata {
|
||||
if row == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := &domain.RPUserMetadata{
|
||||
ClientID: row.ClientID,
|
||||
UserID: row.UserID,
|
||||
Metadata: domain.JSONMap{},
|
||||
}
|
||||
for key, value := range row.Metadata {
|
||||
cloned.Metadata[key] = value
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func TestRPClaimsE2E_UpdatedClaimsAreScopedToCurrentRP(t *testing.T) {
|
||||
const userID = "user-rp-e2e"
|
||||
const clientA = "client-rp-a"
|
||||
const clientB = "client-rp-b"
|
||||
|
||||
clients := map[string]map[string]any{
|
||||
clientA: rpClaimsE2EClient(clientA, []map[string]any{
|
||||
rpClaimsE2EClaim("approvalLevel", "text", "A", "user_and_admin", "user_and_admin"),
|
||||
rpClaimsE2EClaim("activeMember", "boolean", "true", "user_and_admin", "user_and_admin"),
|
||||
rpClaimsE2EClaim("score", "number", "1", "user_and_admin", "user_and_admin"),
|
||||
rpClaimsE2EClaim("featureList", "array", `["default"]`, "user_and_admin", "user_and_admin"),
|
||||
rpClaimsE2EClaim("preferences", "object", `{"theme":"light","density":"comfortable"}`, "user_and_admin", "user_and_admin"),
|
||||
rpClaimsE2EClaim("contractDate", "date", "2026-06-09", "user_and_admin", "user_and_admin"),
|
||||
rpClaimsE2EClaim("approvedAt", "datetime", "2026-06-09T09:30", "user_and_admin", "user_and_admin"),
|
||||
rpClaimsE2EClaim("adminManagedNote", "text", "admin-default", "user_and_admin", "admin_only"),
|
||||
}),
|
||||
clientB: rpClaimsE2EClient(clientB, []map[string]any{
|
||||
rpClaimsE2EClaim("approvalLevel", "text", "B-default", "user_and_admin", "user_and_admin"),
|
||||
rpClaimsE2EClaim("activeMember", "boolean", "false", "user_and_admin", "user_and_admin"),
|
||||
}),
|
||||
}
|
||||
challenges := map[string]string{
|
||||
"challenge-client-a-default": clientA,
|
||||
"challenge-client-a-admin-update": clientA,
|
||||
"challenge-client-a-self-update": clientA,
|
||||
"challenge-client-b-default": clientB,
|
||||
"challenge-client-b-update": clientB,
|
||||
}
|
||||
capturedClaims := map[string]map[string]any{}
|
||||
hydraClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if strings.HasPrefix(r.URL.Path, "/clients/") {
|
||||
clientID := strings.TrimPrefix(r.URL.Path, "/clients/")
|
||||
client, ok := clients[clientID]
|
||||
if !ok {
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusOK, client), nil
|
||||
}
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent" {
|
||||
challenge := r.URL.Query().Get("consent_challenge")
|
||||
clientID, ok := challenges[challenge]
|
||||
if !ok {
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"challenge": challenge,
|
||||
"requested_scope": []string{"openid", "profile"},
|
||||
"subject": userID,
|
||||
"client": clients[clientID],
|
||||
}), nil
|
||||
}
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent/accept" {
|
||||
challenge := r.URL.Query().Get("consent_challenge")
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var acceptReq map[string]any
|
||||
_ = json.Unmarshal(body, &acceptReq)
|
||||
session, _ := acceptReq["session"].(map[string]any)
|
||||
idToken, _ := session["id_token"].(map[string]any)
|
||||
capturedClaims[challenge] = idToken
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"redirect_to": "http://rp/cb",
|
||||
}), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})}
|
||||
|
||||
repo := newRPClaimsE2ERepo()
|
||||
kratos := new(MockKratosAdminService)
|
||||
kratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
|
||||
ID: userID,
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "rp-e2e@example.com",
|
||||
"name": "RP E2E User",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
authHandler := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: hydraClient,
|
||||
},
|
||||
KratosAdmin: kratos,
|
||||
RPUserMetadataRepo: repo,
|
||||
}
|
||||
devHandler := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: hydraClient,
|
||||
},
|
||||
RPUserMetadataRepo: repo,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Put("/api/v1/dev/clients/:id/users/me/metadata", func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: userID, Role: domain.RoleUser})
|
||||
return devHandler.SelfUpdateRPUserMetadata(c)
|
||||
})
|
||||
app.Put("/api/v1/dev/clients/:id/users/:userId/metadata", func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin", Role: domain.RoleSuperAdmin})
|
||||
return devHandler.UpsertRPUserMetadata(c)
|
||||
})
|
||||
app.Post("/api/v1/auth/consent/accept", authHandler.AcceptConsentRequest)
|
||||
|
||||
initialA := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-a-default")
|
||||
assert.Equal(t, "A", initialA["approvalLevel"])
|
||||
assert.Equal(t, true, initialA["activeMember"])
|
||||
assert.Equal(t, float64(1), initialA["score"])
|
||||
assert.Equal(t, []any{"default"}, initialA["featureList"])
|
||||
assert.Equal(t, map[string]any{"theme": "light", "density": "comfortable"}, initialA["preferences"])
|
||||
assert.Equal(t, "2026-06-09", initialA["contractDate"])
|
||||
assert.Equal(t, "2026-06-09T09:30", initialA["approvedAt"])
|
||||
|
||||
upsertRPClaimsE2EMetadata(t, app, clientA, userID, map[string]any{
|
||||
"approvalLevel": "B",
|
||||
"activeMember": false,
|
||||
"score": 42,
|
||||
"featureList": []string{"sso", "claims"},
|
||||
"preferences": map[string]any{"theme": "dark", "density": "compact"},
|
||||
"contractDate": "2026-06-10",
|
||||
"approvedAt": "2026-06-09T10:30",
|
||||
"adminManagedNote": "admin-updated",
|
||||
"approvalLevel_permissions": map[string]any{
|
||||
"writePermission": "user_and_admin",
|
||||
},
|
||||
})
|
||||
|
||||
updatedA := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-a-admin-update")
|
||||
assert.Equal(t, "B", updatedA["approvalLevel"])
|
||||
assert.Equal(t, false, updatedA["activeMember"])
|
||||
assert.Equal(t, float64(42), updatedA["score"])
|
||||
assert.Equal(t, []any{"sso", "claims"}, updatedA["featureList"])
|
||||
assert.Equal(t, map[string]any{"theme": "dark", "density": "compact"}, updatedA["preferences"])
|
||||
assert.Equal(t, "2026-06-10", updatedA["contractDate"])
|
||||
assert.Equal(t, "2026-06-09T10:30", updatedA["approvedAt"])
|
||||
assert.Equal(t, "admin-updated", updatedA["adminManagedNote"])
|
||||
assert.NotContains(t, updatedA, "approvalLevel_permissions")
|
||||
assert.NotContains(t, updatedA, "adminManagedNote_permissions")
|
||||
|
||||
rejectedSelfUpdate := putRPClaimsE2EMetadata(t, app, http.MethodPut, "/api/v1/dev/clients/"+clientA+"/users/me/metadata", map[string]any{
|
||||
"metadata": map[string]any{
|
||||
"adminManagedNote": "user-should-not-overwrite",
|
||||
},
|
||||
})
|
||||
assert.Equal(t, http.StatusForbidden, rejectedSelfUpdate.StatusCode)
|
||||
|
||||
allowedSelfUpdate := putRPClaimsE2EMetadata(t, app, http.MethodPut, "/api/v1/dev/clients/"+clientA+"/users/me/metadata", map[string]any{
|
||||
"metadata": map[string]any{
|
||||
"approvalLevel": "C",
|
||||
},
|
||||
})
|
||||
assert.Equal(t, http.StatusOK, allowedSelfUpdate.StatusCode)
|
||||
|
||||
selfUpdatedA := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-a-self-update")
|
||||
assert.Equal(t, "C", selfUpdatedA["approvalLevel"])
|
||||
assert.Equal(t, "admin-updated", selfUpdatedA["adminManagedNote"])
|
||||
|
||||
defaultB := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-b-default")
|
||||
assert.Equal(t, "B-default", defaultB["approvalLevel"])
|
||||
assert.Equal(t, false, defaultB["activeMember"])
|
||||
assert.NotContains(t, defaultB, "score")
|
||||
assert.NotContains(t, defaultB, "featureList")
|
||||
assert.NotContains(t, defaultB, "adminManagedNote")
|
||||
|
||||
upsertRPClaimsE2EMetadata(t, app, clientB, userID, map[string]any{
|
||||
"approvalLevel": "B-rp-only",
|
||||
"activeMember": true,
|
||||
})
|
||||
updatedB := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-b-update")
|
||||
assert.Equal(t, "B-rp-only", updatedB["approvalLevel"])
|
||||
assert.Equal(t, true, updatedB["activeMember"])
|
||||
assert.NotEqual(t, selfUpdatedA["approvalLevel"], updatedB["approvalLevel"])
|
||||
assert.NotContains(t, updatedB, "score")
|
||||
assert.NotContains(t, updatedB, "featureList")
|
||||
|
||||
require.True(t, repo.seenGet(clientA, userID))
|
||||
require.True(t, repo.seenGet(clientB, userID))
|
||||
require.Contains(t, capturedClaims, "challenge-client-a-admin-update")
|
||||
require.Contains(t, capturedClaims, "challenge-client-b-update")
|
||||
kratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func rpClaimsE2EClient(clientID string, claims []map[string]any) map[string]any {
|
||||
return map[string]any{
|
||||
"client_id": clientID,
|
||||
"client_name": clientID,
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": "tenant-rp-e2e",
|
||||
"id_token_claims": claims,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func rpClaimsE2EClaim(key, valueType, value, readPermission, writePermission string) map[string]any {
|
||||
return map[string]any{
|
||||
"namespace": "rp_claims",
|
||||
"key": key,
|
||||
"valueType": valueType,
|
||||
"value": value,
|
||||
"readPermission": readPermission,
|
||||
"writePermission": writePermission,
|
||||
}
|
||||
}
|
||||
|
||||
func acceptRPClaimsE2EConsent(t *testing.T, app *fiber.App, capturedClaims map[string]map[string]any, challenge string) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"consent_challenge": challenge,
|
||||
"grant_scope": []string{"openid", "profile"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req, -1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
idToken, ok := capturedClaims[challenge]
|
||||
require.True(t, ok)
|
||||
rpClaims, ok := idToken["rp_claims"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
return rpClaims
|
||||
}
|
||||
|
||||
func upsertRPClaimsE2EMetadata(t *testing.T, app *fiber.App, clientID, userID string, metadata map[string]any) {
|
||||
t.Helper()
|
||||
|
||||
resp := putRPClaimsE2EMetadata(t, app, http.MethodPut, "/api/v1/dev/clients/"+clientID+"/users/"+userID+"/metadata", map[string]any{
|
||||
"metadata": metadata,
|
||||
})
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
func putRPClaimsE2EMetadata(t *testing.T, app *fiber.App, method, path string, body map[string]any) *http.Response {
|
||||
t.Helper()
|
||||
|
||||
payload, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest(method, path, bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req, -1)
|
||||
require.NoError(t, err)
|
||||
return resp
|
||||
}
|
||||
@@ -52,6 +52,7 @@ import { ClientDetailTabs } from "./ClientDetailTabs";
|
||||
type RPClaimValueType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "float"
|
||||
| "boolean"
|
||||
| "array"
|
||||
| "object"
|
||||
@@ -167,7 +168,9 @@ function draftRowsToMetadata(rows: MetadataDraftRow[]) {
|
||||
function draftRowValueToMetadataValue(row: MetadataDraftRow) {
|
||||
const value = row.value.trim();
|
||||
switch (row.valueType) {
|
||||
case "number": {
|
||||
case "number":
|
||||
return /^-?\d+$/.test(value) ? Number.parseInt(value, 10) : value;
|
||||
case "float": {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : value;
|
||||
}
|
||||
@@ -200,6 +203,7 @@ function isRPClaimValueType(value: string): value is RPClaimValueType {
|
||||
return (
|
||||
value === "text" ||
|
||||
value === "number" ||
|
||||
value === "float" ||
|
||||
value === "boolean" ||
|
||||
value === "array" ||
|
||||
value === "object" ||
|
||||
@@ -268,10 +272,21 @@ function readRPClaimSchemas(
|
||||
function rpClaimInputType(valueType: RPClaimValueType) {
|
||||
if (valueType === "date") return "date";
|
||||
if (valueType === "datetime") return "datetime-local";
|
||||
if (valueType === "number") return "number";
|
||||
return "text";
|
||||
}
|
||||
|
||||
function rpClaimInputMode(valueType: RPClaimValueType) {
|
||||
if (valueType === "number") return "numeric";
|
||||
if (valueType === "float") return "decimal";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function rpClaimInputPattern(valueType: RPClaimValueType) {
|
||||
if (valueType === "number") return "-?[0-9]*";
|
||||
if (valueType === "float") return "-?(?:[0-9]+(?:\\.[0-9]+)?|\\.[0-9]+)";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function ClientConsentsPage() {
|
||||
const params = useParams();
|
||||
const clientId = params.id ?? "";
|
||||
@@ -452,25 +467,6 @@ function ClientConsentsPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const addMetadataDraftRow = () => {
|
||||
setMetadataDraftRows((current) => [
|
||||
...current,
|
||||
{
|
||||
id: `rp-metadata-${Date.now()}`,
|
||||
key: "",
|
||||
value: "",
|
||||
valueType: "text",
|
||||
readPermission: "admin_only",
|
||||
writePermission: "admin_only",
|
||||
schemaBacked: false,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const removeMetadataDraftRow = (id: string) => {
|
||||
setMetadataDraftRows((current) => current.filter((row) => row.id !== id));
|
||||
};
|
||||
|
||||
if (error) {
|
||||
const axiosError = error as AxiosError<{ error?: string }>;
|
||||
if (axiosError.response?.status === 403) {
|
||||
@@ -958,16 +954,6 @@ function ClientConsentsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{rpClaimSchemas.length === 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
onClick={addMetadataDraftRow}
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
{t("ui.common.add", "추가")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="gap-2"
|
||||
@@ -1008,25 +994,9 @@ function ClientConsentsPage() {
|
||||
key={row.id}
|
||||
className="grid gap-3 md:grid-cols-[minmax(180px,0.8fr)_minmax(220px,1fr)_150px_150px_auto]"
|
||||
>
|
||||
{row.schemaBacked ? (
|
||||
<div className="flex h-10 items-center rounded-md border bg-muted/30 px-3 font-mono text-xs">
|
||||
{row.key}
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
value={row.key}
|
||||
onChange={(event) =>
|
||||
updateMetadataDraftRow(row.id, {
|
||||
key: event.target.value,
|
||||
})
|
||||
}
|
||||
className="font-mono text-xs"
|
||||
placeholder={t(
|
||||
"ui.dev.clients.consents.rp_claims.key_placeholder",
|
||||
"claim_key",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{row.valueType === "boolean" ? (
|
||||
<select
|
||||
value={row.value === "false" ? "false" : "true"}
|
||||
@@ -1061,6 +1031,8 @@ function ClientConsentsPage() {
|
||||
) : (
|
||||
<Input
|
||||
type={rpClaimInputType(row.valueType)}
|
||||
inputMode={rpClaimInputMode(row.valueType)}
|
||||
pattern={rpClaimInputPattern(row.valueType)}
|
||||
value={row.value}
|
||||
onChange={(event) =>
|
||||
updateMetadataDraftRow(row.id, {
|
||||
@@ -1129,22 +1101,12 @@ function ClientConsentsPage() {
|
||||
)}
|
||||
</option>
|
||||
</select>
|
||||
{row.schemaBacked ? (
|
||||
<Badge
|
||||
variant="muted"
|
||||
className="h-10 justify-center rounded-md px-3 font-mono text-xs"
|
||||
>
|
||||
{row.valueType}
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeMetadataDraftRow(row.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ vi.mock("../../lib/i18n", () => ({
|
||||
t: (key: string, fallback?: string) =>
|
||||
({
|
||||
"ui.dev.clients.details.tab.connection": "연동 설정",
|
||||
"ui.dev.clients.details.tab.user_claims": "사용자 Claim",
|
||||
"ui.dev.clients.details.tab.consents": "Consents & Claims",
|
||||
"ui.dev.clients.details.tab.settings": "설정",
|
||||
"ui.dev.clients.details.tab.relationships": "관계",
|
||||
})[key] ??
|
||||
@@ -23,7 +23,7 @@ describe("ClientDetailTabs", () => {
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(html).toContain("사용자 Claim");
|
||||
expect(html).toContain("Consents & Claims");
|
||||
expect(html).toContain('href="/clients/client-a/consents"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,6 @@ const tabOrder: Array<{
|
||||
{
|
||||
key: "consents",
|
||||
href: (clientId) => `/clients/${clientId}/consents`,
|
||||
labelKey: "ui.dev.clients.details.tab.user_claims",
|
||||
},
|
||||
{ key: "settings", href: (clientId) => `/clients/${clientId}/settings` },
|
||||
{
|
||||
|
||||
@@ -126,6 +126,26 @@ async function setInputValue(input: HTMLInputElement, value: string) {
|
||||
await flush();
|
||||
}
|
||||
|
||||
async function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
);
|
||||
descriptor?.set?.call(textarea, value);
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await flush();
|
||||
}
|
||||
|
||||
async function setSelectValue(select: HTMLSelectElement, value: string) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLSelectElement.prototype,
|
||||
"value",
|
||||
);
|
||||
descriptor?.set?.call(select, value);
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await flush();
|
||||
}
|
||||
|
||||
async function renderPage() {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -229,4 +249,225 @@ describe("ClientGeneralPage RP claims", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("forces user read permission on when user write permission is enabled for RP claims", async () => {
|
||||
const { container } = await renderPage();
|
||||
|
||||
const switches = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>('[role="switch"]'),
|
||||
);
|
||||
const readSwitch = switches.find((button) =>
|
||||
/Read|읽기/.test(button.getAttribute("aria-label") ?? ""),
|
||||
);
|
||||
const writeSwitch = switches.find((button) =>
|
||||
/Write|쓰기/.test(button.getAttribute("aria-label") ?? ""),
|
||||
);
|
||||
|
||||
expect(readSwitch).toBeDefined();
|
||||
expect(writeSwitch).toBeDefined();
|
||||
expect(readSwitch?.getAttribute("aria-checked")).toBe("false");
|
||||
expect(writeSwitch?.getAttribute("aria-checked")).toBe("false");
|
||||
|
||||
await act(async () => {
|
||||
writeSwitch?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(readSwitch?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(writeSwitch?.getAttribute("aria-checked")).toBe("true");
|
||||
|
||||
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();
|
||||
|
||||
expect(updateClientMock).toHaveBeenCalledWith(
|
||||
"client-claims",
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
id_token_claims: [
|
||||
expect.objectContaining({
|
||||
readPermission: "user_and_admin",
|
||||
writePermission: "user_and_admin",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps nullable and default value as separate RP claim settings", async () => {
|
||||
const { container } = await renderPage();
|
||||
|
||||
expect(container.textContent).toContain("Nullable");
|
||||
expect(container.textContent).toContain("Default Value");
|
||||
expect(container.textContent).not.toContain("Nullable/default");
|
||||
expect(container.textContent).toContain(
|
||||
"RP 전용 확장 claim을 구분해서 관리합니다",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks saving a number RP claim default value that is not numeric", async () => {
|
||||
const { container } = await renderPage();
|
||||
|
||||
const valueTypeSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[aria-label="Claim 값 타입"]',
|
||||
);
|
||||
expect(valueTypeSelect).not.toBeNull();
|
||||
await setSelectValue(valueTypeSelect as HTMLSelectElement, "number");
|
||||
|
||||
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();
|
||||
|
||||
expect(updateClientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks saving a number RP claim default value that is not an integer", async () => {
|
||||
const { container } = await renderPage();
|
||||
|
||||
const valueTypeSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[aria-label="Claim 값 타입"]',
|
||||
);
|
||||
expect(valueTypeSelect).not.toBeNull();
|
||||
await setSelectValue(valueTypeSelect as HTMLSelectElement, "number");
|
||||
|
||||
const defaultValueInput = container.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="Enter the default value"]',
|
||||
);
|
||||
expect(defaultValueInput).not.toBeNull();
|
||||
await setInputValue(defaultValueInput as HTMLInputElement, "3.14");
|
||||
|
||||
expect(container.textContent).toContain(
|
||||
"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();
|
||||
|
||||
expect(updateClientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("saves a float RP claim default value", async () => {
|
||||
const { container } = await renderPage();
|
||||
|
||||
const valueTypeSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[aria-label="Claim 값 타입"]',
|
||||
);
|
||||
expect(valueTypeSelect).not.toBeNull();
|
||||
expect(
|
||||
valueTypeSelect?.querySelector('option[value="float"]'),
|
||||
).not.toBeNull();
|
||||
await setSelectValue(valueTypeSelect as HTMLSelectElement, "float");
|
||||
|
||||
const defaultValueInput = container.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="Enter the default value"]',
|
||||
);
|
||||
expect(defaultValueInput).not.toBeNull();
|
||||
expect(defaultValueInput?.getAttribute("inputmode")).toBe("decimal");
|
||||
await setInputValue(defaultValueInput as HTMLInputElement, "3.14");
|
||||
|
||||
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();
|
||||
|
||||
expect(updateClientMock).toHaveBeenCalledWith(
|
||||
"client-claims",
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
id_token_claims: [
|
||||
expect.objectContaining({
|
||||
value: "3.14",
|
||||
valueType: "float",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders constrained default value controls for boolean and date RP claims", async () => {
|
||||
const { container } = await renderPage();
|
||||
|
||||
const valueTypeSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[aria-label="Claim 값 타입"]',
|
||||
);
|
||||
expect(valueTypeSelect).not.toBeNull();
|
||||
|
||||
await setSelectValue(valueTypeSelect as HTMLSelectElement, "boolean");
|
||||
const booleanDefaultSelect = Array.from(
|
||||
container.querySelectorAll<HTMLSelectElement>("select"),
|
||||
).find((select) =>
|
||||
Array.from(select.options).some((option) => option.value === "false"),
|
||||
);
|
||||
expect(booleanDefaultSelect).toBeDefined();
|
||||
|
||||
await setSelectValue(valueTypeSelect as HTMLSelectElement, "date");
|
||||
expect(container.querySelector('input[type="date"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("blocks saving an object RP claim default value that is not a JSON object", async () => {
|
||||
const { container } = await renderPage();
|
||||
|
||||
const valueTypeSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[aria-label="Claim 값 타입"]',
|
||||
);
|
||||
expect(valueTypeSelect).not.toBeNull();
|
||||
await setSelectValue(valueTypeSelect as HTMLSelectElement, "object");
|
||||
|
||||
const defaultValueInput = container.querySelector<HTMLTextAreaElement>(
|
||||
'textarea[placeholder="{\\"key\\": \\"value\\"}"]',
|
||||
);
|
||||
expect(defaultValueInput).not.toBeNull();
|
||||
await setTextareaValue(
|
||||
defaultValueInput as HTMLTextAreaElement,
|
||||
"not-json",
|
||||
);
|
||||
|
||||
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();
|
||||
|
||||
expect(updateClientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,6 +71,7 @@ type ClaimNamespace = "rp_claims";
|
||||
type ClaimValueType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "float"
|
||||
| "boolean"
|
||||
| "array"
|
||||
| "object"
|
||||
@@ -149,6 +150,7 @@ function isClaimValueType(value: string): value is ClaimValueType {
|
||||
return (
|
||||
value === "text" ||
|
||||
value === "number" ||
|
||||
value === "float" ||
|
||||
value === "boolean" ||
|
||||
value === "array" ||
|
||||
value === "object" ||
|
||||
@@ -176,6 +178,18 @@ function createIdTokenClaimItem(id: string): IdTokenClaimItem {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeIdTokenClaimPermissions(
|
||||
claim: IdTokenClaimItem,
|
||||
): IdTokenClaimItem {
|
||||
if (claim.writePermission !== "user_and_admin") {
|
||||
return claim;
|
||||
}
|
||||
return {
|
||||
...claim,
|
||||
readPermission: "user_and_admin",
|
||||
};
|
||||
}
|
||||
|
||||
function readIdTokenClaimsMetadata(
|
||||
metadata: Record<string, unknown>,
|
||||
): IdTokenClaimItem[] {
|
||||
@@ -213,7 +227,7 @@ function readIdTokenClaimsMetadata(
|
||||
? record.valueType
|
||||
: "text";
|
||||
|
||||
return {
|
||||
return normalizeIdTokenClaimPermissions({
|
||||
id: `claim-${index + 1}`,
|
||||
namespace: namespaceValue,
|
||||
key: keyValue,
|
||||
@@ -226,7 +240,7 @@ function readIdTokenClaimsMetadata(
|
||||
writePermission: isCustomClaimPermission(record.writePermission)
|
||||
? record.writePermission
|
||||
: "admin_only",
|
||||
};
|
||||
});
|
||||
})
|
||||
.filter((item): item is IdTokenClaimItem => item !== null);
|
||||
}
|
||||
@@ -240,7 +254,7 @@ function normalizeClaimPreviewValue(
|
||||
if (nullable && trimmed === "") {
|
||||
return null;
|
||||
}
|
||||
if (valueType === "number") {
|
||||
if (valueType === "number" || valueType === "float") {
|
||||
if (trimmed === "") return "";
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : trimmed;
|
||||
@@ -279,6 +293,137 @@ function normalizeClaimPreviewValue(
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function isJsonObjectValue(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isIntegerClaimDefaultValue(value: string) {
|
||||
return /^-?\d+$/.test(value);
|
||||
}
|
||||
|
||||
function isFloatClaimDefaultValue(value: string) {
|
||||
return /^-?(?:\d+(?:\.\d+)?|\.\d+)$/.test(value);
|
||||
}
|
||||
|
||||
function isValidDateInputValue(value: string) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||||
const date = new Date(`${value}T00:00:00Z`);
|
||||
if (Number.isNaN(date.getTime())) return false;
|
||||
return date.toISOString().slice(0, 10) === value;
|
||||
}
|
||||
|
||||
function isValidDateTimeInputValue(value: string) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?$/.test(value)) {
|
||||
return false;
|
||||
}
|
||||
const date = new Date(value);
|
||||
return !Number.isNaN(date.getTime());
|
||||
}
|
||||
|
||||
function claimDefaultValueValidationError(claim: IdTokenClaimItem) {
|
||||
const value = claim.value.trim();
|
||||
if (value === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (claim.valueType) {
|
||||
case "number":
|
||||
return isIntegerClaimDefaultValue(value)
|
||||
? null
|
||||
: t(
|
||||
"msg.dev.clients.general.id_token_claims.invalid_default_value",
|
||||
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
|
||||
{ key: claim.key || "-", valueType: claim.valueType },
|
||||
);
|
||||
case "float":
|
||||
return isFloatClaimDefaultValue(value)
|
||||
? null
|
||||
: t(
|
||||
"msg.dev.clients.general.id_token_claims.invalid_default_value",
|
||||
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
|
||||
{ key: claim.key || "-", valueType: claim.valueType },
|
||||
);
|
||||
case "boolean":
|
||||
return value === "true" || value === "false"
|
||||
? null
|
||||
: t(
|
||||
"msg.dev.clients.general.id_token_claims.invalid_default_value",
|
||||
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
|
||||
{ key: claim.key || "-", valueType: claim.valueType },
|
||||
);
|
||||
case "array": {
|
||||
try {
|
||||
return Array.isArray(JSON.parse(value))
|
||||
? null
|
||||
: t(
|
||||
"msg.dev.clients.general.id_token_claims.invalid_default_value",
|
||||
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
|
||||
{ key: claim.key || "-", valueType: claim.valueType },
|
||||
);
|
||||
} catch {
|
||||
return t(
|
||||
"msg.dev.clients.general.id_token_claims.invalid_default_value",
|
||||
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
|
||||
{ key: claim.key || "-", valueType: claim.valueType },
|
||||
);
|
||||
}
|
||||
}
|
||||
case "object": {
|
||||
try {
|
||||
return isJsonObjectValue(JSON.parse(value))
|
||||
? null
|
||||
: t(
|
||||
"msg.dev.clients.general.id_token_claims.invalid_default_value",
|
||||
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
|
||||
{ key: claim.key || "-", valueType: claim.valueType },
|
||||
);
|
||||
} catch {
|
||||
return t(
|
||||
"msg.dev.clients.general.id_token_claims.invalid_default_value",
|
||||
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
|
||||
{ key: claim.key || "-", valueType: claim.valueType },
|
||||
);
|
||||
}
|
||||
}
|
||||
case "date":
|
||||
return isValidDateInputValue(value)
|
||||
? null
|
||||
: t(
|
||||
"msg.dev.clients.general.id_token_claims.invalid_default_value",
|
||||
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
|
||||
{ key: claim.key || "-", valueType: claim.valueType },
|
||||
);
|
||||
case "datetime":
|
||||
return isValidDateTimeInputValue(value)
|
||||
? null
|
||||
: t(
|
||||
"msg.dev.clients.general.id_token_claims.invalid_default_value",
|
||||
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
|
||||
{ key: claim.key || "-", valueType: claim.valueType },
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function claimDefaultInputType(valueType: ClaimValueType) {
|
||||
if (valueType === "date") return "date";
|
||||
if (valueType === "datetime") return "datetime-local";
|
||||
return "text";
|
||||
}
|
||||
|
||||
function claimDefaultInputMode(valueType: ClaimValueType) {
|
||||
if (valueType === "number") return "numeric";
|
||||
if (valueType === "float") return "decimal";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function claimDefaultInputPattern(valueType: ClaimValueType) {
|
||||
if (valueType === "number") return "-?[0-9]*";
|
||||
if (valueType === "float") return "-?(?:[0-9]+(?:\\.[0-9]+)?|\\.[0-9]+)";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildIdTokenClaimsPreview(
|
||||
items: IdTokenClaimItem[],
|
||||
): Record<string, unknown> {
|
||||
@@ -777,10 +922,10 @@ function ClientGeneralPage() {
|
||||
if (claim.id !== id) {
|
||||
return claim;
|
||||
}
|
||||
return {
|
||||
return normalizeIdTokenClaimPermissions({
|
||||
...claim,
|
||||
[field]: permission,
|
||||
};
|
||||
});
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -840,11 +985,13 @@ function ClientGeneralPage() {
|
||||
"허용 알고리즘: {{algorithms}}",
|
||||
{ algorithms: HEADLESS_LOGIN_ALLOWED_ALGORITHMS.join(", ") },
|
||||
);
|
||||
const normalizedIdTokenClaims = idTokenClaims.map((claim) => ({
|
||||
const normalizedIdTokenClaims = idTokenClaims.map((claim) =>
|
||||
normalizeIdTokenClaimPermissions({
|
||||
...claim,
|
||||
key: claim.key.trim(),
|
||||
value: claim.value.trim(),
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
if (headlessLoginEnabled) {
|
||||
if (!trimmedJwksUri) {
|
||||
@@ -930,6 +1077,11 @@ function ClientGeneralPage() {
|
||||
continue;
|
||||
}
|
||||
seenClaimKeys.add(keySignature);
|
||||
|
||||
const defaultValueError = claimDefaultValueValidationError(claim);
|
||||
if (defaultValueError) {
|
||||
claimValidationErrors.push(defaultValueError);
|
||||
}
|
||||
}
|
||||
validationErrors.push(...claimValidationErrors);
|
||||
|
||||
@@ -2103,7 +2255,7 @@ function ClientGeneralPage() {
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.dev.clients.general.id_token_claims.subtitle",
|
||||
"공통 claim과 RP 전용 확장 claim을 구분해서 관리합니다.",
|
||||
"RP 전용 확장 claim을 구분해서 관리합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
@@ -2151,13 +2303,13 @@ function ClientGeneralPage() {
|
||||
<th className="px-4 py-3 text-left font-bold">
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.table.read_user_allowed",
|
||||
"Read",
|
||||
"User read",
|
||||
)}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-bold">
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.table.write_user_allowed",
|
||||
"Write",
|
||||
"User write",
|
||||
)}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-bold">
|
||||
@@ -2175,7 +2327,11 @@ function ClientGeneralPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{idTokenClaims.map((claim) => (
|
||||
{idTokenClaims.map((claim) => {
|
||||
const defaultValueError =
|
||||
claimDefaultValueValidationError(claim);
|
||||
|
||||
return (
|
||||
<tr key={claim.id} className="hover:bg-muted/20">
|
||||
<td className="px-4 py-3 align-top">
|
||||
<Input
|
||||
@@ -2218,7 +2374,7 @@ function ClientGeneralPage() {
|
||||
}
|
||||
aria-label={t(
|
||||
"ui.dev.clients.general.id_token_claims.value_type_label",
|
||||
"Claim value type",
|
||||
"Claim 값 타입",
|
||||
)}
|
||||
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}
|
||||
@@ -2235,6 +2391,12 @@ function ClientGeneralPage() {
|
||||
"Number",
|
||||
)}
|
||||
</option>
|
||||
<option value="float">
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.value_type_float",
|
||||
"Float",
|
||||
)}
|
||||
</option>
|
||||
<option value="boolean">
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.value_type_boolean",
|
||||
@@ -2301,7 +2463,7 @@ function ClientGeneralPage() {
|
||||
}
|
||||
aria-label={t(
|
||||
"ui.dev.clients.general.id_token_claims.read_user_allowed_label",
|
||||
"Read 사용자 허용",
|
||||
"사용자 읽기 허용",
|
||||
)}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
/>
|
||||
@@ -2322,14 +2484,59 @@ function ClientGeneralPage() {
|
||||
}
|
||||
aria-label={t(
|
||||
"ui.dev.clients.general.id_token_claims.write_user_allowed_label",
|
||||
"Write 사용자 허용",
|
||||
"사용자 쓰기 허용",
|
||||
)}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
{claim.valueType === "array" ||
|
||||
claim.valueType === "object" ? (
|
||||
<Textarea
|
||||
value={claim.value}
|
||||
onChange={(e) =>
|
||||
updateIdTokenClaim(
|
||||
claim.id,
|
||||
"value",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
className="min-h-9 font-mono text-xs"
|
||||
placeholder={
|
||||
claim.valueType === "array"
|
||||
? `["value"]`
|
||||
: `{"key": "value"}`
|
||||
}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
/>
|
||||
) : claim.valueType === "boolean" ? (
|
||||
<select
|
||||
value={
|
||||
claim.value === "false" ? "false" : "true"
|
||||
}
|
||||
onChange={(e) =>
|
||||
updateIdTokenClaim(
|
||||
claim.id,
|
||||
"value",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
className="h-9 w-full 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"
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
) : (
|
||||
<Input
|
||||
type={claimDefaultInputType(claim.valueType)}
|
||||
inputMode={claimDefaultInputMode(
|
||||
claim.valueType,
|
||||
)}
|
||||
pattern={claimDefaultInputPattern(
|
||||
claim.valueType,
|
||||
)}
|
||||
value={claim.value}
|
||||
onChange={(e) =>
|
||||
updateIdTokenClaim(
|
||||
@@ -2344,7 +2551,16 @@ function ClientGeneralPage() {
|
||||
"Enter the default value",
|
||||
)}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
aria-invalid={
|
||||
defaultValueError ? true : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{defaultValueError && (
|
||||
<p className="mt-1 text-xs text-destructive">
|
||||
{defaultValueError}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right align-top">
|
||||
<Button
|
||||
@@ -2358,7 +2574,8 @@ function ClientGeneralPage() {
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{idTokenClaims.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
@@ -2378,7 +2595,7 @@ function ClientGeneralPage() {
|
||||
<p className="text-xs leading-6 text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.general.id_token_claims.hint",
|
||||
"RP 전용 확장 claim만 관리합니다. 배열은 JSON 또는 콤마 구분 문자열, 객체는 JSON을 입력하면 됩니다.",
|
||||
"RP 전용 확장 claim을 구분해서 관리합니다. 사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -454,13 +454,14 @@ subtitle = "Define the permission scopes this application can request."
|
||||
tenant = "Tenant access claim"
|
||||
|
||||
[msg.dev.clients.general.id_token_claims]
|
||||
subtitle = "Separate shared claims from RP-specific extension claims."
|
||||
subtitle = "Manage RP-specific extension claims separately."
|
||||
empty = "No ID Token claims have been added yet."
|
||||
hint = "Manage RP-specific extension claims only. Arrays accept JSON or comma-separated values, and objects accept JSON."
|
||||
hint = "Manage RP-specific extension claims separately. Edit per-user claim values in Consents & Claims."
|
||||
preview_hint = "Preview the metadata.id_token_claims structure that will be saved."
|
||||
key_required = "Enter a claim key."
|
||||
reserved_key = "`rp_claims` is a reserved namespace key."
|
||||
duplicate_key = "Duplicate claim key: {{namespace}}.{{key}}"
|
||||
invalid_default_value = "The claim default value does not match its type: {{key}} ({{valueType}})"
|
||||
|
||||
[msg.dev.clients.general.security]
|
||||
private_help = "Server side App: For apps that can safely store a client secret, such as Node.js or Java servers."
|
||||
@@ -1537,10 +1538,10 @@ title = "Security Note"
|
||||
|
||||
[ui.dev.clients.details.tab]
|
||||
connection = "Federation"
|
||||
consents = "Consent & Users"
|
||||
consents = "Consents & Claims"
|
||||
settings = "Settings"
|
||||
relationships = "Relationships"
|
||||
user_claims = "User Claims"
|
||||
user_claims = "Consents & Claims"
|
||||
|
||||
[ui.dev.clients.general]
|
||||
create = "Create Application"
|
||||
@@ -1618,19 +1619,20 @@ 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"
|
||||
read_user_allowed_label = "Allow user read"
|
||||
write_user_allowed_label = "Allow user write"
|
||||
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.read_user_allowed = "User read"
|
||||
table.write_user_allowed = "User write"
|
||||
table.default_value = "Default Value"
|
||||
table.delete = "Delete"
|
||||
value_type_label = "Claim value type"
|
||||
value_type_text = "Text"
|
||||
value_type_number = "Number"
|
||||
value_type_float = "Float"
|
||||
value_type_boolean = "Boolean"
|
||||
value_type_array = "Array"
|
||||
value_type_object = "Object"
|
||||
|
||||
@@ -454,13 +454,14 @@ subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
|
||||
tenant = "소속 테넌트 정보 접근"
|
||||
|
||||
[msg.dev.clients.general.id_token_claims]
|
||||
subtitle = "공통 claim과 RP 전용 확장 claim을 구분해서 관리합니다."
|
||||
subtitle = "RP 전용 확장 claim을 구분해서 관리합니다."
|
||||
empty = "아직 추가된 ID Token claim이 없습니다."
|
||||
hint = "RP 전용 확장 claim만 관리합니다. 배열은 JSON 또는 콤마 구분 문자열, 객체는 JSON을 입력하면 됩니다."
|
||||
hint = "RP 전용 확장 claim을 구분해서 관리합니다. 사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다."
|
||||
preview_hint = "저장될 metadata.id_token_claims 구조를 미리 확인할 수 있습니다."
|
||||
key_required = "Claim key를 입력해야 합니다."
|
||||
reserved_key = "`rp_claims`는 예약된 namespace 키입니다."
|
||||
duplicate_key = "중복된 claim key가 있습니다: {{namespace}}.{{key}}"
|
||||
invalid_default_value = "Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})"
|
||||
|
||||
[msg.dev.clients.general.security]
|
||||
pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
|
||||
@@ -1536,10 +1537,10 @@ title = "보안 메모"
|
||||
|
||||
[ui.dev.clients.details.tab]
|
||||
connection = "연동 설정"
|
||||
consents = "동의 및 사용자"
|
||||
consents = "동의 및 Claims"
|
||||
settings = "설정"
|
||||
relationships = "관계"
|
||||
user_claims = "사용자 Claim"
|
||||
user_claims = "Consents & Claims"
|
||||
|
||||
[ui.dev.clients.general]
|
||||
create = "앱 생성"
|
||||
@@ -1616,20 +1617,21 @@ 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 사용자 허용"
|
||||
nullable_label = "Nullable"
|
||||
read_user_allowed_label = "사용자 읽기 허용"
|
||||
write_user_allowed_label = "사용자 쓰기 허용"
|
||||
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.nullable = "Nullable"
|
||||
table.read_user_allowed = "사용자 읽기"
|
||||
table.write_user_allowed = "사용자 쓰기"
|
||||
table.default_value = "기본값"
|
||||
table.delete = "삭제"
|
||||
value_type_label = "Claim 값 타입"
|
||||
value_type_text = "텍스트"
|
||||
value_type_number = "숫자"
|
||||
value_type_float = "실수"
|
||||
value_type_boolean = "불리언"
|
||||
value_type_array = "배열"
|
||||
value_type_object = "객체"
|
||||
|
||||
@@ -445,6 +445,7 @@ preview_hint = ""
|
||||
key_required = ""
|
||||
reserved_key = ""
|
||||
duplicate_key = ""
|
||||
invalid_default_value = ""
|
||||
|
||||
[msg.dev.clients.relationships]
|
||||
subtitle = ""
|
||||
@@ -1679,6 +1680,7 @@ table.delete = ""
|
||||
value_type_label = ""
|
||||
value_type_text = ""
|
||||
value_type_number = ""
|
||||
value_type_float = ""
|
||||
value_type_boolean = ""
|
||||
value_type_array = ""
|
||||
value_type_object = ""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
type ClientRelation,
|
||||
type Consent,
|
||||
installDevApiMock,
|
||||
makeClient,
|
||||
@@ -7,6 +8,39 @@ import {
|
||||
} from "./helpers/devfront-fixtures";
|
||||
import { installDevFrontStaticRoutes } from "./helpers/static-devfront";
|
||||
|
||||
const editRelations = [
|
||||
{
|
||||
relation: "config_editor",
|
||||
subject: "User:playwright-user",
|
||||
subjectType: "User",
|
||||
subjectId: "playwright-user",
|
||||
},
|
||||
{
|
||||
relation: "admins",
|
||||
subject: "User:playwright-user",
|
||||
subjectType: "User",
|
||||
subjectId: "playwright-user",
|
||||
},
|
||||
{
|
||||
relation: "config_editor",
|
||||
subject: "User:admin-user",
|
||||
subjectType: "User",
|
||||
subjectId: "admin-user",
|
||||
},
|
||||
{
|
||||
relation: "config_editor",
|
||||
subject: "User:undefined",
|
||||
subjectType: "User",
|
||||
subjectId: "undefined",
|
||||
},
|
||||
{
|
||||
relation: "config_editor",
|
||||
subject: "User:",
|
||||
subjectType: "User",
|
||||
subjectId: "",
|
||||
},
|
||||
] satisfies ClientRelation[];
|
||||
|
||||
test.describe("DevFront RP claim cache", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installDevFrontStaticRoutes(page);
|
||||
@@ -33,6 +67,9 @@ test.describe("DevFront RP claim cache", () => {
|
||||
}),
|
||||
],
|
||||
consents: [] as Consent[],
|
||||
relations: {
|
||||
"client-claims": editRelations,
|
||||
},
|
||||
auditLogsByCursor: undefined,
|
||||
mockRole: "super_admin",
|
||||
};
|
||||
@@ -44,6 +81,7 @@ test.describe("DevFront RP claim cache", () => {
|
||||
.getByPlaceholder(/e\.g\. locale|예: locale/i)
|
||||
.first();
|
||||
await expect(claimKeyInput).toHaveValue("old_claim");
|
||||
await expect(claimKeyInput).toBeEnabled();
|
||||
|
||||
await claimKeyInput.fill("new_claim");
|
||||
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
|
||||
@@ -60,4 +98,208 @@ test.describe("DevFront RP claim cache", () => {
|
||||
.toBe("new_claim");
|
||||
await expect(claimKeyInput).toHaveValue("new_claim");
|
||||
});
|
||||
|
||||
test("forces read permission on when write permission is enabled", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = {
|
||||
clients: [
|
||||
makeClient("client-claims", {
|
||||
name: "Claims app",
|
||||
metadata: {
|
||||
id_token_claims: [
|
||||
{
|
||||
namespace: "rp_claims",
|
||||
key: "locale",
|
||||
value: "ko",
|
||||
valueType: "text",
|
||||
readPermission: "admin_only",
|
||||
writePermission: "admin_only",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
consents: [] as Consent[],
|
||||
relations: {
|
||||
"client-claims": editRelations,
|
||||
},
|
||||
auditLogsByCursor: undefined,
|
||||
mockRole: "super_admin",
|
||||
};
|
||||
await installDevApiMock(page, state);
|
||||
|
||||
await page.goto("http://devfront.test/clients/client-claims/settings");
|
||||
|
||||
const readSwitch = page
|
||||
.getByRole("switch", { name: /사용자 읽기|Allow user read/i })
|
||||
.first();
|
||||
const writeSwitch = page
|
||||
.getByRole("switch", { name: /사용자 쓰기|Allow user write/i })
|
||||
.first();
|
||||
|
||||
await expect(readSwitch).toHaveAttribute("aria-checked", "false");
|
||||
await expect(writeSwitch).toHaveAttribute("aria-checked", "false");
|
||||
await expect(readSwitch).toBeEnabled();
|
||||
await expect(writeSwitch).toBeEnabled();
|
||||
|
||||
await writeSwitch.click();
|
||||
|
||||
await expect(readSwitch).toHaveAttribute("aria-checked", "true");
|
||||
await expect(writeSwitch).toHaveAttribute("aria-checked", "true");
|
||||
|
||||
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
(
|
||||
state.clients[0]?.metadata?.id_token_claims as
|
||||
| Array<{
|
||||
readPermission?: string;
|
||||
writePermission?: string;
|
||||
}>
|
||||
| undefined
|
||||
)?.[0],
|
||||
)
|
||||
.toMatchObject({
|
||||
readPermission: "user_and_admin",
|
||||
writePermission: "user_and_admin",
|
||||
});
|
||||
});
|
||||
|
||||
test("blocks saving an RP claim default value that does not match the selected value type", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = {
|
||||
clients: [
|
||||
makeClient("client-claims", {
|
||||
name: "Claims app",
|
||||
metadata: {
|
||||
id_token_claims: [
|
||||
{
|
||||
namespace: "rp_claims",
|
||||
key: "profile",
|
||||
value: "{}",
|
||||
valueType: "text",
|
||||
readPermission: "admin_only",
|
||||
writePermission: "admin_only",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
consents: [] as Consent[],
|
||||
relations: {
|
||||
"client-claims": editRelations,
|
||||
},
|
||||
auditLogsByCursor: undefined,
|
||||
mockRole: "super_admin",
|
||||
};
|
||||
await installDevApiMock(page, state);
|
||||
|
||||
await page.goto("http://devfront.test/clients/client-claims/settings");
|
||||
|
||||
await page
|
||||
.getByLabel(/Claim 값 타입|Claim value type/i)
|
||||
.first()
|
||||
.selectOption("object");
|
||||
await page
|
||||
.locator('textarea[placeholder="{\\"key\\": \\"value\\"}"]')
|
||||
.fill("not-json");
|
||||
|
||||
await expect(
|
||||
page.getByRole("button", { name: /^저장$|^Save$/i }),
|
||||
).toBeDisabled();
|
||||
|
||||
expect(
|
||||
(
|
||||
state.clients[0]?.metadata?.id_token_claims as
|
||||
| Array<{ valueType?: string; value?: string }>
|
||||
| undefined
|
||||
)?.[0],
|
||||
).toMatchObject({
|
||||
value: "{}",
|
||||
valueType: "text",
|
||||
});
|
||||
});
|
||||
|
||||
test("saves a float RP claim default value and blocks decimal values for integer number claims", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = {
|
||||
clients: [
|
||||
makeClient("client-claims", {
|
||||
name: "Claims app",
|
||||
metadata: {
|
||||
id_token_claims: [
|
||||
{
|
||||
namespace: "rp_claims",
|
||||
key: "ratio",
|
||||
value: "0",
|
||||
valueType: "text",
|
||||
readPermission: "admin_only",
|
||||
writePermission: "admin_only",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
consents: [] as Consent[],
|
||||
relations: {
|
||||
"client-claims": editRelations,
|
||||
},
|
||||
auditLogsByCursor: undefined,
|
||||
mockRole: "super_admin",
|
||||
};
|
||||
await installDevApiMock(page, state);
|
||||
|
||||
await page.goto("http://devfront.test/clients/client-claims/settings");
|
||||
|
||||
await page
|
||||
.getByLabel(/Claim 값 타입|Claim value type/i)
|
||||
.first()
|
||||
.selectOption("float");
|
||||
await page
|
||||
.getByPlaceholder(/기본값을 입력하세요|Enter the default value/i)
|
||||
.first()
|
||||
.fill("3.14");
|
||||
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
(
|
||||
state.clients[0]?.metadata?.id_token_claims as
|
||||
| Array<{ valueType?: string; value?: string }>
|
||||
| undefined
|
||||
)?.[0],
|
||||
)
|
||||
.toMatchObject({
|
||||
value: "3.14",
|
||||
valueType: "float",
|
||||
});
|
||||
|
||||
const valueTypeSelect = page
|
||||
.getByLabel(/Claim 값 타입|Claim value type/i)
|
||||
.first();
|
||||
await expect(valueTypeSelect).toHaveValue("float");
|
||||
await expect(
|
||||
page.getByRole("button", { name: /^저장$|^Save$/i }),
|
||||
).toBeEnabled();
|
||||
|
||||
await valueTypeSelect.selectOption("number");
|
||||
await expect(valueTypeSelect).toHaveValue("number");
|
||||
await page
|
||||
.getByPlaceholder(/기본값을 입력하세요|Enter the default value/i)
|
||||
.first()
|
||||
.fill("3.14");
|
||||
|
||||
await expect(
|
||||
page.getByText(/Claim 기본값이 타입과 맞지 않습니다|does not match/i),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /^저장$|^Save$/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
makeClient,
|
||||
seedAuth,
|
||||
} from "./helpers/devfront-fixtures";
|
||||
import { installDevFrontStaticRoutes } from "./helpers/static-devfront";
|
||||
|
||||
function expectClientTabsOrder(pagePath: string, expectedActive: RegExp) {
|
||||
return async ({ page }: { page: Page }) => {
|
||||
@@ -24,9 +25,10 @@ function expectClientTabsOrder(pagePath: string, expectedActive: RegExp) {
|
||||
},
|
||||
auditLogsByCursor: undefined,
|
||||
};
|
||||
await installDevFrontStaticRoutes(page);
|
||||
await installDevApiMock(page, state);
|
||||
|
||||
await page.goto(pagePath);
|
||||
await page.goto(`http://devfront.test${pagePath}`);
|
||||
|
||||
const header = page
|
||||
.locator("header")
|
||||
@@ -38,7 +40,7 @@ function expectClientTabsOrder(pagePath: string, expectedActive: RegExp) {
|
||||
|
||||
await expect(tabs).toHaveText([
|
||||
"연동 설정",
|
||||
"사용자 Claim",
|
||||
"동의 및 Claims",
|
||||
"설정",
|
||||
"관계",
|
||||
]);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
seedAuth,
|
||||
} from "./helpers/devfront-fixtures";
|
||||
import { captureEvidence } from "./helpers/evidence";
|
||||
import { installDevFrontStaticRoutes } from "./helpers/static-devfront";
|
||||
|
||||
test.describe("DevFront consents", () => {
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
@@ -15,6 +16,7 @@ test.describe("DevFront consents", () => {
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installDevFrontStaticRoutes(page);
|
||||
page.on("dialog", async (dialog) => {
|
||||
await dialog.accept();
|
||||
});
|
||||
@@ -81,7 +83,7 @@ test.describe("DevFront consents", () => {
|
||||
};
|
||||
await installDevApiMock(page, state);
|
||||
|
||||
await page.goto("/clients/client-consent/consents");
|
||||
await page.goto("http://devfront.test/clients/client-consent/consents");
|
||||
await expect(page.getByText("Alice")).toBeVisible();
|
||||
await expect(page.getByText("Tenant A")).toBeVisible();
|
||||
await expect(page.getByText(/approvalLevel:\s*A/)).toBeVisible();
|
||||
@@ -127,4 +129,43 @@ test.describe("DevFront consents", () => {
|
||||
await page.getByRole("button", { name: /권한 철회|철회|Revoke/i }).click();
|
||||
await expect(page.getByText(/Revoked|철회/i).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("does not allow adding undefined RP claims from consents and claims", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = {
|
||||
clients: [
|
||||
makeClient("client-consent", {
|
||||
name: "Consent app",
|
||||
metadata: {},
|
||||
}),
|
||||
],
|
||||
consents: [
|
||||
{
|
||||
subject: "user-1",
|
||||
userName: "Alice",
|
||||
clientId: "client-consent",
|
||||
clientName: "Consent app",
|
||||
grantedScopes: ["openid", "profile"],
|
||||
authenticatedAt: "2026-03-03T08:00:00.000Z",
|
||||
createdAt: "2026-03-02T08:00:00.000Z",
|
||||
status: "active",
|
||||
tenantId: "tenant-a",
|
||||
tenantName: "Tenant A",
|
||||
rpMetadata: {},
|
||||
},
|
||||
] as Consent[],
|
||||
auditLogsByCursor: undefined,
|
||||
};
|
||||
await installDevApiMock(page, state);
|
||||
|
||||
await page.goto("http://devfront.test/clients/client-consent/consents");
|
||||
await page.getByRole("button", { name: /Claims|Claim/i }).click();
|
||||
|
||||
await expect(page.getByText("RP Custom Claims")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /^추가$|^Add$/ }),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByPlaceholder(/claim_key/i)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,20 @@ Kratos 내부 트레이트(Traits)에 테넌트, 직급 등 관계형 데이터
|
||||
- **Ory Keto (Relationship SSOT)**: 테넌트 소속, 소유, 접근 같은 권한 관계를 저장하고 판정합니다.
|
||||
- **Backend DB read model**: Ory에 저장되지 않거나 조회가 불가능한 테넌트 표시/검색 metadata, 설정, 외부 연동 상태만 저장합니다.
|
||||
|
||||
## 3. 데이터베이스 스키마 분리 전략
|
||||
## 3. Seed tenant 식별 정책
|
||||
|
||||
`adminfront/seed-tenant.csv`에 정의된 초기 테넌트는 CSV의 `id`/`tenant_id` UUID를 source of truth로 삼습니다. `slug`는 운영자가 읽고 외부 연동에서 다루기 쉬운 식별자지만, 오타 수정이나 명칭 정책 변경으로 바뀔 수 있으므로 초기 테넌트 보호 여부와 seed 동기화의 최종 식별 기준으로 사용하지 않습니다.
|
||||
|
||||
- 초기 테넌트 여부는 seed CSV의 UUID와 `tenants.id` 일치 여부로 판단합니다.
|
||||
- `slug`, `name`, `memo`, 도메인 같은 표시/설정 값은 UUID로 식별된 seed tenant의 동기화 대상 metadata입니다.
|
||||
- 기존 DB row가 seed UUID와 일치하지만 `slug`가 CSV와 다르면, backend bootstrap seed 경로는 CSV의 `slug`로 보정해야 합니다.
|
||||
- 목표 `slug`를 다른 활성 tenant가 이미 사용 중이면 자동 보정하지 않고 충돌로 처리합니다.
|
||||
- AdminFront의 “초기 설정” 표시와 삭제 보호도 `slug`가 아니라 seed UUID 기준으로 동작해야 합니다.
|
||||
- 일반 import/export 정책에서는 운영 편의를 위해 `slug`를 우선 사용할 수 있지만, seed tenant의 identity 보존 및 복구 정책은 UUID 기준을 우선합니다.
|
||||
|
||||
예를 들어 한라산업개발 seed row의 UUID가 `5a03efd2-e62f-4243-800d-58334bf48b2f`이면, 기존 DB의 `slug`가 `hanlla`여도 같은 UUID row는 동일 seed tenant로 보고 CSV 값인 `halla`로 보정합니다.
|
||||
|
||||
## 4. 데이터베이스 스키마 분리 전략
|
||||
|
||||
테넌트 테이블의 비대화를 막기 위해, Identity(신분증) 역할과 무거운 Business 데이터를 분리 조인(Join)합니다.
|
||||
|
||||
@@ -27,7 +40,7 @@ Kratos 내부 트레이트(Traits)에 테넌트, 직급 등 관계형 데이터
|
||||
- **`company_settings` 테이블**: `COMPANY` 및 `COMPANY_GROUP` 타입 전용 무거운 비즈니스 설정 (결제 정보, 커스텀 도메인 등).
|
||||
- **`user_groups` 테이블**: `USER_GROUP` 타입 전용 사내 조직도 메타데이터 (`parent_id`, 조직장 정보 등).
|
||||
|
||||
## 4. 논리적 다중 테넌트 OIDC 관리 (Logical Pooling)
|
||||
## 5. 논리적 다중 테넌트 OIDC 관리 (Logical Pooling)
|
||||
|
||||
인프라 비용의 팽창을 막기 위해 테넌트별로 Hydra(OAuth2) 데이터베이스를 물리적으로 복제하는 방식은 금지합니다.
|
||||
대신 공유되는 소수의 Hydra 클러스터 앞단에 도메인 및 헤더를 재작성하는 지능형 프록시를 배치하고, 백엔드의 동의(Consent) 로직을 통해 요청된 클라이언트의 테넌트 맥락에 맞는 **동적 클레임(Dynamic Claim)**을 ID Token에 주입합니다.
|
||||
|
||||
47
test/code_check_biome_dedup_test.sh
Normal file
47
test/code_check_biome_dedup_test.sh
Normal file
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
WORKFLOW_FILE="$ROOT_DIR/.gitea/workflows/code_check.yml"
|
||||
|
||||
fail() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
job_block() {
|
||||
local job="$1"
|
||||
awk -v job=" ${job}:" '
|
||||
$0 == job { in_job = 1; print; next }
|
||||
in_job && /^ [a-zA-Z0-9_-]+:/ { exit }
|
||||
in_job { print }
|
||||
' "$WORKFLOW_FILE"
|
||||
}
|
||||
|
||||
lint_block="$(job_block lint)"
|
||||
biome_block="$(job_block biome-check)"
|
||||
|
||||
if printf '%s\n' "$lint_block" | grep -Eq 'Biome check (adminfront|devfront|orgfront)|npx biome check'; then
|
||||
fail "lint job must not duplicate frontend Biome checks; keep them in biome-check"
|
||||
fi
|
||||
|
||||
for app in adminfront devfront orgfront; do
|
||||
printf '%s\n' "$biome_block" | grep -Fq "Install ${app} dependencies" ||
|
||||
fail "biome-check job must install ${app} dependencies"
|
||||
printf '%s\n' "$biome_block" | grep -Fq "Biome check ${app}" ||
|
||||
fail "biome-check job must check ${app}"
|
||||
done
|
||||
|
||||
for job in \
|
||||
adminfront-vitest-coverage \
|
||||
devfront-vitest-coverage \
|
||||
orgfront-vitest-coverage \
|
||||
adminfront-tests \
|
||||
devfront-tests \
|
||||
orgfront-tests; do
|
||||
block="$(job_block "$job")"
|
||||
printf '%s\n' "$block" | grep -Fq " - biome-check" ||
|
||||
fail "${job} must depend on biome-check instead of duplicating/depending on lint for frontend quality gate"
|
||||
done
|
||||
|
||||
echo "OK: Code Check runs frontend Biome only in the biome-check job"
|
||||
604
test/rp_claims_live_e2e.mjs
Normal file
604
test/rp_claims_live_e2e.mjs
Normal file
@@ -0,0 +1,604 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import playwright from "../devfront/node_modules/playwright/index.js";
|
||||
|
||||
const { chromium } = playwright;
|
||||
|
||||
const gatewayUrl = process.env.RP_CLAIMS_E2E_GATEWAY_URL ?? "http://localhost:5000";
|
||||
const devfrontUrl = process.env.RP_CLAIMS_E2E_DEVFRONT_URL ?? "http://localhost:5174";
|
||||
const reportDir = process.env.RP_CLAIMS_E2E_REPORT_DIR ?? "reports/rp-claims-live-e2e";
|
||||
const tenantSlug = process.env.RP_CLAIMS_E2E_TENANT_SLUG ?? "public-org";
|
||||
const runId = process.env.RP_CLAIMS_E2E_RUN_ID ?? `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
const email = `rp-claims-${runId}@example.test`;
|
||||
const loginId = `rp${runId.replaceAll("-", "").slice(0, 18)}`;
|
||||
const loginIdentifier = email;
|
||||
const password = `RpClaims${runId.slice(0, 6)}!1aA`;
|
||||
const redirectUri = "http://127.0.0.1:18080/callback";
|
||||
const subjectByLoginId = new Map();
|
||||
|
||||
const scenarios = [
|
||||
{
|
||||
name: "rp-a",
|
||||
clientId: `rp-claims-a-${runId}`,
|
||||
defaults: {
|
||||
approvalLevel: "A",
|
||||
activeMember: true,
|
||||
score: 1,
|
||||
featureList: ["default"],
|
||||
preferences: { theme: "light", density: "comfortable" },
|
||||
contractDate: "2026-06-09",
|
||||
approvedAt: "2026-06-09T09:30",
|
||||
adminManagedNote: "admin-default",
|
||||
},
|
||||
updates: [
|
||||
{
|
||||
label: "admin-update",
|
||||
metadata: {
|
||||
approvalLevel: "B",
|
||||
activeMember: false,
|
||||
score: 42,
|
||||
featureList: ["sso", "claims"],
|
||||
preferences: { theme: "dark", density: "compact" },
|
||||
contractDate: "2026-06-10",
|
||||
approvedAt: "2026-06-09T10:30",
|
||||
adminManagedNote: "admin-updated",
|
||||
approvalLevel_permissions: {
|
||||
readPermission: "admin_only",
|
||||
writePermission: "user_and_admin",
|
||||
},
|
||||
},
|
||||
expected: {
|
||||
approvalLevel: "B",
|
||||
activeMember: false,
|
||||
score: 42,
|
||||
featureList: ["sso", "claims"],
|
||||
preferences: { theme: "dark", density: "compact" },
|
||||
contractDate: "2026-06-10",
|
||||
approvedAt: "2026-06-09T10:30",
|
||||
adminManagedNote: "admin-updated",
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "second-update",
|
||||
metadata: {
|
||||
approvalLevel: "C",
|
||||
activeMember: true,
|
||||
score: 7.5,
|
||||
featureList: ["delta", "omega", "42"],
|
||||
preferences: { theme: "contrast", density: "spacious", nested: { level: 2 } },
|
||||
contractDate: "2026-06-11",
|
||||
approvedAt: "2026-06-11T14:45:30Z",
|
||||
adminManagedNote: "admin-updated-again",
|
||||
approvalLevel_permissions: {
|
||||
readPermission: "user_and_admin",
|
||||
writePermission: "user_and_admin",
|
||||
},
|
||||
},
|
||||
expected: {
|
||||
approvalLevel: "C",
|
||||
activeMember: true,
|
||||
score: 7.5,
|
||||
featureList: ["delta", "omega", "42"],
|
||||
preferences: { theme: "contrast", density: "spacious", nested: { level: 2 } },
|
||||
contractDate: "2026-06-11",
|
||||
approvedAt: "2026-06-11T14:45:30Z",
|
||||
adminManagedNote: "admin-updated-again",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "rp-b",
|
||||
clientId: `rp-claims-b-${runId}`,
|
||||
defaults: {
|
||||
approvalLevel: "B-default",
|
||||
activeMember: false,
|
||||
score: 100,
|
||||
featureList: ["rp-b-default"],
|
||||
preferences: { theme: "blue", density: "wide" },
|
||||
contractDate: "2026-07-01",
|
||||
approvedAt: "2026-07-01T08:00",
|
||||
},
|
||||
updates: [
|
||||
{
|
||||
label: "rp-b-isolated-update",
|
||||
metadata: {
|
||||
approvalLevel: "B-only",
|
||||
activeMember: true,
|
||||
score: 314,
|
||||
featureList: ["only", "rp-b"],
|
||||
preferences: { theme: "green", density: "narrow" },
|
||||
contractDate: "2026-07-02",
|
||||
approvedAt: "2026-07-02T09:15",
|
||||
},
|
||||
expected: {
|
||||
approvalLevel: "B-only",
|
||||
activeMember: true,
|
||||
score: 314,
|
||||
featureList: ["only", "rp-b"],
|
||||
preferences: { theme: "green", density: "narrow" },
|
||||
contractDate: "2026-07-02",
|
||||
approvedAt: "2026-07-02T09:15",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const report = {
|
||||
runId,
|
||||
gatewayUrl,
|
||||
devfrontUrl,
|
||||
loginId,
|
||||
loginIdentifier,
|
||||
email,
|
||||
createdAt: new Date().toISOString(),
|
||||
steps: [],
|
||||
scenarios: [],
|
||||
passed: false,
|
||||
};
|
||||
|
||||
function assertDeepEqual(actual, expected, label) {
|
||||
const actualJson = JSON.stringify(canonicalize(actual));
|
||||
const expectedJson = JSON.stringify(canonicalize(expected));
|
||||
if (actualJson !== expectedJson) {
|
||||
throw new Error(`${label}: expected ${expectedJson}, got ${actualJson}`);
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalize(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(canonicalize);
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => [key, canonicalize(item)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertAbsent(record, key, label) {
|
||||
if (Object.hasOwn(record ?? {}, key)) {
|
||||
throw new Error(`${label}: ${key} must not be present`);
|
||||
}
|
||||
}
|
||||
|
||||
function base64urlDecode(input) {
|
||||
const padded = input.padEnd(input.length + ((4 - (input.length % 4)) % 4), "=");
|
||||
return Buffer.from(padded.replaceAll("-", "+").replaceAll("_", "/"), "base64").toString("utf8");
|
||||
}
|
||||
|
||||
function decodeJwtPayload(token) {
|
||||
const parts = String(token).split(".");
|
||||
if (parts.length < 2) {
|
||||
throw new Error("id_token is not a JWT");
|
||||
}
|
||||
return JSON.parse(base64urlDecode(parts[1]));
|
||||
}
|
||||
|
||||
function base64url(buffer) {
|
||||
return Buffer.from(buffer)
|
||||
.toString("base64")
|
||||
.replaceAll("+", "-")
|
||||
.replaceAll("/", "_")
|
||||
.replaceAll("=", "");
|
||||
}
|
||||
|
||||
function pkceChallenge(verifier) {
|
||||
return base64url(createHash("sha256").update(verifier).digest());
|
||||
}
|
||||
|
||||
function claimDefinitions(defaults) {
|
||||
const claims = [
|
||||
{ key: "approvalLevel", valueType: "text", value: defaults.approvalLevel },
|
||||
{ key: "activeMember", valueType: "boolean", value: String(defaults.activeMember) },
|
||||
{ key: "score", valueType: "number", value: String(defaults.score) },
|
||||
{ key: "featureList", valueType: "array", value: JSON.stringify(defaults.featureList) },
|
||||
{ key: "preferences", valueType: "object", value: JSON.stringify(defaults.preferences) },
|
||||
{ key: "contractDate", valueType: "date", value: defaults.contractDate },
|
||||
{ key: "approvedAt", valueType: "datetime", value: defaults.approvedAt },
|
||||
].filter((claim) => claim.value !== undefined);
|
||||
if (defaults.adminManagedNote !== undefined) {
|
||||
claims.push({
|
||||
key: "adminManagedNote",
|
||||
valueType: "text",
|
||||
value: defaults.adminManagedNote,
|
||||
writePermission: "admin_only",
|
||||
});
|
||||
}
|
||||
return claims.map((claim) => ({
|
||||
namespace: "rp_claims",
|
||||
readPermission: "user_and_admin",
|
||||
writePermission: claim.writePermission ?? "user_and_admin",
|
||||
...claim,
|
||||
}));
|
||||
}
|
||||
|
||||
async function requestJson(url, options = {}) {
|
||||
const { cookieJar, ...fetchOptions } = options;
|
||||
const cookieHeader = cookieJar ? cookieHeaderFromJar(cookieJar) : "";
|
||||
const response = await fetch(url, {
|
||||
...fetchOptions,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Test-Role": "super_admin",
|
||||
...(cookieHeader ? { Cookie: cookieHeader } : {}),
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
});
|
||||
rememberCookies(cookieJar, response);
|
||||
const text = await response.text();
|
||||
let body = null;
|
||||
if (text) {
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
body = text;
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`${options.method ?? "GET"} ${url} failed: ${response.status} ${text}`);
|
||||
}
|
||||
return { response, body };
|
||||
}
|
||||
|
||||
async function createUser() {
|
||||
const body = {
|
||||
email,
|
||||
loginId,
|
||||
password,
|
||||
name: `RP Claims ${runId}`,
|
||||
role: "user",
|
||||
tenantSlug,
|
||||
};
|
||||
const result = await requestJson(`${gatewayUrl}/api/v1/admin/users`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const id = result.body?.id ?? result.body?.user?.id ?? result.body?.identityId;
|
||||
if (!id) {
|
||||
throw new Error(`created user response has no id: ${JSON.stringify(result.body)}`);
|
||||
}
|
||||
subjectByLoginId.set(loginIdentifier, id);
|
||||
report.steps.push({ step: "create-user", status: "ok", subject: id });
|
||||
return id;
|
||||
}
|
||||
|
||||
async function createClient(scenario) {
|
||||
const body = {
|
||||
id: scenario.clientId,
|
||||
name: `RP Claims E2E ${scenario.name} ${runId}`,
|
||||
type: "pkce",
|
||||
status: "active",
|
||||
redirectUris: [redirectUri],
|
||||
scopes: ["openid", "profile", "email"],
|
||||
grantTypes: ["authorization_code", "refresh_token"],
|
||||
responseTypes: ["code"],
|
||||
tokenEndpointAuthMethod: "none",
|
||||
skipConsent: false,
|
||||
metadata: {
|
||||
id_token_claims: claimDefinitions(scenario.defaults),
|
||||
tenant_id: "tenant-rp-claims-live-e2e",
|
||||
},
|
||||
};
|
||||
await requestJson(`${devfrontUrl}/api/v1/dev/clients`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
report.steps.push({ step: "create-client", status: "ok", clientId: scenario.clientId });
|
||||
}
|
||||
|
||||
function cookieHeaderFromJar(cookieJar) {
|
||||
return Object.entries(cookieJar ?? {})
|
||||
.map(([name, value]) => `${name}=${value}`)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
function rememberCookies(cookieJar, response) {
|
||||
if (!cookieJar || !response?.headers) {
|
||||
return;
|
||||
}
|
||||
const setCookies =
|
||||
typeof response.headers.getSetCookie === "function"
|
||||
? response.headers.getSetCookie()
|
||||
: response.headers.get("set-cookie")
|
||||
? [response.headers.get("set-cookie")]
|
||||
: [];
|
||||
for (const header of setCookies) {
|
||||
const firstPart = String(header ?? "").split(";")[0];
|
||||
const separator = firstPart.indexOf("=");
|
||||
if (separator <= 0) {
|
||||
continue;
|
||||
}
|
||||
cookieJar[firstPart.slice(0, separator)] = firstPart.slice(separator + 1);
|
||||
}
|
||||
}
|
||||
|
||||
async function manualFetch(url, options = {}) {
|
||||
try {
|
||||
const { cookieJar, ...fetchOptions } = options;
|
||||
const cookieHeader = cookieJar ? cookieHeaderFromJar(cookieJar) : "";
|
||||
const response = await fetch(url, {
|
||||
redirect: "manual",
|
||||
...fetchOptions,
|
||||
headers: {
|
||||
...(cookieHeader ? { Cookie: cookieHeader } : {}),
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
});
|
||||
rememberCookies(cookieJar, response);
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw new Error(`manual fetch failed for ${url}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function locationFrom(response, label) {
|
||||
const location = response.headers.get("location");
|
||||
if (!location) {
|
||||
throw new Error(`${label}: redirect location missing, status=${response.status}`);
|
||||
}
|
||||
return new URL(location, gatewayUrl).toString();
|
||||
}
|
||||
|
||||
function searchParam(url, name, label) {
|
||||
const value = new URL(url).searchParams.get(name);
|
||||
if (!value) {
|
||||
throw new Error(`${label}: ${name} missing from ${url}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function authorizeAndDecodeClaims(clientId, label) {
|
||||
const state = `${label}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
const nonce = `${label}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
const verifier = base64url(randomBytes(32));
|
||||
const cookieJar = {};
|
||||
const authorize = new URL(`${gatewayUrl}/oidc/oauth2/auth`);
|
||||
authorize.searchParams.set("client_id", clientId);
|
||||
authorize.searchParams.set("redirect_uri", redirectUri);
|
||||
authorize.searchParams.set("response_type", "code");
|
||||
authorize.searchParams.set("scope", "openid profile email");
|
||||
authorize.searchParams.set("state", state);
|
||||
authorize.searchParams.set("nonce", nonce);
|
||||
authorize.searchParams.set("code_challenge", pkceChallenge(verifier));
|
||||
authorize.searchParams.set("code_challenge_method", "S256");
|
||||
const trace = { label, clientId, authorize: authorize.toString(), redirects: [] };
|
||||
report.steps.push({ step: "authorize-token", status: "started", trace });
|
||||
|
||||
const first = await manualFetch(authorize, { cookieJar });
|
||||
let next = locationFrom(first, `${label}: authorize`);
|
||||
trace.redirects.push({ from: "authorize", status: first.status, location: next });
|
||||
const loginChallenge = searchParam(next, "login_challenge", `${label}: login redirect`);
|
||||
|
||||
const login = await requestJson(`${gatewayUrl}/api/v1/auth/password/login`, {
|
||||
method: "POST",
|
||||
cookieJar,
|
||||
body: JSON.stringify({ loginId: loginIdentifier, password, login_challenge: loginChallenge }),
|
||||
});
|
||||
next = login.body.redirectTo;
|
||||
if (!next) {
|
||||
throw new Error(`${label}: password login response missing redirectTo`);
|
||||
}
|
||||
trace.redirects.push({ from: "password-login", location: next });
|
||||
|
||||
let afterLogin = await manualFetch(next, { cookieJar });
|
||||
next = locationFrom(afterLogin, `${label}: after login`);
|
||||
trace.redirects.push({ from: "after-login", status: afterLogin.status, location: next });
|
||||
if (new URL(next).searchParams.has("consent_challenge")) {
|
||||
const consentChallenge = searchParam(next, "consent_challenge", `${label}: consent redirect`);
|
||||
const consent = await requestJson(`${gatewayUrl}/api/v1/auth/consent/accept`, {
|
||||
method: "POST",
|
||||
cookieJar,
|
||||
body: JSON.stringify({
|
||||
consent_challenge: consentChallenge,
|
||||
grant_scope: ["openid", "profile", "email"],
|
||||
}),
|
||||
});
|
||||
next = consent.body.redirectTo;
|
||||
if (!next) {
|
||||
throw new Error(`${label}: consent response missing redirectTo`);
|
||||
}
|
||||
trace.redirects.push({ from: "consent-accept", location: next });
|
||||
afterLogin = await manualFetch(next, { cookieJar });
|
||||
next = locationFrom(afterLogin, `${label}: after consent`);
|
||||
trace.redirects.push({ from: "after-consent", status: afterLogin.status, location: next });
|
||||
}
|
||||
|
||||
if (new URL(next).searchParams.has("error")) {
|
||||
const callback = new URL(next);
|
||||
throw new Error(`${label}: callback error ${callback.searchParams.get("error")} ${callback.searchParams.get("error_description") ?? ""}`.trim());
|
||||
}
|
||||
|
||||
if (!new URL(next).searchParams.has("code")) {
|
||||
if (new URL(next).hostname === "127.0.0.1") {
|
||||
throw new Error(`${label}: callback has no code: ${next}`);
|
||||
}
|
||||
const final = await manualFetch(next, { cookieJar });
|
||||
next = locationFrom(final, `${label}: final code redirect`);
|
||||
trace.redirects.push({ from: "final", status: final.status, location: next });
|
||||
}
|
||||
|
||||
const code = searchParam(next, "code", `${label}: callback`);
|
||||
const returnedState = searchParam(next, "state", `${label}: callback`);
|
||||
if (returnedState !== state) {
|
||||
throw new Error(`${label}: state mismatch`);
|
||||
}
|
||||
|
||||
const tokenResponse = await fetch(`${gatewayUrl}/oidc/oauth2/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
}),
|
||||
});
|
||||
const tokenText = await tokenResponse.text();
|
||||
let tokenBody;
|
||||
try {
|
||||
tokenBody = JSON.parse(tokenText);
|
||||
} catch {
|
||||
tokenBody = tokenText;
|
||||
}
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error(`${label}: token failed ${tokenResponse.status} ${tokenText}`);
|
||||
}
|
||||
const payload = decodeJwtPayload(tokenBody.id_token);
|
||||
report.steps.push({ step: "authorize-token", status: "ok", trace });
|
||||
return {
|
||||
idTokenPayload: payload,
|
||||
rpClaims: payload.rp_claims,
|
||||
tokenResponse: {
|
||||
token_type: tokenBody.token_type,
|
||||
expires_in: tokenBody.expires_in,
|
||||
scope: tokenBody.scope,
|
||||
has_access_token: Boolean(tokenBody.access_token),
|
||||
has_id_token: Boolean(tokenBody.id_token),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function updateClaimsFromDevfrontBrowser(page, scenario, metadata, label) {
|
||||
const result = await requestClaimsUpdateFromDevfrontBrowser(page, scenario, metadata);
|
||||
if (!result.ok) {
|
||||
throw new Error(`${label}: devfront metadata update failed ${result.status} ${JSON.stringify(result.body)}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function requestClaimsUpdateFromDevfrontBrowser(page, scenario, metadata) {
|
||||
const subject = subjectByLoginId.get(loginIdentifier);
|
||||
return page.evaluate(
|
||||
async ({ clientId, subject, metadata }) => {
|
||||
const response = await fetch(`/api/v1/dev/clients/${clientId}/users/${subject}/metadata`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Test-Role": "super_admin",
|
||||
"X-Tenant-ID": "tenant-rp-claims-live-e2e",
|
||||
},
|
||||
body: JSON.stringify({ metadata }),
|
||||
});
|
||||
const text = await response.text();
|
||||
let body;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
body = text;
|
||||
}
|
||||
return { ok: response.ok, status: response.status, body };
|
||||
},
|
||||
{ clientId: scenario.clientId, subject, metadata },
|
||||
);
|
||||
}
|
||||
|
||||
let browser;
|
||||
try {
|
||||
await mkdir(reportDir, { recursive: true });
|
||||
const subject = await createUser();
|
||||
report.subject = subject;
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
await createClient(scenario);
|
||||
}
|
||||
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({
|
||||
extraHTTPHeaders: {
|
||||
"X-Test-Role": "super_admin",
|
||||
"X-Tenant-ID": "tenant-rp-claims-live-e2e",
|
||||
},
|
||||
});
|
||||
await page.goto(devfrontUrl, { waitUntil: "domcontentloaded" });
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const scenarioReport = {
|
||||
name: scenario.name,
|
||||
clientId: scenario.clientId,
|
||||
defaultCheck: null,
|
||||
negativeChecks: [],
|
||||
updates: [],
|
||||
};
|
||||
const defaults = await authorizeAndDecodeClaims(scenario.clientId, `${scenario.name}-default`);
|
||||
assertDeepEqual(defaults.rpClaims, scenario.defaults, `${scenario.name}: default rp_claims`);
|
||||
scenarioReport.defaultCheck = {
|
||||
expected: scenario.defaults,
|
||||
actual: defaults.rpClaims,
|
||||
token: defaults.tokenResponse,
|
||||
};
|
||||
|
||||
const undefinedClaimResult = await requestClaimsUpdateFromDevfrontBrowser(page, scenario, {
|
||||
internalMemo: "must-be-rejected",
|
||||
});
|
||||
if (undefinedClaimResult.status !== 400) {
|
||||
throw new Error(`${scenario.name}: undefined claim update should be rejected with 400, got ${undefinedClaimResult.status}`);
|
||||
}
|
||||
scenarioReport.negativeChecks.push({
|
||||
label: "undefined-claim-rejected",
|
||||
status: undefinedClaimResult.status,
|
||||
body: undefinedClaimResult.body,
|
||||
});
|
||||
|
||||
if (Object.hasOwn(scenario.defaults, "score")) {
|
||||
const invalidTypeResult = await requestClaimsUpdateFromDevfrontBrowser(page, scenario, {
|
||||
score: "not-a-number",
|
||||
});
|
||||
if (invalidTypeResult.status !== 400) {
|
||||
throw new Error(`${scenario.name}: invalid number update should be rejected with 400, got ${invalidTypeResult.status}`);
|
||||
}
|
||||
scenarioReport.negativeChecks.push({
|
||||
label: "invalid-number-rejected",
|
||||
status: invalidTypeResult.status,
|
||||
body: invalidTypeResult.body,
|
||||
});
|
||||
}
|
||||
|
||||
for (const update of scenario.updates) {
|
||||
const updateResult = await updateClaimsFromDevfrontBrowser(page, scenario, update.metadata, `${scenario.name}-${update.label}`);
|
||||
const decoded = await authorizeAndDecodeClaims(scenario.clientId, `${scenario.name}-${update.label}`);
|
||||
assertDeepEqual(decoded.rpClaims, update.expected, `${scenario.name}.${update.label}: updated rp_claims`);
|
||||
assertAbsent(decoded.rpClaims, "internalMemo", `${scenario.name}.${update.label}`);
|
||||
assertAbsent(decoded.rpClaims, "approvalLevel_permissions", `${scenario.name}.${update.label}`);
|
||||
assertAbsent(decoded.rpClaims, "adminManagedNote_permissions", `${scenario.name}.${update.label}`);
|
||||
scenarioReport.updates.push({
|
||||
label: update.label,
|
||||
devfrontUpdateStatus: updateResult.status,
|
||||
expected: update.expected,
|
||||
actual: decoded.rpClaims,
|
||||
token: decoded.tokenResponse,
|
||||
});
|
||||
}
|
||||
report.scenarios.push(scenarioReport);
|
||||
}
|
||||
|
||||
const rpAAfter = report.scenarios.find((item) => item.name === "rp-a")?.updates.at(-1)?.actual;
|
||||
const rpBAfter = report.scenarios.find((item) => item.name === "rp-b")?.updates.at(-1)?.actual;
|
||||
if (rpAAfter?.approvalLevel === rpBAfter?.approvalLevel) {
|
||||
throw new Error("rp isolation failed: rp-a and rp-b approvalLevel unexpectedly match");
|
||||
}
|
||||
assertAbsent(rpBAfter, "internalMemo", "rp-b isolation");
|
||||
assertAbsent(rpBAfter, "adminManagedNote", "rp-b isolation");
|
||||
|
||||
report.passed = true;
|
||||
} catch (error) {
|
||||
report.passed = false;
|
||||
report.error = {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
};
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
}
|
||||
report.finishedAt = new Date().toISOString();
|
||||
const reportPath = `${reportDir}/rp-claims-live-e2e-${runId}.json`;
|
||||
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
console.log(JSON.stringify({ passed: report.passed, reportPath, runId }, null, 2));
|
||||
}
|
||||
@@ -279,10 +279,16 @@ test.describe("UserFront login performance budget", () => {
|
||||
const rootIndex = requestedUrls.findIndex(
|
||||
(url) => new URL(url).pathname === "/",
|
||||
);
|
||||
const signinIndex = requestedUrls.findIndex(
|
||||
(url) => new URL(url).pathname === "/ko/signin",
|
||||
);
|
||||
const bootstrapIndex = requestedUrls.findIndex((url) =>
|
||||
new URL(url).pathname.endsWith("/flutter_bootstrap.js"),
|
||||
);
|
||||
expect(rootIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(bootstrapIndex).toBe(-1);
|
||||
expect(signinIndex).toBeGreaterThan(rootIndex);
|
||||
if (bootstrapIndex >= 0) {
|
||||
expect(bootstrapIndex).toBeGreaterThan(signinIndex);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user