1
0
forked from baron/baron-sso

test: raise frontend coverage baselines

This commit is contained in:
2026-05-29 14:31:10 +09:00
parent 592c1d1741
commit 3e31fdfa0c
50 changed files with 3482 additions and 214 deletions

View File

@@ -0,0 +1,161 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import AuthCallbackPage from "./AuthCallbackPage";
import AuthGuard from "./AuthGuard";
import AuthPage from "./AuthPage";
import LoginPage from "./LoginPage";
const authState = {
isAuthenticated: false,
isLoading: false,
activeNavigator: undefined as string | undefined,
error: null as Error | null,
user: undefined as
| {
state?: unknown;
}
| undefined,
signinRedirect: vi.fn(),
};
vi.mock("react-oidc-context", () => ({
useAuth: () => authState,
}));
vi.mock("../../lib/auth", () => ({
userManager: {
signinPopupCallback: vi.fn(async () => undefined),
},
}));
const roots: Root[] = [];
beforeEach(() => {
authState.isAuthenticated = false;
authState.isLoading = false;
authState.activeNavigator = undefined;
authState.error = null;
authState.user = undefined;
authState.signinRedirect.mockReset();
authState.signinRedirect.mockResolvedValue(undefined);
});
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
});
async function renderWithRouter(
element: React.ReactElement,
{
entry = "/",
path = "*",
}: {
entry?: string;
path?: string;
} = {},
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
await act(async () => {
root.render(
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route path={path} element={element}>
<Route index element={<div>Protected outlet</div>} />
</Route>
<Route path="/login" element={<div>Login route</div>} />
<Route path="/clients" element={<div>Clients route</div>} />
<Route path="/profile" element={<div>Profile route</div>} />
</Routes>
</MemoryRouter>,
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
return container;
}
describe("devfront auth pages", () => {
it("renders the static auth planning page", async () => {
const container = await renderWithRouter(<AuthPage />);
expect(container.textContent).toContain("Admin auth guardrails");
expect(container.textContent).toContain("Device approval");
});
it("renders login page and starts SSO redirect from the action button", async () => {
const container = await renderWithRouter(<LoginPage />, {
entry: "/login?returnTo=/profile",
path: "/login",
});
expect(container.textContent).toContain("개발자 포털 로그인");
const loginButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("SSO 계정으로 로그인"),
);
await act(async () => {
(loginButton as HTMLButtonElement).click();
});
expect(authState.signinRedirect).toHaveBeenCalledWith({
state: { returnTo: "/clients" },
});
});
it("shows AuthGuard loading, error, redirect, and protected outlet states", async () => {
authState.isLoading = true;
const loading = await renderWithRouter(<AuthGuard />);
expect(loading.textContent).toContain("Loading...");
authState.isLoading = false;
authState.error = new Error("OIDC failed");
const error = await renderWithRouter(<AuthGuard />);
expect(error.textContent).toContain("Authentication Error");
const retryButton = error.querySelector("button") as HTMLButtonElement;
await act(async () => {
retryButton.click();
});
expect(authState.signinRedirect).toHaveBeenCalled();
authState.error = null;
const redirected = await renderWithRouter(<AuthGuard />);
expect(redirected.textContent).toContain("Login route");
authState.isAuthenticated = true;
const protectedPage = await renderWithRouter(<AuthGuard />);
expect(protectedPage.textContent).toContain("Protected outlet");
});
it("navigates from callback by auth result and stored return target", async () => {
authState.isAuthenticated = true;
authState.user = { state: { returnTo: "/profile" } };
const authenticated = await renderWithRouter(<AuthCallbackPage />, {
entry: "/auth/callback",
path: "/auth/callback",
});
expect(authenticated.textContent).toContain("Profile route");
authState.isAuthenticated = false;
authState.error = new Error("callback failed");
const failed = await renderWithRouter(<AuthCallbackPage />, {
entry: "/auth/callback",
path: "/auth/callback",
});
expect(failed.textContent).toContain("Login route");
});
});

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import {
compareNullableValues,
sortItems,
toggleSort,
} from "../../../../common/core/utils/sort";
describe("common sort utilities in devfront coverage", () => {
it("keeps nullish values last and compares normalized primitive values", () => {
expect(compareNullableValues(null, "alpha", "asc")).toBe(1);
expect(compareNullableValues("alpha", undefined, "asc")).toBe(-1);
expect(compareNullableValues("Beta", "alpha", "asc")).toBe(1);
expect(compareNullableValues("Beta", "alpha", "desc")).toBe(-1);
expect(compareNullableValues(true, false, "asc")).toBe(1);
expect(
compareNullableValues(
new Date("2026-05-02T00:00:00Z"),
new Date("2026-05-01T00:00:00Z"),
"asc",
),
).toBe(1);
});
it("toggles sort direction and sorts with default and custom resolvers", () => {
const firstSort = toggleSort(null, "name");
expect(firstSort).toEqual({ key: "name", direction: "asc" });
expect(toggleSort(firstSort, "name")).toEqual({
key: "name",
direction: "desc",
});
expect(toggleSort(firstSort, "createdAt")).toEqual({
key: "createdAt",
direction: "asc",
});
const rows = [
{ name: "charlie", rank: 3, nested: { score: 20 } },
{ name: "Alpha", rank: 1, nested: { score: 30 } },
{ name: "bravo", rank: 2, nested: { score: 10 } },
];
expect(
sortItems(rows, { key: "name", direction: "asc" }).map(
(row) => row.name,
),
).toEqual(["Alpha", "bravo", "charlie"]);
expect(
sortItems(rows, { key: "score", direction: "desc" }, {
score: (row) => row.nested.score,
}).map((row) => row.name),
).toEqual(["Alpha", "charlie", "bravo"]);
expect(sortItems(rows, null)).not.toBe(rows);
});
});

View File

@@ -0,0 +1,383 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
import AuditLogsPage from "../audit/AuditLogsPage";
import ClientConsentsPage from "../clients/ClientConsentsPage";
import ClientDetailsPage from "../clients/ClientDetailsPage";
import ClientGeneralPage from "../clients/ClientGeneralPage";
import ClientRelationsPage from "../clients/ClientRelationsPage";
import ClientsPage from "../clients/ClientsPage";
import { ClientFederationPage } from "../clients/routes/ClientFederationPage";
import DeveloperRequestPage from "../developer-request/DeveloperRequestPage";
import GlobalOverviewPage from "../overview/GlobalOverviewPage";
import ProfilePage from "../profile/ProfilePage";
const authProfile = {
sub: "user-1",
role: "super_admin",
tenant_id: "tenant-1",
companyCode: "HANMAC",
};
vi.mock("react-oidc-context", () => ({
useAuth: () => ({
isAuthenticated: true,
isLoading: false,
user: {
access_token: "access-token",
profile: authProfile,
},
signinRedirect: vi.fn(),
removeUser: vi.fn(),
}),
}));
vi.mock("../../lib/i18n", () => ({
t: (key: string, fallback?: string, vars?: Record<string, unknown>) => {
let text = fallback ?? key;
for (const [name, value] of Object.entries(vars ?? {})) {
text = text.replaceAll(`{{${name}}}`, String(value));
}
return text;
},
}));
const clientSummary = {
id: "client-a",
name: "Console App",
type: "private" as const,
status: "active" as const,
createdAt: "2026-05-01T00:00:00Z",
redirectUris: ["https://app.example/callback"],
scopes: ["openid", "profile"],
tokenEndpointAuthMethod: "client_secret_basic",
metadata: {
headless_login_enabled: true,
headless_login_jwks_uri: "https://app.example/jwks.json",
id_token_claims: [
{
namespace: "rp_claims",
key: "employee_id",
value: "E001",
valueType: "text",
},
],
},
};
const clientDetail = {
client: {
...clientSummary,
clientSecret: "secret-value",
jwksUri: "https://app.example/jwks.json",
backchannelLogoutUri: "https://app.example/logout",
backchannelLogoutSessionRequired: true,
grantTypes: ["authorization_code"],
responseTypes: ["code"],
},
endpoints: {
discovery: "https://sso.example/.well-known/openid-configuration",
issuer: "https://sso.example",
authorization: "https://sso.example/oauth2/auth",
token: "https://sso.example/oauth2/token",
userinfo: "https://sso.example/userinfo",
},
headlessJwksCache: {
clientId: "client-a",
jwksUri: "https://app.example/jwks.json",
cachedAt: "2026-05-01T00:00:00Z",
expiresAt: "2026-05-02T00:00:00Z",
lastCheckedAt: "2026-05-01T01:00:00Z",
lastSuccessfulVerificationAt: "2026-05-01T01:00:00Z",
lastRefreshStatus: "success" as const,
cachedKids: ["kid-1"],
parsedKeys: [{ kid: "kid-1", kty: "RSA", use: "sig", alg: "RS256" }],
},
};
vi.mock("../../lib/devApi", () => ({
fetchClients: vi.fn(async () => ({
items: [clientSummary],
limit: 100,
offset: 0,
})),
fetchDevStats: vi.fn(async () => ({
total_clients: 1,
active_sessions: 12,
auth_failures_24h: 2,
})),
fetchDevRPUsageDaily: vi.fn(async () => ({
days: 14,
period: "day",
items: [
{
date: "2026-05-01",
tenantId: "tenant-1",
tenantType: "COMPANY",
tenantName: "Hanmac",
clientId: "client-a",
clientName: "Console App",
loginRequests: 10,
otherRequests: 4,
uniqueSubjects: 3,
},
{
date: "2026-05-08",
tenantId: "tenant-1",
tenantType: "COMPANY",
tenantName: "Hanmac",
clientId: "client-a",
clientName: "Console App",
loginRequests: 8,
otherRequests: 5,
uniqueSubjects: 4,
},
],
})),
fetchClient: vi.fn(async () => clientDetail),
fetchClientRelations: vi.fn(async () => ({
items: [
{
relation: "admins",
subject: "User:user-1",
subjectType: "User",
subjectId: "user-1",
userName: "Dev Admin",
userEmail: "dev@example.com",
},
],
})),
fetchDevUsers: vi.fn(async () => ({
items: [
{
id: "user-2",
name: "Editor User",
email: "editor@example.com",
loginId: "editor",
},
],
})),
addClientRelation: vi.fn(async () => ({
relation: "admins",
subject: "User:user-2",
subjectType: "User",
subjectId: "user-2",
})),
removeClientRelation: vi.fn(async () => undefined),
updateClientStatus: vi.fn(async () => clientDetail),
createClient: vi.fn(async () => clientDetail),
updateClient: vi.fn(async () => clientDetail),
rotateClientSecret: vi.fn(async () => clientDetail),
refreshHeadlessJwksCache: vi.fn(async () => clientDetail),
revokeHeadlessJwksCache: vi.fn(async () => undefined),
deleteClient: vi.fn(async () => undefined),
fetchConsents: vi.fn(async () => ({
items: [
{
subject: "user-1",
userName: "Consent User",
clientId: "client-a",
clientName: "Console App",
grantedScopes: ["openid", "profile"],
authenticatedAt: "2026-05-01T02:00:00Z",
createdAt: "2026-05-01T00:00:00Z",
status: "active",
tenantId: "tenant-1",
tenantName: "Hanmac",
},
],
})),
revokeConsent: vi.fn(async () => undefined),
listIdpConfigsForClient: vi.fn(async () => [
{
id: "idp-1",
client_id: "client-a",
provider_type: "oidc",
display_name: "Workspace OIDC",
status: "active",
issuer_url: "https://accounts.example",
oidc_client_id: "oidc-client",
scopes: "openid email profile",
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
]),
createIdpConfigForClient: vi.fn(async (payload) => ({
id: "idp-1",
...payload,
status: "active",
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
})),
updateIdpConfig: vi.fn(async (_clientId, idpId, payload) => ({
id: idpId,
client_id: "client-a",
provider_type: "oidc",
display_name: "Provider",
status: "active",
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
...payload,
})),
deleteIdpConfig: vi.fn(async () => undefined),
fetchDevAuditLogs: vi.fn(async () => ({
items: [
{
event_id: "event-1",
timestamp: "2026-05-01T00:00:00Z",
user_id: "user-1",
event_type: "client.update",
status: "success",
ip_address: "127.0.0.1",
user_agent: "Vitest",
details: "{\"client_id\":\"client-a\"}",
},
],
limit: 50,
})),
fetchMyTenants: vi.fn(async () => [
{
id: "tenant-1",
name: "Hanmac",
slug: "hanmac",
type: "COMPANY",
status: "active",
description: "",
memberCount: 10,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
]),
fetchDeveloperRequestStatus: vi.fn(async () => ({ status: "approved" })),
requestDeveloperAccess: vi.fn(async () => ({ status: "pending" })),
fetchDeveloperRequests: vi.fn(async () => [
{
id: 1,
userId: "user-3",
tenantId: "tenant-1",
name: "Requester",
organization: "Hanmac",
email: "requester@example.com",
reason: "Need RP access",
status: "pending",
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
]),
approveDeveloperRequest: vi.fn(async () => ({ status: "approved" })),
rejectDeveloperRequest: vi.fn(async () => ({ status: "rejected" })),
cancelDeveloperRequestApproval: vi.fn(async () => ({ status: "cancelled" })),
}));
vi.mock("../auth/authApi", () => ({
fetchMe: vi.fn(async () => ({
id: "user-1",
email: "dev@example.com",
name: "Dev Admin",
role: "super_admin",
tenantId: "tenant-1",
})),
}));
const roots: Root[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
});
async function renderPage(
element: React.ReactElement,
{
path = "/",
entry = path,
}: {
path?: string;
entry?: string;
} = {},
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route path={path} element={element} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
return container;
}
describe("devfront coverage smoke pages", () => {
it("renders overview, client list, audit, developer request, and profile pages", async () => {
const overview = await renderPage(<GlobalOverviewPage />);
expect(overview.textContent).toContain("Console App");
const clients = await renderPage(<ClientsPage />);
expect(clients.textContent).toContain("Console App");
const audit = await renderPage(<AuditLogsPage />);
expect(audit.textContent).toContain("client.update");
const requests = await renderPage(<DeveloperRequestPage />);
expect(requests.textContent).toContain("Requester");
const profile = await renderPage(<ProfilePage />);
expect(profile.textContent).toContain("Dev Admin");
});
it("renders client detail, settings, consent, federation, and relationship pages", async () => {
const details = await renderPage(<ClientDetailsPage />, {
path: "/clients/:id",
entry: "/clients/client-a",
});
expect(details.textContent).toContain("Console App");
const settings = await renderPage(<ClientGeneralPage />, {
path: "/clients/:id/settings",
entry: "/clients/client-a/settings",
});
expect(settings.textContent).toContain("Console App");
const consents = await renderPage(<ClientConsentsPage />, {
path: "/clients/:id/consents",
entry: "/clients/client-a/consents",
});
expect(consents.textContent).toContain("Consent User");
const federation = await renderPage(<ClientFederationPage />, {
path: "/clients/:id/federation",
entry: "/clients/client-a/federation",
});
expect(federation.textContent).toContain("Workspace OIDC");
const relations = await renderPage(<ClientRelationsPage />, {
path: "/clients/:id/relationships",
entry: "/clients/client-a/relationships",
});
expect(relations.textContent).toContain("Dev Admin");
});
});