diff --git a/devfront/src/components/common/LanguageSelector.test.tsx b/devfront/src/components/common/LanguageSelector.test.tsx new file mode 100644 index 00000000..302dd23c --- /dev/null +++ b/devfront/src/components/common/LanguageSelector.test.tsx @@ -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(); + }); + + 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"); + }); +}); diff --git a/devfront/src/components/ui/copy-button.test.tsx b/devfront/src/components/ui/copy-button.test.tsx new file mode 100644 index 00000000..9ddea7e5 --- /dev/null +++ b/devfront/src/components/ui/copy-button.test.tsx @@ -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(); + }); + + 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); + }); +}); diff --git a/devfront/src/features/coverage/commonAudit.test.ts b/devfront/src/features/coverage/commonAudit.test.ts new file mode 100644 index 00000000..43add445 --- /dev/null +++ b/devfront/src/features/coverage/commonAudit.test.ts @@ -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 = {}; + 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("-"); + }); +}); diff --git a/devfront/src/features/coverage/commonAuth.test.ts b/devfront/src/features/coverage/commonAuth.test.ts new file mode 100644 index 00000000..5618e53d --- /dev/null +++ b/devfront/src/features/coverage/commonAuth.test.ts @@ -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); + }); +}); diff --git a/devfront/src/features/overview/recentClientChanges.test.ts b/devfront/src/features/overview/recentClientChanges.test.ts new file mode 100644 index 00000000..8163f10e --- /dev/null +++ b/devfront/src/features/overview/recentClientChanges.test.ts @@ -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, +): 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" }, + ], + }); + }); +}); diff --git a/devfront/src/features/overview/recentClientChanges.ts b/devfront/src/features/overview/recentClientChanges.ts index 6284f58d..534cb86a 100644 --- a/devfront/src/features/overview/recentClientChanges.ts +++ b/devfront/src/features/overview/recentClientChanges.ts @@ -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", "재발급"), }, ]; }