mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents need a persistent identity and a verified active run for governed access. > - Customer-success inspection needs broad reads without tenant writes or secret access. > - Ordinary board login and database credentials give more authority than this task needs. > - This pull request adds a dedicated inspection API and strict managed-run authority. > - Cloud owns short grants, human approval, replay protection, and audit records. > - The benefit is inspectable access that an operator can disable immediately. ## Linked Issues or Issue Description Refs: #15352. This change reuses the persistent Ed25519 identity from that PR. **Subsystem affected** Server authentication, pure resource readers, and the shared wire contract. **Problem or motivation** One internal Paperclip agent must inspect customer onboarding work. It must not receive owner login or database credentials. Reads must not create customer sessions, memberships, activity, or read receipts. **Proposed solution** Add disabled-by-default run authority and versioned tenant inspection endpoints. Require strict instance-bound managed-run JWTs on the home instance. Require exact-operation, single-use Cloud permits on tenants. Execute a reviewed company-scoped catalog in read-only transactions. Cloud applies seven-day stack-age eligibility and human exceptions. **Roadmap alignment** This is access support for Cloud deployments and governed agent identities. Bot creation, scheduling, scoring, and reports are separate work. The maintainer requested this implementation. ## What Changed - Reuse existing public identity reads and managed private-key injection. Reject unprovisioned keys, paused agents, ended runs, legacy signatures, and wrong instances. - Mount `/api/customer-success/v1` before actor/session synchronization. Verify Cloud permits and consume them centrally before reading. - Add explicit company-scoped database readers and bounded instruction, skill snapshot, run log, workspace, and asset reads. Preserve existing redactions and file protections. - Add protocol, security, database immutability, and managed-agent qualification tests. Add deployment and rollback documentation. ## Verification - Full `pnpm -r typecheck` and `pnpm build` passed. Server typecheck passed after review fixes. - The broad local `pnpm test:run` recorded 14,277 passes and four failures in unchanged suites: two timeouts and two PR-metadata mock assertions. All three affected suites passed on isolated reruns (36 tests). The complete CI matrix passes at the final head, including every test lane, typecheck, build, runner checks, canary dry run, and the security scan. - Focused inspection, JWT, and existing identity tests pass. The catalog test compares every public database table before and after reads. - Inspection and route-contract tests: 22 passed. The coordinated test runs a real managed process agent against separate home/customer PostgreSQL databases and a PostgreSQL broker over HTTP. It proves wake through the existing controller, bounded binary file reads, single challenge consumption across replicas, concurrent grants with a two-connection pool, scoped SQL audits, append-only runtime auditing, one-year retention, and unchanged tenant data/files. - Run the coordinated test with `PAPERCLIP_INSPECTION_CLOUD_DIST` pointing at the sibling Cloud build. Normal unit runs skip that optional private integration. - Final-head Greptile is 5/5 with no unresolved findings. - No production deployment or customer inspection occurred. ## Risks - This adds an authentication boundary. Keep both feature flags disabled until coordinated staging and canary qualification. - Cloud support must deploy after this API. Unsupported tenants fail closed. There is no owner-login or database fallback. - Existing redactions remain the content boundary. Arbitrary pasted secrets in readable prose or files may remain. - Remote files and suppressed provider traces remain unavailable. Wake can cause normal startup/background writes; test those separately. - Disable Cloud policy first during rollback. Preserve existing identity material and Cloud audit history. ## Model Used OpenAI GPT-6 (Codex), with reasoning, code execution, and browser testing. The session does not expose a more specific deployment ID or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (focused checks and isolated reruns; broad-run flakes are documented above) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1014 lines
38 KiB
TypeScript
1014 lines
38 KiB
TypeScript
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 { COMPANY_IMPORT_TRANSFERS_ROUTE_PATH } from "@paperclipai/shared/company-import-transfer";
|
|
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<string, string> = {
|
|
"pipelines.ts": "/api",
|
|
"cases.ts": "/api",
|
|
"smoke-lab.ts": "/api",
|
|
"access.ts": "/api",
|
|
"activity.ts": "/api",
|
|
"adapters.ts": "/api",
|
|
"agents.ts": "/api",
|
|
"agent-commentary.ts": "/api",
|
|
"agent-avatars.ts": "/api",
|
|
"announcements.ts": "/api",
|
|
"ai-connections.ts": "/api",
|
|
"attention.ts": "/api",
|
|
"approvals.ts": "/api",
|
|
"assets.ts": "/api",
|
|
"auth.ts": "/api/auth",
|
|
"board-chat.ts": "/api",
|
|
"browser-use.ts": "/api",
|
|
"built-in-agents.ts": "/api",
|
|
"chat-channels.ts": "/api",
|
|
"slack-tools.ts": "/api",
|
|
"email.ts": "/api",
|
|
"cloud.ts": "/api/cloud",
|
|
"customer-success.ts": "/api/customer-success/v1",
|
|
"companies.ts": "/api/companies",
|
|
"company-skills.ts": "/api",
|
|
"company-skill-policy.ts": "/api",
|
|
"connection-intents.ts": "/api",
|
|
"costs.ts": "/api",
|
|
"dashboard.ts": "/api",
|
|
"decision-queues.ts": "/api",
|
|
"decisions.ts": "/api",
|
|
"decision-training.ts": "/api",
|
|
"environments.ts": "/api",
|
|
"execution-workspaces.ts": "/api",
|
|
"file-resources.ts": "/api",
|
|
"folders.ts": "/api",
|
|
"goals.ts": "/api",
|
|
"health.ts": "/api/health",
|
|
"inbox-agent-policy.ts": "/api",
|
|
"inbox-dismissals.ts": "/api",
|
|
"instance-database-backups.ts": "/api",
|
|
"instance-settings.ts": "/api",
|
|
"issues.ts": "/api",
|
|
"issue-tree-control.ts": "/api",
|
|
"llms.ts": "/api",
|
|
"managed-agent-profiles.ts": "/api",
|
|
"onboarding-seed.ts": "/api",
|
|
"openapi.ts": "/api",
|
|
"plugin-ui-static.ts": "/api",
|
|
"plugins.ts": "/api",
|
|
"projects.ts": "/api",
|
|
"public-mcp.ts": "/api",
|
|
"project-tools.ts": "/api",
|
|
"resource-memberships.ts": "/api",
|
|
"remote-agent-profiles.ts": "/api",
|
|
"routines.ts": "/api",
|
|
"secrets.ts": "/api",
|
|
"sidebar-badges.ts": "/api",
|
|
"sidebar-preferences.ts": "/api",
|
|
"summary-slots.ts": "/api",
|
|
"status-cards.ts": "/api",
|
|
"teams-catalog.ts": "/api",
|
|
"tool-access.ts": "/api",
|
|
"tool-gateway.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",
|
|
]);
|
|
const explicitOpenApiCoverageExclusions = new Set<string>();
|
|
|
|
const explicitOpenApiOperationCoverageExclusions = new Set([
|
|
// Inspection uses its own versioned Cloud-permit/managed-run protocol,
|
|
// documented in CUSTOMER-SUCCESS-INSPECTION.md and the shared contract.
|
|
// Ordinary board sessions and agent API keys cannot invoke these endpoints.
|
|
"GET /api/customer-success/v1/run-authority",
|
|
"POST /api/customer-success/v1/read",
|
|
// OAuth discovery and protocol endpoints have their own metadata contract;
|
|
// browser connection-management operations remain documented in the board API.
|
|
"GET /.well-known/oauth-authorization-server",
|
|
"POST /mcp/oauth/register",
|
|
"POST /mcp/oauth/device_authorization",
|
|
"GET /mcp/oauth/authorize",
|
|
"POST /mcp/oauth/token",
|
|
"POST /mcp/oauth/revoke",
|
|
// This endpoint is authenticated by the provider signature rather than by a
|
|
// Paperclip board/agent credential. It intentionally stays out of the public
|
|
// board API document, while this exact exclusion keeps route coverage honest.
|
|
"POST /api/chat-webhooks/agentmail/{publicId}",
|
|
"POST /api/chat-webhooks/{publicId}/{provider}",
|
|
]);
|
|
|
|
// The set of contract-first routes whose OpenAPI document leads the mounted
|
|
// request handler. The company-and-environment Claude setup-token login routes
|
|
// now have request handlers, so the set is empty. A new contract-first route
|
|
// belongs here only until its handler lands.
|
|
const specOnlyContractFirstRoutes = new Set<string>([]);
|
|
|
|
function createApp() {
|
|
const app = express();
|
|
app.use("/api", openApiRoutes());
|
|
app.use(errorHandler);
|
|
return app;
|
|
}
|
|
|
|
// Route files may compose paths from shared path constants inside template
|
|
// literals; substitute the constants' values before normalizing.
|
|
const routePathConstantSubstitutions: Record<string, string> = {
|
|
"${COMPANY_IMPORT_TRANSFERS_ROUTE_PATH}": COMPANY_IMPORT_TRANSFERS_ROUTE_PATH,
|
|
};
|
|
|
|
function normalizeExpressPath(routePath: string) {
|
|
let substituted = routePath;
|
|
for (const [placeholder, value] of Object.entries(
|
|
routePathConstantSubstitutions,
|
|
)) {
|
|
substituted = substituted.split(placeholder).join(value);
|
|
}
|
|
return substituted
|
|
.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 === "public-mcp.ts" && (routePath.startsWith("/mcp/oauth/") || routePath.startsWith("/.well-known/"))) return routePath;
|
|
if (
|
|
(file === "chat-channels.ts" || file === "email.ts") &&
|
|
routePath.startsWith("/api/chat-webhooks/")
|
|
) {
|
|
return routePath;
|
|
}
|
|
if (file === "tool-gateway.ts" && routePath.startsWith("/mcp/gateways/")) {
|
|
return routePath;
|
|
}
|
|
if (
|
|
file === "connection-intents.ts" &&
|
|
(routePath.startsWith("/mcp/") || routePath.startsWith("/runtime-tools/"))
|
|
) {
|
|
return routePath;
|
|
}
|
|
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<string>();
|
|
const excludedRoutes = new Set<string>();
|
|
const unknownRouteFiles: string[] = [];
|
|
|
|
for (const file of fs
|
|
.readdirSync(ROUTES_DIR)
|
|
.filter((entry) => entry.endsWith(".ts"))) {
|
|
if (explicitOpenApiCoverageExclusions.has(file)) continue;
|
|
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];
|
|
const operation = `${method} ${normalizeExpressPath(resolveMountedPath(file, prefix, routePath))}`;
|
|
if (explicitOpenApiOperationCoverageExclusions.has(operation)) {
|
|
excludedRoutes.add(operation);
|
|
} else {
|
|
routes.add(operation);
|
|
}
|
|
}
|
|
|
|
if (
|
|
file === "companies.ts" &&
|
|
source.includes("router.post(COMPANY_IMPORT_ROUTE_PATH")
|
|
) {
|
|
routes.add("POST /api/companies/import");
|
|
}
|
|
if (
|
|
file === "companies.ts" &&
|
|
source.includes("router.post(COMPANY_IMPORT_TRANSFERS_ROUTE_PATH")
|
|
) {
|
|
routes.add(`POST /api/companies${COMPANY_IMPORT_TRANSFERS_ROUTE_PATH}`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
routes,
|
|
excludedRoutes,
|
|
unknownRouteFiles: unknownRouteFiles.sort(),
|
|
};
|
|
}
|
|
|
|
function loadSpecRoutes() {
|
|
const spec = buildOpenApiSpec();
|
|
const routes = new Set<string>();
|
|
|
|
for (const [routePath, pathItem] of Object.entries<
|
|
Record<string, Record<string, unknown>>
|
|
>(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("documents strict run-attributed feedback without a read endpoint", () => {
|
|
const { spec } = loadSpecRoutes();
|
|
const path = spec.paths["/api/companies/{companyId}/agent-commentary"];
|
|
expect(Object.keys(path)).toEqual(["post"]);
|
|
const operation = path.post;
|
|
expect(operation.security).toEqual([{ AgentBearerAuth: [] }]);
|
|
expect(operation["x-paperclip-authorization"]).toEqual({ actor: "agent", heartbeatBound: true });
|
|
expect(operation.description).toContain("X-Paperclip-Run-Id");
|
|
const body = operation.requestBody.content["application/json"].schema;
|
|
expect(body.additionalProperties).toBe(false);
|
|
expect(Object.keys(body.properties).sort()).toEqual(["body", "idempotencyKey", "kind"]);
|
|
expect(body.properties.body.maxLength).toBe(524288);
|
|
for (const code of ["200", "201"]) {
|
|
const result = operation.responses[code].content["application/json"].schema;
|
|
expect(result.additionalProperties).toBe(false);
|
|
expect(Object.keys(result.properties).sort()).toEqual(["createdAt", "id", "kind", "replayed"]);
|
|
}
|
|
expect(operation.responses["409"]).toBeDefined();
|
|
expect(operation.responses["503"]).toBeDefined();
|
|
});
|
|
|
|
it("documents board-only pool management with revision-checked deletion", () => {
|
|
const { spec } = loadSpecRoutes();
|
|
const pools = spec.paths["/api/companies/{companyId}/ai-connection-pools"];
|
|
const remove = spec.paths["/api/companies/{companyId}/ai-connection-pools/{poolId}"].delete;
|
|
const inspection = spec.paths["/api/companies/{companyId}/ai-connection-pools/{poolId}/inspection"].get;
|
|
for (const operation of [pools.get, pools.post, remove, inspection]) {
|
|
expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
|
|
}
|
|
expect(remove.requestBody.required).toBe(true);
|
|
expect(remove.requestBody.content["application/json"].schema).toMatchObject({
|
|
required: ["expectedRevision"], additionalProperties: false,
|
|
properties: { expectedRevision: { type: "integer", minimum: 0, exclusiveMinimum: true } },
|
|
});
|
|
expect(Object.keys(remove.responses)).toEqual(expect.arrayContaining(["200", "400", "401", "403", "404", "409"]));
|
|
});
|
|
|
|
it("documents personal board-only announcements and private responses", () => {
|
|
const { spec } = loadSpecRoutes();
|
|
const current = spec.paths["/api/announcements/current"].get;
|
|
const image = spec.paths["/api/announcements/{id}/image"].get;
|
|
const animation = spec.paths["/api/announcements/{id}/animation"].get;
|
|
const dismiss = spec.paths["/api/announcements/{id}/dismiss"].post;
|
|
for (const operation of [current, image, animation, dismiss]) {
|
|
expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
|
|
expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" });
|
|
const success = operation.responses["200"] ?? operation.responses["204"];
|
|
expect(success.headers["Cache-Control"].schema.enum).toEqual(["private, no-store"]);
|
|
}
|
|
expect(current.responses["200"].content["application/json"].schema.nullable).toBe(true);
|
|
expect(Object.keys(image.responses["200"].content)).toEqual(["image/png", "image/jpeg", "image/webp"]);
|
|
expect(dismiss.requestBody.content["application/json"].schema).toMatchObject({
|
|
required: ["companyId"], additionalProperties: false,
|
|
});
|
|
expect(dismiss.description).toContain("viewers may dismiss their own");
|
|
});
|
|
|
|
it("documents exact failed-run selection and durable accepted retry responses", async () => {
|
|
const res = await request(createApp()).get("/api/openapi.json");
|
|
const wake = res.body.paths["/api/agents/{id}/wakeup"].post;
|
|
expect(
|
|
wake.requestBody.content["application/json"].schema.properties
|
|
.failedRunId,
|
|
).toMatchObject({ type: "string", format: "uuid" });
|
|
expect(wake.responses["202"]).toBeDefined();
|
|
expect(wake.responses["409"]).toBeDefined();
|
|
expect(wake.description).toContain("durable queued/deferred receipt");
|
|
});
|
|
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/mcp/project-tools"].post.security).toEqual([{ AgentRunAuth: [] }]);
|
|
expect(res.body.paths["/api/mcp/project-tools"].post["x-paperclip-authorization"]).toEqual({ actor: "agent", heartbeatBound: true, taskBound: true });
|
|
expect(res.body.paths["/mcp/gateways/{gatewayPublicId}"].post.security).toEqual([]);
|
|
expect(res.body.paths["/api/mcp/gateways/{gatewayPublicId}"]).toBeUndefined();
|
|
expect(res.body.paths["/api/companies"].get.parameters).toContainEqual({
|
|
name: "scope",
|
|
in: "query",
|
|
required: false,
|
|
schema: { type: "string", enum: ["accessible"] },
|
|
});
|
|
expect(res.body.paths["/api/companies"].get.responses["403"]).toBeDefined();
|
|
expect(res.body.paths["/api/companies"].get.responses["400"]).toBeDefined();
|
|
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(
|
|
JSON.stringify(res.body.paths["/api/companies"].post.responses),
|
|
).not.toContain("candidates");
|
|
expect(
|
|
res.body.paths["/api/companies/{companyId}/skills/scan-projects"].post
|
|
.responses["200"].content["application/json"].schema,
|
|
).toMatchObject({
|
|
type: "object",
|
|
properties: {
|
|
candidates: { type: "array" },
|
|
},
|
|
required: expect.arrayContaining(["candidates"]),
|
|
});
|
|
expect(
|
|
res.body.paths["/api/agents/{id}/keys"].post.requestBody.content[
|
|
"application/json"
|
|
].schema,
|
|
).toMatchObject({
|
|
type: "object",
|
|
properties: {
|
|
name: { type: "string" },
|
|
},
|
|
});
|
|
expect(
|
|
res.body.paths["/api/companies/{companyId}/folders"].post.responses[
|
|
"201"
|
|
],
|
|
).toBeDefined();
|
|
expect(
|
|
Object.keys(
|
|
res.body.paths[
|
|
"/api/issues/{id}/work-products/{workProductId}/review-document"
|
|
].post.responses,
|
|
).sort(),
|
|
).toEqual(["200", "201", "401", "403", "404", "409", "413", "415", "422"]);
|
|
expect(
|
|
res.body.paths["/api/issues/{id}/interactions/{interactionId}/withdraw"]
|
|
.post.summary,
|
|
).toBe("Withdraw a pending issue thread interaction");
|
|
const createInteraction =
|
|
res.body.paths["/api/issues/{id}/interactions"].post;
|
|
expect(createInteraction.description).toContain(
|
|
"defaults to canonical `anyone`",
|
|
);
|
|
const createInteractionSchema = JSON.stringify(
|
|
createInteraction.requestBody.content["application/json"].schema,
|
|
);
|
|
for (const resolverPolicy of [
|
|
"anyone",
|
|
"not_creator",
|
|
"human_only",
|
|
"board_or_agents",
|
|
"board_only",
|
|
]) {
|
|
expect(createInteractionSchema).toContain(`\"${resolverPolicy}\"`);
|
|
}
|
|
expect(
|
|
res.body.paths["/api/companies/{companyId}/folders/items/move"].post
|
|
.summary,
|
|
).toBe("Move an item into or out of a folder");
|
|
const createQueue =
|
|
res.body.paths["/api/companies/{companyId}/decision-queues"].post;
|
|
expect(createQueue.security).toContainEqual({ AgentBearerAuth: [] });
|
|
expect(createQueue.responses["200"]).toBeDefined();
|
|
expect(createQueue.responses["201"]).toBeDefined();
|
|
expect(
|
|
createQueue.requestBody.content["application/json"].schema,
|
|
).toMatchObject({
|
|
type: "object",
|
|
properties: {
|
|
key: { type: "string", minLength: 1, maxLength: 80 },
|
|
title: { type: "string", minLength: 1, maxLength: 120 },
|
|
},
|
|
required: ["key", "title"],
|
|
});
|
|
const updateTriage =
|
|
res.body.paths[
|
|
"/api/companies/{companyId}/decision-triage/{sourceKind}/{sourceId}"
|
|
].put;
|
|
expect(updateTriage.responses["422"]).toBeDefined();
|
|
expect(
|
|
updateTriage.requestBody.content["application/json"].schema.properties,
|
|
).toMatchObject({
|
|
decideBy: { nullable: true },
|
|
snoozedUntil: { type: "string", format: "date-time", nullable: true },
|
|
});
|
|
expect(
|
|
JSON.stringify(res.body.paths["/api/tool-gateway/tools"].get),
|
|
).not.toContain("sessionToken");
|
|
expect(
|
|
JSON.stringify(res.body.paths["/api/tool-gateway/tools/call"].post),
|
|
).not.toContain("sessionToken");
|
|
});
|
|
|
|
it("publishes the complete board contract for chat channels", () => {
|
|
const { spec } = loadSpecRoutes();
|
|
const boardSecurity = [{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }];
|
|
const operations = [
|
|
["get", "/api/companies/{companyId}/chat-endpoints"],
|
|
["post", "/api/companies/{companyId}/chat-endpoints"],
|
|
["get", "/api/chat-endpoints/{endpointId}"],
|
|
["get", "/api/chat-endpoints/{endpointId}/github/configuration"],
|
|
["put", "/api/chat-endpoints/{endpointId}/github/configuration"],
|
|
["post", "/api/chat-endpoints/{endpointId}/github/verify"],
|
|
["put", "/api/chat-endpoints/{endpointId}/github/progress"],
|
|
["get", "/api/chat-endpoints/{endpointId}/github/reviews"],
|
|
["get", "/api/chat-endpoints/{endpointId}/github/personal-connections"],
|
|
["post", "/api/chat-endpoints/{endpointId}/github/identity"],
|
|
["post", "/api/chat-endpoints/{endpointId}/github/people/lookup"],
|
|
["post", "/api/chat-endpoints/{endpointId}/github/registration"],
|
|
["post", "/api/chat-endpoints/{endpointId}/github/app"],
|
|
["post", "/api/chat-endpoints/{endpointId}/github/repositories/refresh"],
|
|
["patch", "/api/chat-endpoints/{endpointId}"],
|
|
["post", "/api/chat-endpoints/{endpointId}/setup"],
|
|
["post", "/api/chat-endpoints/{endpointId}/setup-secret"],
|
|
["post", "/api/chat-endpoints/{endpointId}/test"],
|
|
["post", "/api/chat-endpoints/{endpointId}/photon/inspect"],
|
|
["get", "/api/chat-endpoints/{endpointId}/resources"],
|
|
["put", "/api/chat-endpoints/{endpointId}/resources"],
|
|
["get", "/api/chat-endpoints/{endpointId}/principals"],
|
|
[
|
|
"post",
|
|
"/api/chat-endpoints/{endpointId}/principals/{principalId}/link-intent",
|
|
],
|
|
[
|
|
"delete",
|
|
"/api/chat-endpoints/{endpointId}/principals/{principalId}/link",
|
|
],
|
|
["get", "/api/chat-identity-links/preview"],
|
|
["post", "/api/chat-identity-links/confirm"],
|
|
["get", "/api/chat-endpoints/{endpointId}/conversations"],
|
|
["get", "/api/chat-endpoints/{endpointId}/activity"],
|
|
[
|
|
"post",
|
|
"/api/chat-endpoints/{endpointId}/deliveries/{deliveryId}/replay",
|
|
],
|
|
[
|
|
"post",
|
|
"/api/chat-endpoints/{endpointId}/publications/{publicationId}/replay",
|
|
],
|
|
[
|
|
"post",
|
|
"/api/chat-endpoints/{endpointId}/publications/{publicationId}/resolve",
|
|
],
|
|
["post", "/api/chat-endpoints/{endpointId}/actions/{actionId}/resolve"],
|
|
[
|
|
"post",
|
|
"/api/chat-endpoints/{endpointId}/conversations/{conversationId}/publications",
|
|
],
|
|
["get", "/api/issues/{issueId}/chat-binding"],
|
|
[
|
|
"get",
|
|
"/api/chat-endpoints/{endpointId}/conversations/{conversationId}/publications/{publicationId}/status",
|
|
],
|
|
] as const;
|
|
|
|
for (const [method, routePath] of operations) {
|
|
const operation = spec.paths[routePath]?.[method];
|
|
expect(
|
|
operation,
|
|
`${method.toUpperCase()} ${routePath} is documented`,
|
|
).toBeDefined();
|
|
expect(
|
|
operation.security,
|
|
`${method.toUpperCase()} ${routePath} is board-only`,
|
|
).toEqual(boardSecurity);
|
|
expect(operation["x-paperclip-authorization"]).toEqual({
|
|
actor: "board",
|
|
});
|
|
expect(operation.tags).toContain("chat-channels");
|
|
}
|
|
|
|
const create = spec.paths["/api/companies/{companyId}/chat-endpoints"].post;
|
|
expect(create.responses["201"]).toBeDefined();
|
|
expect(create.requestBody.content["application/json"].schema).toMatchObject(
|
|
{
|
|
type: "object",
|
|
additionalProperties: false,
|
|
properties: {
|
|
provider: {
|
|
type: "string",
|
|
enum: ["slack", "github", "discord", "microsoft-teams", "telegram", "imessage-photon"],
|
|
},
|
|
assignedAgentId: { type: "string", format: "uuid" },
|
|
},
|
|
required: ["provider", "assignedAgentId"],
|
|
},
|
|
);
|
|
|
|
const endpointResponse =
|
|
spec.paths["/api/chat-endpoints/{endpointId}"].get.responses["200"]
|
|
.content["application/json"].schema;
|
|
expect(endpointResponse).toMatchObject({
|
|
type: "object",
|
|
additionalProperties: false,
|
|
properties: {
|
|
assignedAgentId: { type: "string", format: "uuid" },
|
|
status: {
|
|
type: "string",
|
|
enum: [
|
|
"draft",
|
|
"verifying",
|
|
"active",
|
|
"paused",
|
|
"attention",
|
|
"revoked",
|
|
"archived",
|
|
],
|
|
},
|
|
capabilities: { type: "object", additionalProperties: false },
|
|
setup: { type: "object", additionalProperties: false },
|
|
},
|
|
});
|
|
expect(JSON.stringify(endpointResponse)).not.toContain("credentials");
|
|
expect(JSON.stringify(endpointResponse)).not.toContain("privateKey");
|
|
expect(JSON.stringify(endpointResponse)).not.toContain("signingSecret");
|
|
expect(
|
|
endpointResponse.properties.setup.properties.callbacksNeedUpdate,
|
|
).toEqual({ type: "boolean" });
|
|
expect(
|
|
endpointResponse.properties.setup.properties.callbackSurfaces.properties
|
|
.events.properties.status.enum,
|
|
).toEqual(["current", "stale", "unverified"]);
|
|
|
|
const setup = spec.paths["/api/chat-endpoints/{endpointId}/setup"].post;
|
|
expect(
|
|
setup.requestBody.content["application/json"].schema.properties.action
|
|
.enum,
|
|
).toEqual([
|
|
"configure",
|
|
"verify",
|
|
"pause",
|
|
"resume",
|
|
"reconnect",
|
|
"remove",
|
|
]);
|
|
expect(setup.description).toContain(
|
|
"Discord: `applicationId`, `guildId`, `botToken`",
|
|
);
|
|
expect(setup.responses["409"]).toBeDefined();
|
|
expect(setup.responses["422"]).toBeDefined();
|
|
expect(setup.responses["502"]).toBeDefined();
|
|
expect(setup.responses["503"]).toBeDefined();
|
|
|
|
const photon = spec.paths["/api/chat-endpoints/{endpointId}/photon/inspect"].post;
|
|
expect(photon.requestBody.content["application/json"].schema.required).toEqual([
|
|
"projectId", "projectSecret",
|
|
]);
|
|
const photonResponse = photon.responses["200"].content["application/json"].schema;
|
|
expect(photonResponse.properties.allocation.enum).toEqual(["dedicated", "shared"]);
|
|
expect(photonResponse.properties.lines.items.additionalProperties).toBe(false);
|
|
expect(JSON.stringify(photonResponse)).not.toMatch(/projectSecret|token/);
|
|
expect(photon.responses["422"]).toBeDefined();
|
|
expect(photon.responses["429"]).toBeDefined();
|
|
expect(photon.responses["502"]).toBeDefined();
|
|
expect(photon.responses["503"]).toBeDefined();
|
|
|
|
const setupSecret =
|
|
spec.paths["/api/chat-endpoints/{endpointId}/setup-secret"].post;
|
|
expect(
|
|
setupSecret.responses["201"].content["application/json"].schema,
|
|
).toMatchObject({
|
|
type: "object",
|
|
additionalProperties: false,
|
|
required: ["webhookSecret"],
|
|
});
|
|
expect(setupSecret.responses["409"]).toBeDefined();
|
|
expect(setupSecret.responses["422"]).toBeDefined();
|
|
|
|
const resolveAction =
|
|
spec.paths["/api/chat-endpoints/{endpointId}/actions/{actionId}/resolve"]
|
|
.post;
|
|
expect(
|
|
resolveAction.requestBody.content["application/json"].schema.properties
|
|
.action.enum,
|
|
).toEqual(["mark_delivered", "retry_anyway", "cancel"]);
|
|
expect(resolveAction.responses["409"]).toBeDefined();
|
|
expect(resolveAction.responses["422"]).toBeDefined();
|
|
|
|
const activity =
|
|
spec.paths["/api/chat-endpoints/{endpointId}/activity"].get.responses[
|
|
"200"
|
|
].content["application/json"].schema.oneOf[0].items;
|
|
expect(activity.properties.actionType.enum).toEqual([
|
|
"slash_task_start",
|
|
"provider_effect",
|
|
"github_webhook_ingress",
|
|
"slack_session_sync",
|
|
"slack_session_stop",
|
|
]);
|
|
const fileTransfer = activity.properties.fileTransfer;
|
|
expect(fileTransfer).toMatchObject({
|
|
type: "object",
|
|
additionalProperties: false,
|
|
required: ["provider", "phase", "filename", "version"],
|
|
properties: {
|
|
provider: { type: "string", enum: ["microsoft-teams"] },
|
|
version: { type: "integer", minimum: 0, exclusiveMinimum: true },
|
|
},
|
|
});
|
|
expect(Object.keys(fileTransfer.properties).sort()).toEqual([
|
|
"expiresAt",
|
|
"filename",
|
|
"phase",
|
|
"provider",
|
|
"version",
|
|
]);
|
|
expect(fileTransfer.properties.phase.enum).toHaveLength(15);
|
|
const boardSend =
|
|
spec.paths[
|
|
"/api/chat-endpoints/{endpointId}/conversations/{conversationId}/publications"
|
|
].post;
|
|
expect(boardSend.responses["409"]).toBeDefined();
|
|
expect(boardSend.responses["422"]).toBeDefined();
|
|
expect(boardSend.description).toContain(
|
|
"chat_board_send_attachments_already_bound",
|
|
);
|
|
expect(boardSend.description).toContain(
|
|
"Other errors do not establish non-delivery",
|
|
);
|
|
const batchStatus =
|
|
spec.paths[
|
|
"/api/chat-endpoints/{endpointId}/conversations/{conversationId}/publications/{publicationId}/status"
|
|
].get.responses["200"].content["application/json"].schema;
|
|
expect(batchStatus.required).toEqual(
|
|
expect.arrayContaining([
|
|
"publication",
|
|
"parts",
|
|
"total",
|
|
"published",
|
|
"awaitingConsent",
|
|
"declined",
|
|
"expired",
|
|
"cancelled",
|
|
"settled",
|
|
"canDismiss",
|
|
]),
|
|
);
|
|
expect(batchStatus.properties.parts.items.properties.fileTransfer).toEqual(
|
|
fileTransfer,
|
|
);
|
|
expect(batchStatus.properties.publication.properties.state.enum).toContain(
|
|
"awaiting_consent",
|
|
);
|
|
const resolvePublication =
|
|
spec.paths[
|
|
"/api/chat-endpoints/{endpointId}/publications/{publicationId}/resolve"
|
|
].post.requestBody.content["application/json"].schema;
|
|
expect(resolvePublication.properties.fileTransfer).toMatchObject({
|
|
type: "object",
|
|
additionalProperties: false,
|
|
required: ["phase", "version"],
|
|
properties: {
|
|
phase: { enum: fileTransfer.properties.phase.enum },
|
|
version: { type: "integer", minimum: 0, exclusiveMinimum: true },
|
|
},
|
|
});
|
|
expect(JSON.stringify(batchStatus)).not.toMatch(
|
|
/uploadUrl|privateState|tokenSha256|credentialFingerprint/,
|
|
);
|
|
|
|
const replaceResources =
|
|
spec.paths["/api/chat-endpoints/{endpointId}/resources"].put;
|
|
expect(
|
|
replaceResources.requestBody.content["application/json"].schema,
|
|
).toMatchObject({
|
|
type: "object",
|
|
additionalProperties: false,
|
|
required: ["resources"],
|
|
});
|
|
expect(replaceResources.responses["409"]).toBeDefined();
|
|
expect(replaceResources.responses["422"]).toBeDefined();
|
|
|
|
expect(
|
|
spec.paths[
|
|
"/api/chat-endpoints/{endpointId}/principals/{principalId}/link"
|
|
].delete.responses["204"],
|
|
).toBeDefined();
|
|
expect(
|
|
spec.paths[
|
|
"/api/chat-endpoints/{endpointId}/deliveries/{deliveryId}/replay"
|
|
].post.responses["204"],
|
|
).toBeDefined();
|
|
expect(
|
|
spec.paths[
|
|
"/api/chat-endpoints/{endpointId}/publications/{publicationId}/replay"
|
|
].post.responses["204"],
|
|
).toBeDefined();
|
|
|
|
const endpointScopedOperations = operations.filter(
|
|
([, routePath]) =>
|
|
routePath.includes("{endpointId}") ||
|
|
routePath === "/api/issues/{issueId}/chat-binding",
|
|
);
|
|
for (const [method, routePath] of endpointScopedOperations) {
|
|
expect(
|
|
spec.paths[routePath][method].responses["404"],
|
|
`${method.toUpperCase()} ${routePath} preserves the non-member 404 boundary`,
|
|
).toBeDefined();
|
|
}
|
|
|
|
expect(
|
|
spec.paths["/api/chat-webhooks/{publicId}/{provider}"],
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("covers the mounted server routes exactly", () => {
|
|
const {
|
|
routes: actualRoutes,
|
|
excludedRoutes,
|
|
unknownRouteFiles,
|
|
} = loadActualRoutes();
|
|
const { routes: specRoutes } = loadSpecRoutes();
|
|
|
|
const missingInSpec = [...actualRoutes]
|
|
.filter((route) => !specRoutes.has(route))
|
|
.sort();
|
|
const extraInSpec = [...specRoutes]
|
|
.filter(
|
|
(route) =>
|
|
!actualRoutes.has(route) && !specOnlyContractFirstRoutes.has(route),
|
|
)
|
|
.sort();
|
|
|
|
expect({
|
|
unknownRouteFiles,
|
|
missingInSpec,
|
|
extraInSpec,
|
|
excludedRoutes: [...excludedRoutes].sort(),
|
|
}).toEqual({
|
|
unknownRouteFiles: [],
|
|
missingInSpec: [],
|
|
extraInSpec: [],
|
|
excludedRoutes: [...explicitOpenApiOperationCoverageExclusions].sort(),
|
|
});
|
|
});
|
|
|
|
it("documents board-only repository discovery and selection", () => {
|
|
const { spec } = loadSpecRoutes();
|
|
const discovery = spec.paths["/api/companies/{companyId}/project-repositories"].get;
|
|
const replacement = spec.paths["/api/projects/{id}/repositories"].put;
|
|
for (const operation of [discovery, replacement]) {
|
|
expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" });
|
|
expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
|
|
}
|
|
expect(replacement.requestBody.content["application/json"].schema.required).toContain("repositoryIds");
|
|
expect(replacement.responses["422"]).toBeDefined();
|
|
});
|
|
|
|
it("documents auth and reviewed response-code invariants", () => {
|
|
const { spec } = loadSpecRoutes();
|
|
|
|
expect(spec.paths["/api/openapi.json"].get.security).toEqual([]);
|
|
expect(
|
|
spec.paths["/runtime-tools/github/credentials"].post.security,
|
|
).toEqual([{ RuntimeToolsBearerAuth: [] }]);
|
|
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/execution-workspaces/{id}/reconcile-branch"].post
|
|
.security,
|
|
).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
|
|
expect(
|
|
spec.paths["/api/execution-workspaces/{id}/reconcile-branch"].post[
|
|
"x-paperclip-authorization"
|
|
],
|
|
).toEqual({
|
|
actor: "board",
|
|
});
|
|
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/companies/{companyId}/managed-agent-profiles"].post
|
|
.security,
|
|
).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
|
|
expect(
|
|
spec.paths["/api/companies/{companyId}/remote-agent-profiles"].get
|
|
.security,
|
|
).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
|
|
const remoteAgentProfileBody =
|
|
spec.paths["/api/companies/{companyId}/remote-agent-profiles"].post
|
|
.requestBody.content["application/json"].schema;
|
|
expect(remoteAgentProfileBody.properties.service).toMatchObject({
|
|
type: "string",
|
|
enum: ["aws_bedrock_agentcore_harness"],
|
|
});
|
|
expect(
|
|
remoteAgentProfileBody.properties.credentialSecretId,
|
|
).toBeUndefined();
|
|
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();
|
|
expect(
|
|
spec.paths["/api/routines/{id}/run"].post.responses["422"],
|
|
).toBeDefined();
|
|
});
|
|
|
|
it("publishes the Claude browser-code grammar and strict setup-token response shapes", () => {
|
|
const { spec } = loadSpecRoutes();
|
|
const base = "/api/companies/{companyId}/setup-token-login-sessions";
|
|
|
|
// The submitted browser code carries the bounded printable-ASCII grammar.
|
|
const codeBody =
|
|
spec.paths[`${base}/{sessionId}/code`].post.requestBody.content[
|
|
"application/json"
|
|
].schema;
|
|
const browserCode = codeBody.properties.browserCode;
|
|
expect(browserCode.minLength).toBe(1);
|
|
expect(browserCode.maxLength).toBe(512);
|
|
expect(typeof browserCode.pattern).toBe("string");
|
|
expect(browserCode.pattern.length).toBeGreaterThan(0);
|
|
|
|
// Every Claude request object forbids an unknown property.
|
|
const startBody =
|
|
spec.paths[base].post.requestBody.content["application/json"].schema;
|
|
expect(startBody.additionalProperties).toBe(false);
|
|
expect(codeBody.additionalProperties).toBe(false);
|
|
|
|
// The four contract-first routes carry typed strict response schemas.
|
|
const responseSchemas: Record<string, Record<string, unknown>> = {
|
|
start:
|
|
spec.paths[base].post.responses["201"].content["application/json"]
|
|
.schema,
|
|
status:
|
|
spec.paths[`${base}/{sessionId}`].get.responses["200"].content[
|
|
"application/json"
|
|
].schema,
|
|
prompt:
|
|
spec.paths[`${base}/{sessionId}/prompt`].get.responses["200"].content[
|
|
"application/json"
|
|
].schema,
|
|
code: spec.paths[`${base}/{sessionId}/code`].post.responses["200"]
|
|
.content["application/json"].schema,
|
|
};
|
|
const forbiddenProperties = ["token", "accountId", "leaseId"];
|
|
for (const [name, schema] of Object.entries(responseSchemas)) {
|
|
expect(schema.type, `${name} response is a typed object`).toBe("object");
|
|
expect(schema.additionalProperties, `${name} response is strict`).toBe(
|
|
false,
|
|
);
|
|
const properties = (schema.properties ?? {}) as Record<string, unknown>;
|
|
expect(
|
|
Object.keys(properties).length,
|
|
`${name} response lists properties`,
|
|
).toBeGreaterThan(0);
|
|
for (const forbidden of forbiddenProperties) {
|
|
expect(
|
|
properties[forbidden],
|
|
`${name} response hides ${forbidden}`,
|
|
).toBeUndefined();
|
|
}
|
|
// No property name looks like a raw prompt secret or a token.
|
|
for (const property of Object.keys(properties)) {
|
|
expect(
|
|
/token|secret|accountId|leaseId/i.test(property),
|
|
`${name}.${property} is not secret-adjacent`,
|
|
).toBe(false);
|
|
}
|
|
}
|
|
|
|
// The status and code routes share the public response; it hides the prompt.
|
|
expect(responseSchemas.status.properties).toEqual(
|
|
responseSchemas.code.properties,
|
|
);
|
|
expect(
|
|
(responseSchemas.status.properties as Record<string, unknown>).prompt,
|
|
).toBeUndefined();
|
|
// The owner start response adds the panel mode and the one-time prompt.
|
|
expect(
|
|
(responseSchemas.start.properties as Record<string, unknown>).panelMode,
|
|
).toBeDefined();
|
|
expect(
|
|
(responseSchemas.start.properties as Record<string, unknown>).prompt,
|
|
).toBeDefined();
|
|
// The prompt route returns the authorization URL and the optional transport
|
|
// advisory. The advisory is present on a non-confidential transport, so the
|
|
// client can show a non-blocking disclaimer.
|
|
expect(
|
|
Object.keys(responseSchemas.prompt.properties as Record<string, unknown>),
|
|
).toEqual(["authorizationUrl", "transportAdvisory"]);
|
|
});
|
|
|
|
it("documents the 404 non-member gate on the Claude setup-token cancel route", () => {
|
|
const { spec } = loadSpecRoutes();
|
|
const cancel =
|
|
spec.paths[
|
|
"/api/companies/{companyId}/setup-token-login-sessions/{sessionId}/cancel"
|
|
].post;
|
|
// The 404 is reachable at run time. The company-access gate returns a fixed
|
|
// 404 for a non-member before the cancel logic runs, so the spec declares
|
|
// it. The idempotent cancel still returns 200 for an owner-scoped missing,
|
|
// terminal, or foreign session id.
|
|
const codes = Object.keys(cancel.responses).sort();
|
|
expect(codes).toEqual(["200", "401", "403", "404"]);
|
|
});
|
|
});
|
|
|
|
|
|
describe("heartbeat run ID OpenAPI contract", () => {
|
|
it("publishes the runtime UUID constraint and 400 response on all agent-router run endpoints", async () => {
|
|
const response = await request(createApp()).get("/api/openapi.json");
|
|
expect(response.status).toBe(200);
|
|
const paths = response.body.paths;
|
|
let checked = 0;
|
|
for (const [path, operations] of Object.entries(paths)) {
|
|
if (!path.startsWith("/api/heartbeat-runs/{runId}") || path.endsWith("/issues")) continue;
|
|
for (const operation of Object.values(operations as Record<string, any>)) {
|
|
const parameter = operation.parameters.find((param: { name: string }) => param.name === "runId");
|
|
expect(parameter.schema.pattern).toEqual(expect.any(String));
|
|
const pattern = new RegExp(parameter.schema.pattern);
|
|
for (const id of [
|
|
"aaaaaaaa-aaaa-1aaa-8aaa-aaaaaaaaaaaa",
|
|
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
"AAAAAAAA-AAAA-5AAA-BAAA-AAAAAAAAAAAA",
|
|
]) expect(pattern.test(id), id).toBe(true);
|
|
for (const id of [
|
|
"undefined", "not-a-uuid", " aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa ",
|
|
"aaaaaaaa-aaaa-7aaa-8aaa-aaaaaaaaaaaa", "aaaaaaaa-aaaa-4aaa-0aaa-aaaaaaaaaaaa",
|
|
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\n",
|
|
]) expect(pattern.test(id), JSON.stringify(id)).toBe(false);
|
|
expect(operation.responses["400"]).toBeDefined();
|
|
checked++;
|
|
}
|
|
}
|
|
expect(checked).toBe(12);
|
|
});
|
|
});
|