1
0
forked from baron/baron-sso

테스트 커버리지 보강 및 공통 유틸 테스트 추가

This commit is contained in:
2026-06-01 16:54:58 +09:00
parent d0f44de2d1
commit a4d457073a
6 changed files with 511 additions and 1 deletions

View File

@@ -0,0 +1,77 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../lib/i18n", () => ({
t: (_key: string, fallback?: string) => fallback ?? _key,
}));
import LanguageSelector from "./LanguageSelector";
const roots: Root[] = [];
beforeEach(() => {
window.localStorage.clear();
window.history.replaceState({}, "", "/");
document.body.innerHTML = "";
});
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
vi.restoreAllMocks();
});
function renderSelector() {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
act(() => {
root.render(<LanguageSelector />);
});
return container;
}
describe("LanguageSelector", () => {
it("prefers the locale stored in localStorage", () => {
window.localStorage.setItem("locale", "en");
const container = renderSelector();
const select = container.querySelector("select") as HTMLSelectElement;
expect(select.value).toBe("en");
});
it("falls back to the path locale when storage is empty", () => {
window.history.replaceState({}, "", "/ko");
const container = renderSelector();
const select = container.querySelector("select") as HTMLSelectElement;
expect(select.value).toBe("ko");
});
it("saves the selected locale and dispatches a development event", () => {
vi.stubEnv("MODE", "development");
const dispatchEvent = vi.spyOn(window, "dispatchEvent");
window.history.replaceState({}, "", "/ko");
const container = renderSelector();
const select = container.querySelector("select") as HTMLSelectElement;
act(() => {
select.value = "en";
select.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(window.localStorage.getItem("locale")).toBe("en");
expect(dispatchEvent).toHaveBeenCalled();
expect(select.value).toBe("en");
});
});

View File

@@ -0,0 +1,82 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { CopyButton } from "./copy-button";
const roots: Root[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
vi.restoreAllMocks();
delete (navigator as Navigator & { clipboard?: unknown }).clipboard;
Object.defineProperty(window, "isSecureContext", {
value: false,
configurable: true,
});
document.body.innerHTML = "";
});
function renderCopyButton(value: string, onCopy = vi.fn()) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
act(() => {
root.render(<CopyButton value={value} onCopy={onCopy} />);
});
return { container, onCopy };
}
describe("CopyButton", () => {
it("copies with the clipboard API when secure context is available", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
Object.defineProperty(window, "isSecureContext", {
value: true,
configurable: true,
});
const { container, onCopy } = renderCopyButton("client-secret");
const button = container.querySelector("button");
expect(button).not.toBeNull();
await act(async () => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(writeText).toHaveBeenCalledWith("client-secret");
expect(onCopy).toHaveBeenCalledTimes(1);
});
it("falls back to execCommand when clipboard API is unavailable", async () => {
const execCommand = vi.fn(() => true);
Object.defineProperty(document, "execCommand", {
value: execCommand,
configurable: true,
});
Object.defineProperty(window, "isSecureContext", {
value: false,
configurable: true,
});
const { container, onCopy } = renderCopyButton("client-secret");
const button = container.querySelector("button");
expect(button).not.toBeNull();
await act(async () => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(execCommand).toHaveBeenCalledWith("copy");
expect(onCopy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import {
formatAuditDateParts,
formatAuditValue,
parseAuditDetails,
resolveAuditAction,
resolveAuditActor,
resolveAuditTarget,
} from "../../../../common/core/audit";
describe("common audit helpers", () => {
it("parses audit details and falls back on invalid payloads", () => {
expect(parseAuditDetails()).toEqual({});
expect(parseAuditDetails("not-json")).toEqual({});
expect(parseAuditDetails('{"action":"ADD_RELATION"}')).toEqual({
action: "ADD_RELATION",
});
});
it("formats audit values and dates", () => {
const circular: Record<string, unknown> = {};
circular.self = circular;
expect(formatAuditValue(null)).toBe("-");
expect(formatAuditValue("hello")).toBe("hello");
expect(formatAuditValue({ a: 1 })).toBe('{"a":1}');
expect(formatAuditValue(circular)).toBe("[object Object]");
expect(formatAuditDateParts("")).toEqual({ date: "-", time: "-" });
expect(formatAuditDateParts("invalid")).toEqual({
date: "invalid",
time: "-",
});
const parsed = formatAuditDateParts("2026-05-27T07:43:39.000Z");
expect(parsed.date).toBe("2026-05-27");
expect(parsed.time).not.toBe("-");
});
it("resolves audit actor, action, and target consistently", () => {
expect(
resolveAuditActor(
{ user_id: "actor-1" },
{ actor_id: "actor-2" },
),
).toBe("actor-1");
expect(
resolveAuditActor({ user_id: "" }, { actor_id: "actor-2" }),
).toBe("actor-2");
expect(resolveAuditActor({ user_id: "" }, {})).toBe("-");
expect(
resolveAuditAction(
{ event_type: "UPDATE_CLIENT" },
{ action: "ADD_RELATION" },
),
).toBe("ADD_RELATION");
expect(
resolveAuditAction(
{ event_type: "UPDATE_CLIENT" },
{ method: "POST", path: "/dev/clients" },
),
).toBe("POST /dev/clients");
expect(resolveAuditAction({ event_type: "UPDATE_CLIENT" }, {})).toBe(
"UPDATE_CLIENT",
);
expect(resolveAuditTarget({ target: "target-1" })).toBe("target-1");
expect(resolveAuditTarget({ target_id: "target-2" })).toBe("target-2");
expect(resolveAuditTarget({})).toBe("-");
});
});

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_OIDC_REDIRECT_PATH,
DEFAULT_OIDC_SCOPE,
buildCommonOidcRuntimeConfig,
buildCommonUserManagerSettings,
shouldStartLoginRedirect,
} from "../../../../common/core/auth";
describe("common auth helpers", () => {
it("builds the runtime OIDC config with sensible defaults", () => {
const config = buildCommonOidcRuntimeConfig({
authority: "https://issuer.example.com",
clientId: "client-1",
userStore: { kind: "store" },
});
expect(config).toEqual({
authority: "https://issuer.example.com",
client_id: "client-1",
redirect_uri: `${window.location.origin}${DEFAULT_OIDC_REDIRECT_PATH}`,
response_type: "code",
scope: DEFAULT_OIDC_SCOPE,
post_logout_redirect_uri: window.location.origin,
popup_redirect_uri: `${window.location.origin}${DEFAULT_OIDC_REDIRECT_PATH}`,
userStore: { kind: "store" },
automaticSilentRenew: false,
});
});
it("copies user manager config and fills missing string fields", () => {
expect(
buildCommonUserManagerSettings({
authority: "https://issuer.example.com",
}),
).toEqual({
authority: "https://issuer.example.com",
client_id: "",
redirect_uri: "",
});
});
it("decides when to start login redirects", () => {
expect(
shouldStartLoginRedirect({
pathname: "/clients",
isRedirecting: false,
}),
).toBe(true);
expect(
shouldStartLoginRedirect({
pathname: "/login",
isRedirecting: false,
}),
).toBe(false);
expect(
shouldStartLoginRedirect({
pathname: `${DEFAULT_OIDC_REDIRECT_PATH}/callback`,
isRedirecting: false,
}),
).toBe(false);
expect(
shouldStartLoginRedirect({
pathname: "/clients",
isRedirecting: true,
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,201 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { ClientSummary, DevAuditLog } from "../../lib/devApi";
import {
buildRecentClientChangeDetails,
buildRecentClientChanges,
getRecentClientActionLabel,
} from "./recentClientChanges";
function makeClient(id: string, name = id): ClientSummary {
return {
id,
name,
type: "private",
status: "active",
createdAt: "2026-05-27T00:00:00.000Z",
redirectUris: [],
scopes: [],
};
}
function makeAuditLog(
eventId: string,
timestamp: string,
action: string,
targetId: string,
details: Record<string, unknown>,
): DevAuditLog {
return {
event_id: eventId,
timestamp,
user_id: "actor-1",
event_type: "AUDIT",
status: "success",
ip_address: "127.0.0.1",
user_agent: "vitest",
details: JSON.stringify({
action,
target_id: targetId,
...details,
}),
};
}
describe("recent client changes", () => {
beforeEach(() => {
window.localStorage.clear();
window.history.replaceState({}, "", "/");
});
function mockLocale(locale: "ko" | "en") {
window.localStorage.clear();
window.history.replaceState({}, "", `/${locale}`);
}
it("translates action labels and relation details by locale", () => {
mockLocale("en");
expect(getRecentClientActionLabel("CREATE_CLIENT")).toBe("App creation");
expect(getRecentClientActionLabel("UPDATE_CLIENT")).toBe("Settings changes");
expect(getRecentClientActionLabel("UPDATE_CLIENT_STATUS")).toBe(
"Status changes",
);
expect(getRecentClientActionLabel("ROTATE_SECRET")).toBe(
"Client secret rotation",
);
expect(getRecentClientActionLabel("ADD_RELATION")).toBe("Add Relationship");
expect(getRecentClientActionLabel("REMOVE_RELATION")).toBe("Remove");
expect(getRecentClientActionLabel("DELETE_CLIENT")).toBe("App deletion");
expect(getRecentClientActionLabel("OTHER_ACTION")).toBe("OTHER_ACTION");
expect(
buildRecentClientChangeDetails("ROTATE_SECRET", {
after: {},
}),
).toEqual([{ label: "Client Secret", value: "Secret Rotated" }]);
expect(
buildRecentClientChangeDetails("ADD_RELATION", {
after: {
relation: "admins",
subject: "User:1",
},
}),
).toEqual([
{ label: "Relation", value: "admins" },
{ label: "Subject", value: "User:1" },
]);
});
it("builds recent client changes with sorting, filtering, and detail slicing", () => {
mockLocale("ko");
const clients = [makeClient("client-a", "Alpha"), makeClient("client-b", "")];
const auditLogs = [
makeAuditLog("evt-1", "2026-05-27T07:00:00.000Z", "CREATE_CLIENT", "client-a", {
after: { name: "Alpha", type: "private", status: "active" },
}),
makeAuditLog("evt-2", "2026-05-27T08:00:00.000Z", "UPDATE_CLIENT", "client-a", {
before: {
name: "Alpha old",
status: "inactive",
sameField: "same",
oldField: "old-value",
},
after: {
name: "Alpha new",
status: "active",
sameField: "same",
newField: "new-value",
},
}),
makeAuditLog("evt-3", "2026-05-27T09:00:00.000Z", "UPDATE_CLIENT_STATUS", "client-a", {
before: { status: "inactive" },
after: { status: "active" },
}),
makeAuditLog("evt-4", "2026-05-27T10:00:00.000Z", "ADD_RELATION", "client-b", {
after: {
relation: "audit_viewer",
subject: "User:89692983-f512-4d96-845d-ac6123d08b95",
},
}),
makeAuditLog("evt-5", "2026-05-27T11:00:00.000Z", "REMOVE_RELATION", "client-b", {
before: {
relation: "admins",
subject: "User:89692983-f512-4d96-845d-ac6123d08b95",
},
}),
makeAuditLog("evt-6", "2026-05-27T12:00:00.000Z", "ROTATE_SECRET", "client-a", {
after: {},
}),
makeAuditLog("evt-7", "2026-05-27T13:00:00.000Z", "DELETE_CLIENT", "client-a", {
before: {
name: "Alpha",
status: "inactive",
},
}),
makeAuditLog("evt-8", "2026-05-27T14:00:00.000Z", "UNSUPPORTED_ACTION", "client-a", {
after: { name: "Ignored" },
}),
];
const changes = buildRecentClientChanges(
auditLogs,
clients,
);
expect(changes).toHaveLength(7);
expect(changes[0]).toMatchObject({
eventId: "evt-7",
clientName: "Alpha",
actionLabel: "앱 삭제",
});
expect(changes[1]).toMatchObject({
eventId: "evt-6",
clientName: "Alpha",
actionLabel: "클라이언트 시크릿 재발급",
detailLabels: [
{
label: "클라이언트 시크릿",
value: "Client Secret이 재발급되었습니다.",
},
],
});
expect(changes[2]).toMatchObject({
eventId: "evt-5",
clientName: "client-b",
actionLabel: "제외",
detailLabels: [
{ label: "관계", value: "admins" },
{
label: "주체",
value: "User:89692983-f512-4d96-845d-ac6123d08b95",
},
],
});
expect(changes[4]).toMatchObject({
eventId: "evt-3",
actionLabel: "상태 변경",
clientName: "Alpha",
detailLabels: [{ value: "inactive → active" }],
});
expect(changes[5]).toMatchObject({
eventId: "evt-2",
actionLabel: "설정 변경",
detailLabels: [
{ label: "애플리케이션", value: "Alpha old → Alpha new" },
{ label: "상태", value: "inactive → active" },
{ label: "oldField", value: "old-value" },
],
});
expect(changes[6]).toMatchObject({
eventId: "evt-1",
actionLabel: "앱 생성",
detailLabels: [
{ label: "애플리케이션", value: "Alpha" },
{ label: "유형", value: "private" },
{ label: "상태", value: "active" },
],
});
});
});

View File

@@ -59,6 +59,12 @@ export function getRecentClientActionLabel(action: string) {
function getRecentClientFieldLabel(key: string) {
switch (key) {
case "name":
return t("ui.dev.clients.table.application", "Application");
case "type":
return t("ui.dev.clients.table.type", "Type");
case "status":
return t("ui.dev.clients.table.status", "Status");
case "relation":
return t("ui.dev.clients.relationships.relation", "관계");
case "subject":
@@ -84,7 +90,7 @@ export function buildRecentClientChangeDetails(
return [
{
label: getRecentClientFieldLabel("client_secret"),
value: t("msg.dev.clients.secret_rotated", "재발급"),
value: t("msg.dev.clients.details.secret_rotated", "재발급"),
},
];
}