1
0
forked from baron/baron-sso

userfront e2e 전체 테스트

This commit is contained in:
2026-05-29 08:19:34 +09:00
parent dc16958804
commit da01f63c54
22 changed files with 1439 additions and 103 deletions

View File

@@ -0,0 +1,72 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import LoginPage from "./LoginPage";
const mockSigninRedirect = vi.fn();
const mockUseAuth = vi.fn();
vi.mock("react-oidc-context", () => ({
useAuth: () => mockUseAuth(),
}));
function renderLoginPage(initialEntry: string) {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<LoginPage />
</MemoryRouter>,
);
}
describe("LoginPage", () => {
beforeEach(() => {
Object.defineProperty(window, "crypto", {
configurable: true,
value: {},
});
Object.defineProperty(window, "isSecureContext", {
configurable: true,
value: false,
});
mockSigninRedirect.mockReset();
mockUseAuth.mockReturnValue({
activeNavigator: undefined,
error: undefined,
isAuthenticated: false,
isLoading: false,
signinRedirect: mockSigninRedirect,
});
});
it("shows an actionable error instead of starting PKCE when WebCrypto is unavailable", async () => {
renderLoginPage("/login?returnTo=%2F");
await userEvent.click(screen.getByRole("button", { name: /SSO 계정으로 로그인/i }));
expect(mockSigninRedirect).not.toHaveBeenCalled();
expect(screen.getByRole("alert")).toHaveTextContent(
/SSO 로그인을 시작할 수 없습니다/,
);
});
it("preserves the returnTo query when starting SSO manually", async () => {
Object.defineProperty(window, "crypto", {
configurable: true,
value: { subtle: {} },
});
Object.defineProperty(window, "isSecureContext", {
configurable: true,
value: true,
});
renderLoginPage("/login?returnTo=%2Fusers%3Fpage%3D2");
await userEvent.click(screen.getByRole("button", { name: /SSO 계정으로 로그인/i }));
expect(mockSigninRedirect).toHaveBeenCalledWith({
state: {
returnTo: "/users?page=2",
},
});
});
});

View File

@@ -1,5 +1,5 @@
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
import { useEffect, useRef } from "react";
import { AlertTriangle, ExternalLink, LogIn, ShieldHalf } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "react-oidc-context";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Button } from "../../components/ui/button";
@@ -10,15 +10,36 @@ import {
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { canStartBrowserPkceLogin } from "../../lib/authConfig";
import { debugLog } from "../../lib/debugLog";
const insecurePkceMessage =
"이 주소에서는 브라우저 보안 정책 때문에 SSO 로그인을 시작할 수 없습니다. HTTPS 또는 localhost로 접속하거나, 내부망/host.docker.internal 개발 접속은 Chrome의 insecure-origin secure context 옵션에 실제 auth UI origin(예: http://host.docker.internal:5000)을 정확히 등록해 주세요.";
function isPkceSetupFailure(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return /Crypto\.subtle|WebCrypto|PKCE|secure context|subtle/i.test(message);
}
function LoginPage() {
const auth = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const autoStartedRef = useRef(false);
const [loginError, setLoginError] = useState<string | null>(null);
const returnTo = searchParams.get("returnTo") || "/";
const shouldAutoLogin = searchParams.get("auto") === "1";
const authErrorMessage = useMemo(() => {
const message = auth.error?.message;
if (!message) {
return null;
}
if (message.includes("Crypto.subtle")) {
return insecurePkceMessage;
}
return message;
}, [auth.error?.message]);
const visibleLoginError = loginError || authErrorMessage;
useEffect(() => {
debugLog("[LoginPage] Auth state check:", {
@@ -42,21 +63,46 @@ function LoginPage() {
if (autoStartedRef.current || auth.isLoading || auth.activeNavigator) {
return;
}
if (!canStartBrowserPkceLogin()) {
setLoginError(insecurePkceMessage);
return;
}
autoStartedRef.current = true;
void auth.signinRedirect({
state: {
returnTo,
},
});
void auth
.signinRedirect({
state: {
returnTo,
},
})
.catch((error) => {
if (isPkceSetupFailure(error)) {
setLoginError(insecurePkceMessage);
return;
}
console.error("Auto login redirect failed", error);
});
}, [auth, auth.activeNavigator, auth.isLoading, returnTo, shouldAutoLogin]);
const handleSSOLogin = () => {
void auth.signinRedirect({
state: {
returnTo: "/",
},
});
const handleSSOLogin = async () => {
try {
setLoginError(null);
if (!canStartBrowserPkceLogin()) {
setLoginError(insecurePkceMessage);
return;
}
await auth.signinRedirect({
state: {
returnTo,
},
});
} catch (error) {
if (isPkceSetupFailure(error)) {
setLoginError(insecurePkceMessage);
return;
}
console.error("Redirect login failed", error);
}
};
return (
@@ -85,11 +131,7 @@ function LoginPage() {
variant="ghost"
className="p-0 h-auto text-destructive underline mt-2 hover:bg-transparent"
onClick={() => {
void auth.signinRedirect({
state: {
returnTo,
},
});
void handleSSOLogin();
}}
>
@@ -127,6 +169,16 @@ function LoginPage() {
)}
</Button>
{visibleLoginError ? (
<div
role="alert"
className="flex gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm leading-5 text-destructive"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{visibleLoginError}</span>
</div>
) : null}
<p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed">
15 .
<br />

View File

@@ -128,4 +128,23 @@ nullable@test.com,Nullable User,010-1111-1111,user,primary-tenant,개발팀,책
});
expect(result[1].additionalAppointments).toBeUndefined();
});
it("should preserve sub_email as secondary email metadata without replacing primary email", () => {
const csv = `email,name,tenant_slug,employee_id,sub_email
primary@samaneng.com,Primary User,rnd-saman,EMP001,secondary@hanmaceng.co.kr`;
const result = parseUserCSV(csv);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
email: "primary@samaneng.com",
tenantSlug: "rnd-saman",
metadata: {
employee_id: "EMP001",
sub_email: "secondary@hanmaceng.co.kr",
secondary_emails: ["secondary@hanmaceng.co.kr"],
aliasEmails: ["secondary@hanmaceng.co.kr"],
},
});
});
});

View File

@@ -12,11 +12,13 @@ export function parseUserCSV(text: string): BulkUserItem[] {
for (let i = 1; i < records.length; i++) {
const values = records[i].map((v) => v.trim());
if (values.every((value) => value === "")) continue;
const item: Partial<BulkUserItem> & { metadata: Record<string, string> } = {
const item: Partial<BulkUserItem> & {
metadata: Record<string, unknown>;
} = {
metadata: {},
};
const additionalAppointment: BulkUserAppointment & {
metadata: Record<string, string>;
metadata: Record<string, unknown>;
} = {
metadata: {},
};
@@ -94,6 +96,8 @@ export function parseUserCSV(text: string): BulkUserItem[] {
item.jobTitle = value;
} else if (header === "employee_id") {
item.metadata.employee_id = value;
} else if (header === "secondary_emails") {
applySecondaryEmailMetadata(item, value);
} else if (header === "tenant_slug1") {
additionalAppointment.tenantSlug = value;
} else if (header === "department1") {
@@ -117,6 +121,7 @@ export function parseUserCSV(text: string): BulkUserItem[] {
item.metadata.personal_email = value;
} else if (header === "subemail") {
item.metadata.naverworks_sub_email = value;
addWorksmobileAliasEmails(item, splitEmailTokens(value).slice(1));
item.email = firstEmailToken(value) || item.email;
} else if (header === "nickname") {
item.metadata.naverworks_nickname = value;
@@ -185,7 +190,7 @@ export function parseUserCSV(text: string): BulkUserItem[] {
}
function cleanAdditionalAppointment(
appointment: BulkUserAppointment & { metadata: Record<string, string> },
appointment: BulkUserAppointment & { metadata: Record<string, unknown> },
) {
const metadata =
Object.keys(appointment.metadata).length > 0
@@ -210,7 +215,31 @@ function cleanAdditionalAppointment(
}
function normalizeHeader(header: string) {
return header
const raw = header.trim().replace(/^\uFEFF/, "");
const lower = raw.toLowerCase();
const separatorNormalized = lower.replace(/-+/g, "_").replace(/_+/g, "_");
const compactKorean = raw.replace(/\s+/g, "");
if (
[
"sub_email",
"secondary_email",
"secondary_emails",
"additional_email",
"additional_emails",
"alias_email",
"alias_emails",
"worksmobile_alias_email",
"worksmobile_alias_emails",
].includes(separatorNormalized) ||
["보조이메일", "보조메일", "추가이메일", "추가메일"].includes(
compactKorean,
)
) {
return "secondary_emails";
}
return raw
.trim()
.toLowerCase()
.replace(/^\uFEFF/, "")
@@ -264,12 +293,69 @@ function parseCSVRecords(text: string) {
}
function firstEmailToken(value: string) {
return (
value
.split(/[;,]/)
.map((token) => token.trim())
.find((token) => token.includes("@")) ?? ""
);
return splitEmailTokens(value)[0] ?? "";
}
function splitEmailTokens(value: string) {
return value
.split(/[;,\n\r\t]/)
.map((token) => token.trim())
.filter((token) => token.includes("@"));
}
function metadataString(value: unknown) {
return typeof value === "string" ? value : "";
}
function metadataEmailList(value: unknown) {
if (Array.isArray(value)) {
return value
.map((item) => (typeof item === "string" ? item.trim() : ""))
.filter(Boolean);
}
if (typeof value === "string") {
return splitEmailTokens(value);
}
return [];
}
function uniqueEmails(values: string[]) {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
const normalized = value.trim().toLowerCase();
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
result.push(normalized);
}
return result;
}
function addWorksmobileAliasEmails(
item: Partial<BulkUserItem> & { metadata: Record<string, unknown> },
emails: string[],
) {
const aliases = uniqueEmails([
...metadataEmailList(item.metadata.aliasEmails),
...emails,
]);
if (aliases.length > 0) {
item.metadata.aliasEmails = aliases;
item.metadata.worksmobileAliasEmails = aliases;
}
}
function applySecondaryEmailMetadata(
item: Partial<BulkUserItem> & { metadata: Record<string, unknown> },
value: string,
) {
const emails = splitEmailTokens(value);
item.metadata.sub_email = value;
item.metadata.secondary_emails = uniqueEmails([
...metadataEmailList(item.metadata.secondary_emails),
...emails,
]);
addWorksmobileAliasEmails(item, emails);
}
function splitOrganizationPath(value: string) {
@@ -280,28 +366,32 @@ function splitOrganizationPath(value: string) {
}
function applyNaverWorksFallbacks(
item: Partial<BulkUserItem> & { metadata: Record<string, string> },
item: Partial<BulkUserItem> & { metadata: Record<string, unknown> },
) {
if (!item.name) {
const firstName = item.metadata.naverworks_first_name ?? "";
const lastName = item.metadata.naverworks_last_name ?? "";
const firstName = metadataString(item.metadata.naverworks_first_name);
const lastName = metadataString(item.metadata.naverworks_last_name);
item.name = [firstName, lastName].filter(Boolean).join(" ").trim();
if (!item.name && item.metadata.naverworks_nickname) {
item.name = item.metadata.naverworks_nickname;
const nickname = metadataString(item.metadata.naverworks_nickname);
if (!item.name && nickname) {
item.name = nickname;
}
}
if (!item.email) {
item.email = item.metadata.personal_email;
item.email = metadataString(item.metadata.personal_email);
}
if (!item.phone) {
const countryCode = item.metadata.naverworks_mobile_country_code ?? "";
const number = item.metadata.naverworks_mobile_numbers ?? "";
const countryCode = metadataString(
item.metadata.naverworks_mobile_country_code,
);
const number = metadataString(item.metadata.naverworks_mobile_numbers);
item.phone = `${countryCode}${number}`.replace(/\s/g, "");
}
if (!item.grade && item.metadata.naverworks_level) {
item.grade = item.metadata.naverworks_level;
const level = metadataString(item.metadata.naverworks_level);
if (!item.grade && level) {
item.grade = level;
}
}

View File

@@ -101,4 +101,60 @@ describe("applyGeneralPlanningOfficePriority", () => {
employee_id: "EMP001",
});
});
it("uses GPDTDC as the Baron representative while keeping the first affiliation primary for WorksMobile", () => {
const user: BulkUserItem = {
email: "gpdtdc-dual@test.com",
name: "GPDTDC Dual User",
tenantSlug: "rnd-saman",
department: "삼안기술연구소",
grade: "책임",
metadata: {
employee_id: "SAMAN001",
},
additionalAppointments: [
{
tenantSlug: "tdc",
tenantName: "기술개발센터",
grade: "책임연구원",
metadata: {
employee_id: "B24051",
},
},
],
};
const result = applyGeneralPlanningOfficePriority(user, [
tenant("family", "한맥가족사", "hanmac-family"),
tenant("gpdtdc", "총괄기획&기술개발센터", "gpdtdc", "family"),
tenant("tdc", "기술개발센터", "tdc", "gpdtdc"),
tenant("saman", "삼안", "rnd-saman"),
]);
expect(result.tenantSlug).toBe("gpdtdc");
expect(result.tenantImport).toMatchObject({
slug: "gpdtdc",
name: "총괄기획&기술개발센터",
});
expect(result.metadata.employee_id).toBe("SAMAN001");
expect(result.additionalAppointments).toEqual([
expect.objectContaining({
tenantSlug: "rnd-saman",
isPrimary: true,
department: "삼안기술연구소",
grade: "책임",
metadata: {
employee_id: "SAMAN001",
},
}),
expect.objectContaining({
tenantSlug: "tdc",
isPrimary: false,
grade: "책임연구원",
metadata: {
employee_id: "B24051",
},
}),
]);
});
});

View File

@@ -1,9 +1,18 @@
import type { BulkUserItem, TenantSummary } from "../../../lib/adminApi";
import type {
BulkUserAppointment,
BulkUserItem,
TenantSummary,
} from "../../../lib/adminApi";
export function applyGeneralPlanningOfficePriority(
user: BulkUserItem,
tenants: TenantSummary[],
): BulkUserItem {
const gpdtdcRepresentative = applyGPDTDCRepresentativeTenant(user, tenants);
if (gpdtdcRepresentative) {
return gpdtdcRepresentative;
}
const firstAdditional = user.additionalAppointments?.[0];
const secondarySlug = firstAdditional?.tenantSlug;
@@ -67,6 +76,82 @@ export function applyGeneralPlanningOfficePriority(
};
}
function applyGPDTDCRepresentativeTenant(
user: BulkUserItem,
tenants: TenantSummary[],
): BulkUserItem | undefined {
const root = findGPDTDCRootTenant(tenants);
if (!root) return undefined;
const primarySlug = user.tenantSlug || "";
const hasPrimaryUnderRoot = isUnderTenant(primarySlug, root, tenants);
const hasAppointmentUnderRoot = (user.additionalAppointments ?? []).some(
(appointment) => isUnderTenant(appointment.tenantSlug || "", root, tenants),
);
if (!hasPrimaryUnderRoot && !hasAppointmentUnderRoot) return undefined;
if (primarySlug === root.slug) return undefined;
const worksmobileAppointments: BulkUserAppointment[] = [];
const seen = new Set<string>();
const addAppointment = (
appointment: BulkUserAppointment,
fallbackKey: string,
) => {
const key = appointment.tenantSlug || appointment.tenantId || fallbackKey;
if (!key || key === root.slug || seen.has(key)) return;
seen.add(key);
worksmobileAppointments.push(appointment);
};
addAppointment(buildPrimaryAppointment(user), "primary");
for (const appointment of user.additionalAppointments ?? []) {
addAppointment(
{ ...appointment, isPrimary: false },
appointment.tenantSlug || "",
);
}
return {
...user,
tenantSlug: root.slug,
tenantImport: {
...(user.tenantImport ?? {}),
sourceTenantId: undefined,
slug: root.slug,
name: root.name,
},
additionalAppointments:
worksmobileAppointments.length > 0 ? worksmobileAppointments : undefined,
};
}
function buildPrimaryAppointment(user: BulkUserItem): BulkUserAppointment {
return {
...(user.tenantId ? { tenantId: user.tenantId } : {}),
...(user.tenantSlug ? { tenantSlug: user.tenantSlug } : {}),
isPrimary: true,
isOwner: false,
...(user.department ? { department: user.department } : {}),
...(user.grade ? { grade: user.grade } : {}),
...(user.position ? { position: user.position } : {}),
...(user.jobTitle ? { jobTitle: user.jobTitle } : {}),
metadata: { ...user.metadata },
};
}
function findGPDTDCRootTenant(tenants: TenantSummary[]) {
return tenants.find((tenant) => {
const slug = tenant.slug.trim().toLowerCase();
const name = tenant.name.replace(/\s+/g, "").toLowerCase();
return (
slug === "gpdtdc" ||
name === "gpdtdc" ||
name.includes("총괄기획&기술개발센터") ||
name.includes("총괄기획기술개발센터")
);
});
}
function isUnderGeneralPlanningOffice(
tenantSlug: string,
tenants: TenantSummary[],
@@ -80,6 +165,20 @@ function isUnderGeneralPlanningOffice(
return false;
}
function isUnderTenant(
tenantSlug: string,
root: TenantSummary,
tenants: TenantSummary[],
): boolean {
let current = tenants.find((tenant) => tenant.slug === tenantSlug);
while (current) {
if (current.id === root.id || current.slug === root.slug) return true;
if (!current.parentId) break;
current = tenants.find((tenant) => tenant.id === current?.parentId);
}
return false;
}
function stringValue(value: unknown) {
return typeof value === "string" ? value : undefined;
}

View File

@@ -745,7 +745,7 @@ export type BulkUserItem = {
memo?: string;
emailDomain?: string;
};
metadata: Record<string, string>;
metadata: Record<string, unknown>;
};
export type BulkUserResult = {

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
buildAdminAuthRedirectUris,
canStartBrowserPkceLogin,
resolveAdminPublicOrigin,
} from "./authConfig";
@@ -24,4 +25,39 @@ describe("admin auth config", () => {
"http://localhost:5173",
);
});
it("blocks browser PKCE login when WebCrypto is unavailable", () => {
expect(
canStartBrowserPkceLogin({
isSecureContext: false,
origin: "http://localhost:5173",
cryptoSubtleAvailable: false,
}),
).toBe(false);
expect(
canStartBrowserPkceLogin({
isSecureContext: true,
origin: "https://admin.example.test",
cryptoSubtleAvailable: false,
}),
).toBe(false);
});
it("allows trusted local and private-network origins only when WebCrypto is available", () => {
for (const origin of [
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://host.docker.internal:5173",
"http://172.16.9.189:5173",
"http://192.168.0.20:5173",
]) {
expect(
canStartBrowserPkceLogin({
isSecureContext: false,
origin,
cryptoSubtleAvailable: true,
}),
).toBe(true);
}
});
});

View File

@@ -31,3 +31,58 @@ export function buildAdminAuthRedirectUris(
popupRedirectUri: `${publicOrigin}${ADMIN_AUTH_CALLBACK_PATH}`,
};
}
export type BrowserPkceLoginCheck = {
isSecureContext?: boolean;
origin?: string;
cryptoSubtleAvailable?: boolean;
};
const devTrustedPkceHosts = new Set([
"localhost",
"127.0.0.1",
"::1",
"host.docker.internal",
]);
function isPrivateIPv4(hostname: string) {
const parts = hostname.split(".").map((part) => Number.parseInt(part, 10));
if (
parts.length !== 4 ||
parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)
) {
return false;
}
const [first, second] = parts;
return (
first === 10 ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 168)
);
}
function isDevTrustedPkceOrigin(origin: string) {
try {
const hostname = new URL(origin).hostname;
return devTrustedPkceHosts.has(hostname) || isPrivateIPv4(hostname);
} catch {
return false;
}
}
export function canStartBrowserPkceLogin({
isSecureContext = window.isSecureContext,
origin = window.location.origin,
cryptoSubtleAvailable = Boolean(window.crypto?.subtle),
}: BrowserPkceLoginCheck = {}) {
if (!cryptoSubtleAvailable) {
return false;
}
if (isSecureContext) {
return true;
}
return isDevTrustedPkceOrigin(origin);
}