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", "재발급"),
},
];
}