import type { Page, Route } from "@playwright/test"; export type ClientStatus = "active" | "inactive"; export type ClientType = "private" | "pkce"; export type Client = { id: string; name: string; type: ClientType; status: ClientStatus; redirectUris: string[]; scopes: string[]; createdAt: string; clientSecret?: string; tokenEndpointAuthMethod?: string; jwksUri?: string; jwks?: Record | string; headlessJwksCache?: { clientId: string; jwksUri: string; cachedAt: string; expiresAt: string; lastCheckedAt?: string; lastSuccessfulVerificationAt?: string; lastRefreshStatus?: "success" | "failure" | "pending"; lastError?: string; consecutiveFailures?: number; cachedKids?: string[]; etag?: string; lastModified?: string; parsedKeys?: Array<{ kid?: string; kty?: string; use?: string; alg?: string; n?: string; }>; }; metadata?: Record; }; export type Consent = { subject: string; userName: string; clientId: string; clientName: string; grantedScopes: string[]; authenticatedAt?: string; createdAt: string; deletedAt?: string; status: "active" | "revoked"; tenantId: string; tenantName: string; rpMetadata?: Record; }; export type DeveloperRequestStatus = "pending" | "approved" | "rejected"; export type DeveloperRequest = { id: string; userId: string; userName: string; name?: string; // 추가 userEmail: string; organization: string; reason: string; status: DeveloperRequestStatus; createdAt: string; updatedAt: string; approvedAt?: string; rejectedAt?: string; comment?: string; adminNotes?: string; // 추가 }; export type ClientRelation = { relation: string; subject: string; subjectType: string; subjectId: string; userName?: string; userEmail?: string; userLoginId?: string; }; export type DevAssignableUser = { id: string; name: string; email: string; loginId?: string; }; export type DevTenantSummary = { id: string; name: string; slug: string; description?: string; type?: string; }; export type AuditLog = { event_id: string; timestamp: string; user_id: string; event_type: string; status: "success" | "failure"; ip_address: string; user_agent: string; details: string; }; export type DevApiMockState = { clients: Client[]; consents: Consent[]; developerRequests?: DeveloperRequest[]; relations?: Record; users?: DevAssignableUser[]; tenants?: DevTenantSummary[]; auditLogsByCursor?: Record< string, { items: AuditLog[]; next_cursor?: string } >; auditLogs?: AuditLog[]; onUpdateStatus?: (status: ClientStatus) => void; onRotateSecret?: (newSecret: string) => void; onRefreshHeadlessJwks?: (clientId: string) => void; onRevokeHeadlessJwksCache?: (clientId: string) => void; mockRole?: string; }; const seededRoles = new WeakMap(); export function makeClient( id: string, overrides: Partial = {}, ): Client { return { id, name: `${id} app`, type: "private", status: "active", redirectUris: [`https://${id}.example.com/callback`], scopes: ["openid", "profile", "email"], createdAt: "2026-03-03T00:00:00.000Z", clientSecret: `${id}-secret`, metadata: {}, ...overrides, }; } export async function seedAuth(page: Page, role?: string) { const nowInSeconds = Math.floor(Date.now() / 1000); seededRoles.set(page, role || "super_admin"); await page.addInitScript( ({ issuedAt, injectedRole }) => { ( window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean } )._IS_TEST_MODE = true; const mockOidcUser = { id_token: "playwright-id-token", session_state: "playwright-session", access_token: "playwright-access-token", refresh_token: "playwright-refresh-token", token_type: "Bearer", scope: "openid profile email", profile: { sub: "playwright-user", email: "playwright@example.com", name: "Playwright User", ...(injectedRole ? { role: injectedRole } : {}), }, expires_at: issuedAt + 3600, }; const storageKeys = [ "user:http://localhost:5000/oidc:devfront", "user:http://localhost:5000/oidc/:devfront", "user:https://sso.example.test/oidc:devfront", "user:https://sso.example.test/oidc/:devfront", "oidc.user:http://localhost:5000/oidc:devfront", "oidc.user:http://localhost:5000/oidc/:devfront", "oidc.user:https://sso.example.test/oidc:devfront", "oidc.user:https://sso.example.test/oidc/:devfront", ]; for (const key of storageKeys) { window.localStorage.setItem(key, JSON.stringify(mockOidcUser)); window.sessionStorage.setItem(key, JSON.stringify(mockOidcUser)); } window.localStorage.setItem("dev_role", injectedRole || "super_admin"); window.localStorage.setItem("dev_tenant_id", "tenant-a"); }, { issuedAt: nowInSeconds, injectedRole: role ?? "" }, ); await page.route("**/oidc/**", async (route) => { const url = route.request().url(); if (url.includes(".well-known/openid-configuration")) { await route.fulfill({ json: { issuer: "http://localhost:5000/oidc", authorization_endpoint: "http://localhost:5000/oidc/auth", token_endpoint: "http://localhost:5000/oidc/token", jwks_uri: "http://localhost:5000/oidc/jwks", userinfo_endpoint: "http://localhost:5000/oidc/userinfo", end_session_endpoint: "http://localhost:5000/oidc/session/end", }, headers: { "Access-Control-Allow-Origin": "*" }, }); } else if (url.includes("/jwks")) { await route.fulfill({ json: { keys: [] }, headers: { "Access-Control-Allow-Origin": "*" }, }); } else { await route.fulfill({ status: 200, body: "ok", headers: { "Access-Control-Allow-Origin": "*" }, }); } }); } function json(route: Route, payload: unknown, status = 200) { return route.fulfill({ status, contentType: "application/json", body: JSON.stringify(payload), }); } function parseClientId(pathname: string): string { const parts = pathname.split("/").filter(Boolean); return parts[parts.length - 1] ?? ""; } export async function installDevApiMock(page: Page, state: DevApiMockState) { const readMockRole = () => (state.mockRole ?? seededRoles.get(page) ?? "super_admin").trim(); const buildDeveloperAccessStatus = () => { const requests = state.developerRequests ?? []; const myRequests = requests.filter( (request) => request.userId === "playwright-user", ); const approvedPages = myRequests .filter((request) => request.status === "approved") .flatMap((request) => request.accessPages ?? ["all"]); const pendingPages = myRequests .filter((request) => request.status === "pending") .flatMap((request) => request.accessPages ?? ["all"]); const latestRequest = myRequests[myRequests.length - 1]; if (!latestRequest) { return { status: "none" as const, }; } return { status: latestRequest.status, approvedPages, pendingPages, }; }; const buildSelfConfigEditorRelation = (): ClientRelation => ({ relation: "config_editor", subject: "User:playwright-user", subjectType: "User", subjectId: "playwright-user", userName: "Playwright User", userEmail: "playwright@example.com", userLoginId: "playwright@example.com", }); const shouldGrantDefaultEditRelation = (role: string) => role === "super_admin"; const resolveClientRelations = async (clientId: string) => { const explicitRelations = state.relations?.[clientId]; if (explicitRelations) { return explicitRelations; } const role = readMockRole(); if (!shouldGrantDefaultEditRelation(role)) { return []; } return [buildSelfConfigEditorRelation()]; }; const appendAuditLog = ( eventType: string, action: string, targetId: string, status: "success" | "failure" = "success", ) => { if (!state.auditLogs) return; state.auditLogs.unshift({ event_id: `evt-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, timestamp: new Date().toISOString(), user_id: "playwright-user", event_type: eventType, status, ip_address: "127.0.0.1", user_agent: "playwright", details: JSON.stringify({ action, target_id: targetId, tenant_id: "tenant-a", }), }); }; await page.route("**/api/v1/user/me", async (route) => { const storedRole = readMockRole(); return json(route, { id: "playwright-user", loginId: "playwright@example.com", email: "playwright@example.com", name: "Playwright User", phoneNumber: "", department: "QA", tenantId: "tenant-a", tenantName: "Tenant A", role: storedRole, createdAt: "2026-03-03T00:00:00.000Z", updatedAt: "2026-03-03T00:00:00.000Z", }); }); await page.route("**/api/v1/dev/**", async (route) => { const request = route.request(); const url = new URL(request.url()); const { pathname, searchParams } = url; const method = request.method(); if ( (pathname === "/api/v1/dev/requests" || pathname === "/api/v1/dev/developer-request/list") && method === "GET" ) { return json(route, state.developerRequests ?? []); } if ( (pathname === "/api/v1/dev/requests" || pathname === "/api/v1/dev/developer-request") && method === "POST" ) { const payload = (request.postDataJSON() as { name?: string; organization?: string; reason?: string; }) || {}; const created: DeveloperRequest = { id: `req-${Date.now()}`, userId: "playwright-user", userName: payload.name ?? "Playwright User", name: payload.name ?? "Playwright User", userEmail: "playwright@example.com", organization: payload.organization ?? "Unknown", reason: payload.reason ?? "No reason", status: "pending", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; if (!state.developerRequests) { state.developerRequests = []; } state.developerRequests.push(created); return json(route, created, 201); } if ( (pathname === "/api/v1/dev/requests/status" || pathname === "/api/v1/dev/developer-request/status") && method === "GET" ) { return json(route, buildDeveloperAccessStatus()); } if ( (pathname.startsWith("/api/v1/dev/requests/") || pathname.startsWith("/api/v1/dev/developer-request/")) && pathname.endsWith("/approve") && method === "POST" ) { const reqId = pathname.split("/")[5] ?? pathname.split("/")[4] ?? ""; const found = state.developerRequests?.find((r) => r.id === reqId); if (!found) return json(route, { error: "not found" }, 404); found.status = "approved"; found.approvedAt = new Date().toISOString(); return json(route, found); } if ( (pathname.startsWith("/api/v1/dev/requests/") || pathname.startsWith("/api/v1/dev/developer-request/")) && pathname.endsWith("/reject") && method === "POST" ) { const reqId = pathname.split("/")[5] ?? pathname.split("/")[4] ?? ""; const found = state.developerRequests?.find((r) => r.id === reqId); if (!found) return json(route, { error: "not found" }, 404); found.status = "rejected"; found.rejectedAt = new Date().toISOString(); return json(route, found); } if ( (pathname.startsWith("/api/v1/dev/requests/") || pathname.startsWith("/api/v1/dev/developer-request/")) && (pathname.endsWith("/cancel") || pathname.endsWith("/cancel-approval")) && method === "POST" ) { const reqId = pathname.split("/")[5] ?? pathname.split("/")[4] ?? ""; const found = state.developerRequests?.find((r) => r.id === reqId); if (!found) return json(route, { error: "not found" }, 404); found.status = "pending"; found.approvedAt = undefined; return json(route, found); } if (pathname === "/api/v1/dev/my-tenants" && method === "GET") { return json( route, state.tenants ?? [ { id: "tenant-a", name: "Tenant A", slug: "tenant-a" }, ], ); } if (pathname === "/api/v1/dev/stats" && method === "GET") { const total = state.clients.length; return json(route, { total_clients: total, active_sessions: Math.max(1, total), auth_failures_24h: 0, }); } if (pathname === "/api/v1/dev/rp-usage/daily" && method === "GET") { return json(route, { items: [], days: Number.parseInt(searchParams.get("days") || "14", 10), period: (searchParams.get("period") as "day" | "week" | "month") || "day", }); } if (pathname === "/api/v1/dev/clients" && method === "GET") { return json(route, { items: state.clients.map((client) => ({ id: client.id, name: client.name, type: client.type, status: client.status, createdAt: client.createdAt, redirectUris: client.redirectUris, scopes: client.scopes, metadata: client.metadata ?? {}, })), limit: 50, offset: 0, }); } if (pathname === "/api/v1/dev/users" && method === "GET") { const search = (searchParams.get("search") || "").toLowerCase(); const limit = Number.parseInt(searchParams.get("limit") || "10", 10); const items = (state.users ?? []) .filter((user) => { if (!search) return true; return [user.name, user.email, user.loginId ?? ""].some((value) => value.toLowerCase().includes(search), ); }) .slice(0, Number.isFinite(limit) ? limit : 10); return json(route, { items }); } if (pathname === "/api/v1/dev/clients" && method === "POST") { const payload = (request.postDataJSON() as { name?: string; type?: ClientType; status?: ClientStatus; redirectUris?: string[]; scopes?: string[]; tokenEndpointAuthMethod?: string; jwksUri?: string; jwks?: Record | string; metadata?: Record; }) || { name: "created app" }; const created = makeClient(`client-${state.clients.length + 1}`, { name: payload.name ?? "created app", type: payload.type ?? "private", status: payload.status ?? "active", redirectUris: payload.redirectUris ?? [], scopes: payload.scopes ?? ["openid"], tokenEndpointAuthMethod: payload.tokenEndpointAuthMethod, jwksUri: payload.jwksUri, jwks: payload.jwks, metadata: payload.metadata ?? {}, }); state.clients.push(created); if (!state.relations) { state.relations = {}; } state.relations[created.id] = [buildSelfConfigEditorRelation()]; appendAuditLog("CLIENT_CREATE", "CREATE_CLIENT", created.id); return json(route, { client: created, endpoints: { discovery: "https://issuer/.well-known/openid-configuration", issuer: "https://issuer", authorization: "https://issuer/oauth2/auth", token: "https://issuer/oauth2/token", userinfo: "https://issuer/userinfo", }, }); } if ( pathname.startsWith("/api/v1/dev/clients/") && pathname.endsWith("/relations") && method === "GET" ) { const clientId = pathname.split("/")[5] ?? ""; return json(route, { items: await resolveClientRelations(clientId), }); } if ( pathname.startsWith("/api/v1/dev/clients/") && pathname.endsWith("/relations") && method === "POST" ) { const clientId = pathname.split("/")[5] ?? ""; const payload = (request.postDataJSON() as { relation?: string; subject?: string; userId?: string; }) || { relation: "config_editor" }; const subject = payload.subject || (payload.userId ? `User:${payload.userId}` : "User:playwright-user"); const subjectId = subject.startsWith("User:") ? subject.slice("User:".length) : subject; const created: ClientRelation = { relation: payload.relation ?? "config_editor", subject, subjectType: "User", subjectId, }; if (!state.relations) { state.relations = {}; } if (!state.relations[clientId]) { state.relations[clientId] = []; } state.relations[clientId].push(created); appendAuditLog("CLIENT_RELATION_CREATE", "ADD_RELATION", clientId); return json(route, created, 201); } if ( pathname.startsWith("/api/v1/dev/clients/") && pathname.endsWith("/relations") && method === "DELETE" ) { const clientId = pathname.split("/")[5] ?? ""; const relation = searchParams.get("relation") || ""; const subject = searchParams.get("subject") || ""; if (state.relations?.[clientId]) { state.relations[clientId] = state.relations[clientId].filter( (item) => !(item.relation === relation && item.subject === subject), ); } appendAuditLog("CLIENT_RELATION_DELETE", "REMOVE_RELATION", clientId); return route.fulfill({ status: 204 }); } if ( pathname.startsWith("/api/v1/dev/clients/") && pathname.endsWith("/status") && method === "PATCH" ) { const clientId = pathname.split("/")[5] ?? ""; const payload = request.postDataJSON() as { status: ClientStatus }; const found = state.clients.find((client) => client.id === clientId); if (!found) return json(route, { error: "not found" }, 404); found.status = payload.status; appendAuditLog("CLIENT_UPDATE_STATUS", "UPDATE_CLIENT_STATUS", clientId); state.onUpdateStatus?.(payload.status); return json(route, { client: found, endpoints: { discovery: "https://issuer/.well-known/openid-configuration", issuer: "https://issuer", authorization: "https://issuer/oauth2/auth", token: "https://issuer/oauth2/token", userinfo: "https://issuer/userinfo", }, headlessJwksCache: found.headlessJwksCache, }); } if ( pathname.startsWith("/api/v1/dev/clients/") && pathname.endsWith("/secret/rotate") && method === "POST" ) { const clientId = pathname.split("/")[5] ?? ""; const found = state.clients.find((client) => client.id === clientId); if (!found) return json(route, { error: "not found" }, 404); found.clientSecret = `${clientId}-rotated-secret`; appendAuditLog("CLIENT_ROTATE_SECRET", "ROTATE_SECRET", clientId); state.onRotateSecret?.(found.clientSecret); return json(route, { client: found, endpoints: { discovery: "https://issuer/.well-known/openid-configuration", issuer: "https://issuer", authorization: "https://issuer/oauth2/auth", token: "https://issuer/oauth2/token", userinfo: "https://issuer/userinfo", }, headlessJwksCache: found.headlessJwksCache, }); } if ( pathname.startsWith("/api/v1/dev/clients/") && pathname.includes("/users/") && pathname.endsWith("/metadata") && method === "GET" ) { const parts = pathname.split("/").filter(Boolean); const clientId = parts[4] ?? ""; const userId = parts[6] ?? ""; const target = state.consents.find( (row) => row.clientId === clientId && row.subject === userId, ); return json(route, { clientId, userId, metadata: target?.rpMetadata ?? {}, }); } if ( pathname.startsWith("/api/v1/dev/clients/") && pathname.includes("/users/") && pathname.endsWith("/metadata") && method === "PUT" ) { const parts = pathname.split("/").filter(Boolean); const clientId = parts[4] ?? ""; const userId = parts[6] ?? ""; const payload = (request.postDataJSON() as { metadata?: Record; }) || { metadata: {} }; const target = state.consents.find( (row) => row.clientId === clientId && row.subject === userId, ); if (target) { target.rpMetadata = payload.metadata ?? {}; } return json(route, { clientId, userId, metadata: payload.metadata ?? {}, }); } if (pathname.startsWith("/api/v1/dev/clients/") && method === "PUT") { const clientId = parseClientId(pathname); const payload = (request.postDataJSON() as { name?: string; type?: ClientType; scopes?: string[]; redirectUris?: string[]; tokenEndpointAuthMethod?: string; jwksUri?: string; jwks?: Record | string; metadata?: Record; }) || { name: "updated app" }; const found = state.clients.find((client) => client.id === clientId); if (!found) return json(route, { error: "not found" }, 404); if (payload.name) found.name = payload.name; if (payload.type) found.type = payload.type; if (payload.scopes) found.scopes = payload.scopes; if (payload.redirectUris) found.redirectUris = payload.redirectUris; if (payload.tokenEndpointAuthMethod !== undefined) { found.tokenEndpointAuthMethod = payload.tokenEndpointAuthMethod; } if (payload.jwksUri !== undefined) { found.jwksUri = payload.jwksUri; } if (payload.jwks !== undefined) { found.jwks = payload.jwks; } if (payload.metadata) found.metadata = payload.metadata; appendAuditLog("CLIENT_UPDATE", "UPDATE_CLIENT", clientId); return json(route, { client: found, endpoints: { discovery: "https://issuer/.well-known/openid-configuration", issuer: "https://issuer", authorization: "https://issuer/oauth2/auth", token: "https://issuer/oauth2/token", userinfo: "https://issuer/userinfo", }, headlessJwksCache: found.headlessJwksCache, }); } if (pathname.startsWith("/api/v1/dev/clients/") && method === "GET") { const clientId = parseClientId(pathname); const found = state.clients.find((client) => client.id === clientId); if (!found) return json(route, { error: "forbidden" }, 403); return json(route, { client: found, endpoints: { discovery: "https://issuer/.well-known/openid-configuration", issuer: "https://issuer", authorization: "https://issuer/oauth2/auth", token: "https://issuer/oauth2/token", userinfo: "https://issuer/userinfo", }, headlessJwksCache: found.headlessJwksCache, }); } if ( pathname.startsWith("/api/v1/dev/clients/") && pathname.endsWith("/headless-jwks/cache") && method === "DELETE" ) { const clientId = pathname.split("/")[5] ?? ""; const found = state.clients.find((client) => client.id === clientId); if (!found) return json(route, { error: "not found" }, 404); found.headlessJwksCache = undefined; state.onRevokeHeadlessJwksCache?.(clientId); return route.fulfill({ status: 204 }); } if ( pathname.startsWith("/api/v1/dev/clients/") && pathname.endsWith("/headless-jwks/refresh") && method === "POST" ) { const clientId = pathname.split("/")[5] ?? ""; const found = state.clients.find((client) => client.id === clientId); if (!found) return json(route, { error: "not found" }, 404); state.onRefreshHeadlessJwks?.(clientId); return json(route, { client: found, endpoints: { discovery: "https://issuer/.well-known/openid-configuration", issuer: "https://issuer", authorization: "https://issuer/oauth2/auth", token: "https://issuer/oauth2/token", userinfo: "https://issuer/userinfo", }, headlessJwksCache: found.headlessJwksCache, }); } if ( pathname.startsWith("/api/v1/dev/clients/") && !pathname.endsWith("/headless-jwks/cache") && method === "DELETE" ) { const clientId = parseClientId(pathname); state.clients = state.clients.filter((client) => client.id !== clientId); appendAuditLog("CLIENT_DELETE", "DELETE_CLIENT", clientId); return route.fulfill({ status: 204 }); } if (pathname === "/api/v1/dev/consents" && method === "GET") { const subject = searchParams.get("subject") || ""; const clientId = searchParams.get("client_id") || ""; const status = searchParams.get("status") || ""; const items = state.consents.filter((row) => { const matchesSubject = !subject || row.subject.includes(subject) || row.userName.includes(subject); const matchesClientId = !clientId || row.clientId === clientId; const matchesStatus = !status || row.status === status; return matchesSubject && matchesClientId && matchesStatus; }); return json(route, { items }); } if (pathname === "/api/v1/dev/consents" && method === "DELETE") { const subject = searchParams.get("subject") || ""; const clientId = searchParams.get("client_id") || ""; const target = state.consents.find( (row) => row.subject === subject && row.clientId === clientId, ); if (target) { target.status = "revoked"; target.deletedAt = "2026-03-03T10:00:00.000Z"; } return route.fulfill({ status: 204 }); } if (pathname === "/api/v1/dev/audit-logs" && method === "GET") { if (state.auditLogsByCursor) { const cursor = searchParams.get("cursor") || ""; const pageSet = state.auditLogsByCursor[cursor] ?? { items: [] }; return json(route, { items: pageSet.items, limit: 50, cursor: cursor || undefined, next_cursor: pageSet.next_cursor, }); } if (state.auditLogs) { const action = searchParams.get("action") || ""; const clientId = searchParams.get("client_id") || ""; const status = searchParams.get("status") || ""; const filtered = state.auditLogs.filter((item) => { let parsedDetails: { action?: string; target_id?: string } = {}; try { parsedDetails = JSON.parse(item.details) as { action?: string; target_id?: string; }; } catch {} const matchesAction = !action || parsedDetails.action === action; const matchesClient = !clientId || parsedDetails.target_id === clientId; const matchesStatus = !status || item.status === status; return matchesAction && matchesClient && matchesStatus; }); return json(route, { items: filtered, limit: 50 }); } return json(route, { items: [], limit: 50 }); } return json(route, { error: `Unhandled ${method} ${pathname}` }, 404); }); }