import { describe, test, expect } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { TWILIO_MEDIA_STREAM_WEBHOOK_PATH, TWILIO_STATUS_WEBHOOK_PATH, TWILIO_VOICE_WEBHOOK_PATH, } from "@vellumai/service-contracts/twilio-ingress"; import { A2A_AGENT_CARD_PATH } from "../http/routes/a2a-routes.js"; import { buildSchema } from "../schema.js"; import { buildMarkedContactRoutes } from "./helpers/contact-route-table.js"; /** A route extracted from source: path + optional HTTP method. */ interface ExtractedRoute { path: string; method: string | null; // null means "any method" } const ROUTE_PATH_CONSTANTS: Record = { A2A_AGENT_CARD_PATH, TWILIO_MEDIA_STREAM_WEBHOOK_PATH, TWILIO_STATUS_WEBHOOK_PATH, TWILIO_VOICE_WEBHOOK_PATH, }; /** * Extracts route paths from the gateway index.ts source code. * * Routes are defined in three places: * 1. The `routes` array (RouteDefinition[]) — matched by the router * 2. Pre-router paths in the `fetch()` handler (healthz, readyz, schema, WS upgrades) * 3. The contact-family route table spread into the array (imported directly) * * We parse the source text rather than importing index.ts because it calls * `main()` at module scope which starts the server. */ function extractRoutesFromSource(): ExtractedRoute[] { const src = readFileSync( join(import.meta.dirname!, "..", "index.ts"), "utf-8", ); const lines = src.split("\n"); const routes: ExtractedRoute[] = []; const seenPreRouterPaths = new Set(); for (let i = 0; i < lines.length; i++) { const line = lines[i]; // Match string literal paths: `path: "/some/path"` const stringMatch = line.match(/path:\s*"([^"]+)"/); if (stringMatch) { const method = findMethodNearPath(lines, i); routes.push({ path: stringMatch[1], method }); continue; } // Match shared path constants: `path: SOME_WEBHOOK_PATH` const constantMatch = line.match(/path:\s*([A-Z0-9_]+)\b/); const constantPath = constantMatch ? ROUTE_PATH_CONSTANTS[constantMatch[1]] : undefined; if (constantPath) { const method = findMethodNearPath(lines, i); routes.push({ path: constantPath, method }); continue; } // Match regex paths: `path: /^\/v1\/contacts\/([^/]+)$/` const regexMatch = line.match(/path:\s*\/\^(.*?)\$\//); if (regexMatch) { const converted = regexToOpenApiPath(regexMatch[1]); if (converted) { const method = findMethodNearPath(lines, i); routes.push({ path: converted, method }); } continue; } // Pre-router paths matched via `url.pathname === "/..."` in the fetch handler const preRouterMatch = line.match(/url\.pathname\s*===\s*"([^"]+)"/); if (preRouterMatch && !seenPreRouterPaths.has(preRouterMatch[1])) { seenPreRouterPaths.add(preRouterMatch[1]); routes.push({ path: preRouterMatch[1], method: null }); } const preRouterConstantMatch = line.match( /url\.pathname\s*===\s*([A-Z0-9_]+)\b/, ); const preRouterConstantPath = preRouterConstantMatch ? ROUTE_PATH_CONSTANTS[preRouterConstantMatch[1]] : undefined; if ( preRouterConstantPath && !seenPreRouterPaths.has(preRouterConstantPath) ) { seenPreRouterPaths.add(preRouterConstantPath); routes.push({ path: preRouterConstantPath, method: null }); } } // The contact family is registered via a spread of the shared route table; // extract it from the real builder rather than source text. if (!src.includes("...buildContactsControlPlaneRoutes(")) { throw new Error( "index.ts no longer spreads buildContactsControlPlaneRoutes — update this guard", ); } routes.push(...extractContactTableRoutes()); return routes; } /** Contact-family routes from the builder index.ts spreads. */ function extractContactTableRoutes(): ExtractedRoute[] { return buildMarkedContactRoutes().flatMap((route) => { const method = route.method ?? null; if (typeof route.path === "string") { return [{ path: route.path, method }]; } const converted = regexToOpenApiPath( route.path.source.replace(/^\^/, "").replace(/\$$/, ""), ); return converted ? [{ path: converted, method }] : []; }); } /** * Looks for a `method: "..."` declaration near a `path:` line. * In the route table, method is always declared within a few lines * of path (same object literal). We scan up to 3 lines after path. */ function findMethodNearPath( lines: string[], pathLineIndex: number, ): string | null { // method can appear before or after path within the same object. // Scan a small window around the path line, stopping at object boundaries. for (let offset = -3; offset <= 3; offset++) { const idx = pathLineIndex + offset; if (idx < 0 || idx >= lines.length) continue; const methodMatch = lines[idx].match(/method:\s*"([A-Z]+)"/); if (methodMatch) return methodMatch[1]; } return null; } /** Deduplicated, sorted list of unique route paths. */ function extractRoutePathsFromSource(): string[] { const paths = new Set(extractRoutesFromSource().map((r) => r.path)); return [...paths].sort(); } /** * Converts an escaped regex path to an OpenAPI-style path. * e.g. `\/v1\/contacts\/([^/]+)` → `/v1/contacts/{id}` * * Each capture group `([^/]+)` is replaced with `{paramN}` where N is the * 1-based index of the group. */ function regexToOpenApiPath(escaped: string): string | null { // Unescape forward slashes let path = escaped.replace(/\\\//g, "/"); // Zero-width lookarounds constrain which parameter values are accepted, // but they do not change the structural path shape we compare to the schema. path = path.replace(/\(\?(?:=|!|<=| { paramIndex++; return `{param${paramIndex}}`; }); // Strip optional trailing slash (`/?`) — common in route regexes path = path.replace(/\/\?$/, ""); // If there are remaining regex constructs we can't convert, skip if (/[\\()\[\].*+?{}|^$]/.test(path.replace(/\{param\d+\}/g, ""))) { return null; } return path; } // ── Routes that are intentionally undocumented in the OpenAPI schema ── // Each entry must have a comment explaining why it's excluded. const EXCLUDED_FROM_SCHEMA = new Set([ // Runtime proxy catch-all — documented as /{path} in the schema "catch-all", // Loopback-only pairing endpoint — not part of the public gateway API "/v1/pair", // Loopback-only remote web pairing challenge — not part of the public gateway API "/v1/remote-web/pairing-challenge", // Remote web pairing verification bootstrap — not part of the public gateway API "/v1/remote-web/pairing-verification", // Remote web pairing token bootstrap — not part of the public gateway API "/v1/remote-web/pairing-token", // Loopback-only device management — not part of the public gateway API "/v1/devices", "/v1/devices/revoke", // A2A agent card discovery — read-only, unauthenticated per spec "/.well-known/agent-card.json", // Internal-only: reachable only via vembda's trusted gateway-query proxy "/v1/contacts/guardian/channel", // BFF token auth — loopback-only, not part of the public gateway API "/auth/token", // Managed-speech relay — daemon-only WS egress to velay, rejected on any // ingress path; not part of the public gateway API "/v1/speech/stt/stream", "/v1/speech/tts/stream", ]); // ── Schema paths that don't map to a discrete route definition ── // These are documented in the schema but correspond to pre-router logic // or catch-all behavior rather than an explicit route table entry. const SCHEMA_ONLY_PATHS = new Set([ // Served by the catch-all runtime proxy, not a dedicated route "/{path}", ]); describe("route-schema sync guard", () => { const schema = buildSchema() as { paths: Record }; const schemaPaths = new Set(Object.keys(schema.paths)); const routePaths = extractRoutePathsFromSource(); test("every route path should have a corresponding schema entry", () => { const missing: string[] = []; for (const routePath of routePaths) { if (EXCLUDED_FROM_SCHEMA.has(routePath)) continue; // The catch-all regex `/^\//` matches everything — it maps to /{path} in the schema if (routePath === "/") continue; // Normalize regex-extracted parameterized paths to match schema naming. // Route regexes use positional params ({param1}, {param2}) while the // schema uses semantic names. We check if any schema path matches // structurally (same segments, params in same positions). const matched = findMatchingSchemaPath(routePath, schemaPaths); if (!matched) { missing.push(routePath); } } expect(missing).toEqual([]); }); test("every schema path should have a corresponding route", () => { const orphaned: string[] = []; for (const schemaPath of schemaPaths) { if (SCHEMA_ONLY_PATHS.has(schemaPath)) continue; const matched = findMatchingRoutePath(schemaPath, routePaths); if (!matched) { orphaned.push(schemaPath); } } expect(orphaned).toEqual([]); }); test("HTTP methods for each path should match between routes and schema", () => { const routes = extractRoutesFromSource(); const HTTP_METHODS = ["get", "post", "put", "patch", "delete"] as const; type HttpMethod = (typeof HTTP_METHODS)[number]; const mismatches: string[] = []; // Build a map of path → set of methods from the route table. // Routes without an explicit method match any method — skip those // since the guard can't know which methods they actually handle. const routeMethodsByPath = new Map>(); for (const route of routes) { if (EXCLUDED_FROM_SCHEMA.has(route.path)) continue; if (route.path === "/") continue; // catch-all if (!route.method) continue; // any-method routes can't be compared const normalizedPath = resolveSchemaPath(route.path, schemaPaths); if (!normalizedPath) continue; let methods = routeMethodsByPath.get(normalizedPath); if (!methods) { methods = new Set(); routeMethodsByPath.set(normalizedPath, methods); } methods.add(route.method.toLowerCase() as HttpMethod); } // For each path that has explicit methods in the route table, // verify the schema documents exactly the same set of methods. for (const [path, routeMethods] of routeMethodsByPath) { const schemaEntry = ( schema.paths as Record> )[path]; if (!schemaEntry) continue; // path-level mismatch is caught by the other tests const schemaMethods = new Set( HTTP_METHODS.filter((m) => m in schemaEntry), ); const missingFromSchema = [...routeMethods].filter( (m) => !schemaMethods.has(m), ); const extraInSchema = [...schemaMethods].filter( (m) => !routeMethods.has(m), ); for (const m of missingFromSchema) { mismatches.push( `${m.toUpperCase()} ${path}: in routes but not in schema`, ); } for (const m of extraInSchema) { mismatches.push( `${m.toUpperCase()} ${path}: in schema but not in routes`, ); } } expect(mismatches).toEqual([]); }); test("excluded routes list contains only paths that actually exist", () => { // Catch-all is a special synthetic entry const actualPaths = new Set(routePaths); const stale = [...EXCLUDED_FROM_SCHEMA].filter( (p) => p !== "catch-all" && !actualPaths.has(p), ); expect(stale).toEqual([]); }); test("regex route normalization ignores negative lookaheads", () => { expect( regexToOpenApiPath(String.raw`\/v1\/contacts\/(?!invites$)([^/]+)`), ).toBe("/v1/contacts/{param1}"); }); }); /** * Returns the schema path string that matches a route path, or null if none. * Used by the method comparison test to look up schema entries by path. */ function resolveSchemaPath( routePath: string, schemaPaths: Set, ): string | null { if (schemaPaths.has(routePath)) return routePath; const routeSegments = routePath.split("/"); for (const schemaPath of schemaPaths) { const schemaSegments = schemaPath.split("/"); if (routeSegments.length !== schemaSegments.length) continue; const matches = routeSegments.every((seg, i) => { if (seg === schemaSegments[i]) return true; if (seg.startsWith("{") && schemaSegments[i].startsWith("{")) return true; return false; }); if (matches) return schemaPath; } return null; } /** * Checks if a route path (possibly with {paramN} placeholders) matches * any schema path (with semantic parameter names like {contactId}). * * Two paths match if they have the same number of segments and every * non-parameter segment is identical. */ function findMatchingSchemaPath( routePath: string, schemaPaths: Set, ): boolean { // Direct match if (schemaPaths.has(routePath)) return true; const routeSegments = routePath.split("/"); for (const schemaPath of schemaPaths) { const schemaSegments = schemaPath.split("/"); if (routeSegments.length !== schemaSegments.length) continue; const matches = routeSegments.every((seg, i) => { if (seg === schemaSegments[i]) return true; // Both are parameters if (seg.startsWith("{") && schemaSegments[i].startsWith("{")) return true; return false; }); if (matches) return true; } return false; } /** * Checks if a schema path matches any route path, accounting for * parameterized segments. */ function findMatchingRoutePath( schemaPath: string, routePaths: string[], ): boolean { if (routePaths.includes(schemaPath)) return true; const schemaSegments = schemaPath.split("/"); for (const routePath of routePaths) { const routeSegments = routePath.split("/"); if (schemaSegments.length !== routeSegments.length) continue; const matches = schemaSegments.every((seg, i) => { if (seg === routeSegments[i]) return true; if (seg.startsWith("{") && routeSegments[i].startsWith("{")) return true; return false; }); if (matches) return true; } return false; }