1
0
forked from baron/baron-sso
Files
baron-sso/devfront/tests/helpers/devfront-fixtures.ts

855 lines
27 KiB
TypeScript

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, unknown> | 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<string, unknown>;
};
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<string, unknown>;
};
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<string, ClientRelation[]>;
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<Page, string>();
export function makeClient(
id: string,
overrides: Partial<Client> = {},
): 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,
})),
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, unknown> | string;
metadata?: Record<string, unknown>;
}) || { 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",
},
});
}
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",
},
});
}
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<string, unknown>;
}) || { 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, unknown> | string;
metadata?: Record<string, unknown>;
}) || { 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",
},
});
}
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);
});
}