import express from "express"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import request from "supertest"; import { describe, expect, it } from "vitest"; import { errorHandler } from "../middleware/index.js"; import { buildOpenApiSpec, openApiRoutes } from "../routes/openapi.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROUTES_DIR = path.resolve(__dirname, "../routes"); const apiPrefixes: Record = { "access.ts": "/api", "activity.ts": "/api", "adapters.ts": "/api", "agents.ts": "/api", "approvals.ts": "/api", "assets.ts": "/api", "auth.ts": "/api/auth", "cloud-upstreams.ts": "/api", "companies.ts": "/api/companies", "company-skills.ts": "/api", "costs.ts": "/api", "dashboard.ts": "/api", "environments.ts": "/api", "execution-workspaces.ts": "/api", "goals.ts": "/api", "health.ts": "/api/health", "inbox-dismissals.ts": "/api", "instance-database-backups.ts": "/api", "instance-settings.ts": "/api", "issues.ts": "/api", "issue-tree-control.ts": "/api", "llms.ts": "/api", "openapi.ts": "/api", "plugin-ui-static.ts": "/api", "plugins.ts": "/api", "projects.ts": "/api", "resource-memberships.ts": "/api", "routines.ts": "/api", "secrets.ts": "/api", "sidebar-badges.ts": "/api", "sidebar-preferences.ts": "/api", "teams-catalog.ts": "/api", "user-profiles.ts": "/api", }; const ROUTE_LITERAL_PATTERN = /router\.(get|post|put|patch|delete)\(\s*["'`]([^"'`]+)["'`]/g; const ROUTER_METHOD_PATTERN = /router\.(get|post|put|patch|delete)\(/; const HTTP_METHODS = new Set(["get", "put", "post", "delete", "options", "head", "patch", "trace"]); function createApp() { const app = express(); app.use("/api", openApiRoutes()); app.use(errorHandler); return app; } function normalizeExpressPath(routePath: string) { return routePath .replace(/\*([A-Za-z0-9_]+)/g, "{$1}") .replace(/:([A-Za-z0-9_]+)/g, "{$1}") .replace(/\/+/g, "/"); } function resolveMountedPath(file: string, prefix: string, routePath: string) { if ((file === "companies.ts" || file === "health.ts") && routePath === "/") { return prefix; } if (file === "companies.ts" || file === "health.ts") { return `${prefix}${routePath}`; } if (file === "auth.ts") { return `${prefix}${routePath === "/" ? "" : routePath}`; } return `${prefix}${routePath}`; } function loadActualRoutes() { const routes = new Set(); const unknownRouteFiles: string[] = []; for (const file of fs.readdirSync(ROUTES_DIR).filter((entry) => entry.endsWith(".ts"))) { const prefix = apiPrefixes[file]; const source = fs.readFileSync(path.join(ROUTES_DIR, file), "utf8"); if (!prefix) { if (ROUTER_METHOD_PATTERN.test(source)) { unknownRouteFiles.push(file); } continue; } for (const match of source.matchAll(ROUTE_LITERAL_PATTERN)) { const method = match[1].toUpperCase(); const routePath = match[2]; routes.add(`${method} ${normalizeExpressPath(resolveMountedPath(file, prefix, routePath))}`); } if (file === "companies.ts" && source.includes("router.post(COMPANY_IMPORT_ROUTE_PATH")) { routes.add("POST /api/companies/import"); } } return { routes, unknownRouteFiles: unknownRouteFiles.sort() }; } function loadSpecRoutes() { const spec = buildOpenApiSpec(); const routes = new Set(); for (const [routePath, pathItem] of Object.entries>>(spec.paths ?? {})) { for (const method of Object.keys(pathItem)) { if (HTTP_METHODS.has(method)) { routes.add(`${method.toUpperCase()} ${routePath}`); } } } return { spec, routes }; } describe("openapi routes", () => { it("serves the generated OpenAPI document", async () => { const res = await request(createApp()).get("/api/openapi.json"); expect(res.status).toBe(200); expect(res.body.openapi).toBe("3.0.0"); expect(res.body.info.title).toBe("Paperclip API"); expect(res.body.paths["/api/openapi.json"].get.summary).toBe("Get the generated OpenAPI document"); expect(res.body.paths["/api/companies/{companyId}/agents"].get.summary).toBe("List agents in a company"); expect(res.body.paths["/api/agents/{id}/keys"].post.summary).toBe("Create an agent API key"); expect(res.body.components.securitySchemes).toMatchObject({ BoardSessionAuth: { type: "apiKey", in: "cookie" }, BoardApiKeyAuth: { type: "http", scheme: "bearer" }, AgentBearerAuth: { type: "http", scheme: "bearer" }, }); expect(res.body.paths["/api/health"].get.security).toEqual([]); expect(res.body.paths["/api/companies"].post.responses["201"]).toBeDefined(); expect(res.body.paths["/api/companies"].post.requestBody.content["application/json"].schema).toMatchObject({ type: "object", properties: { name: { type: "string", minLength: 1 }, }, required: ["name"], }); expect(res.body.paths["/api/agents/{id}/keys"].post.requestBody.content["application/json"].schema).toMatchObject({ type: "object", properties: { name: { type: "string" }, }, }); }); it("covers the mounted server routes exactly", () => { const { routes: actualRoutes, unknownRouteFiles } = loadActualRoutes(); const { routes: specRoutes } = loadSpecRoutes(); const missingInSpec = [...actualRoutes].filter((route) => !specRoutes.has(route)).sort(); const extraInSpec = [...specRoutes].filter((route) => !actualRoutes.has(route)).sort(); expect({ unknownRouteFiles, missingInSpec, extraInSpec }).toEqual({ unknownRouteFiles: [], missingInSpec: [], extraInSpec: [], }); }); it("documents auth and reviewed response-code invariants", () => { const { spec } = loadSpecRoutes(); expect(spec.paths["/api/openapi.json"].get.security).toEqual([]); expect(spec.paths["/api/plugins/install"].post.security).toEqual([ { BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }, ]); expect(spec.paths["/api/plugins/install"].post["x-paperclip-authorization"]).toEqual({ actor: "board", instanceAdmin: true, }); expect(spec.paths["/api/companies/{companyId}/cost-events"].post.responses["201"]).toBeDefined(); expect(spec.paths["/api/companies/{companyId}/cost-events"].post.responses["403"]).toBeDefined(); expect(spec.paths["/api/instance/database-backups"].post.responses["201"]).toBeDefined(); expect(spec.paths["/api/invites/{token}/accept"].post.responses["202"]).toBeDefined(); expect(spec.paths["/api/board-api-keys"].post.responses["201"]).toBeDefined(); expect(spec.paths["/api/companies/import"].post.responses["202"]).toBeDefined(); }); });