forked from baron/baron-sso
테스트 커버리지 보강 및 공통 유틸 테스트 추가
This commit is contained in:
77
devfront/src/components/common/LanguageSelector.test.tsx
Normal file
77
devfront/src/components/common/LanguageSelector.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
82
devfront/src/components/ui/copy-button.test.tsx
Normal file
82
devfront/src/components/ui/copy-button.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
72
devfront/src/features/coverage/commonAudit.test.ts
Normal file
72
devfront/src/features/coverage/commonAudit.test.ts
Normal 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("-");
|
||||
});
|
||||
});
|
||||
72
devfront/src/features/coverage/commonAuth.test.ts
Normal file
72
devfront/src/features/coverage/commonAuth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
201
devfront/src/features/overview/recentClientChanges.test.ts
Normal file
201
devfront/src/features/overview/recentClientChanges.test.ts
Normal 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" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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", "재발급"),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user