import { beforeAll, describe, expect, test } from "vitest"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { scanApiRoutesFromFile, type ApiRoute } from "../../../tools/api-inventory/scan-api-routes.js"; type MutatingMethod = "POST" | "PATCH" | "DELETE"; type ProtectionClass = | "jwt-auth" | "service-token" | "service-or-tenant-api-key" | "optional-api-key-validation" | "webhook-signature" | "public-analytics-token" | "public-widget" | "auth-credential-exchange"; type MutatingRoute = { method: MutatingMethod; path: string; }; type InventoryEntry = MutatingRoute & { protectionClass: ProtectionClass; note: string; }; const allowedProtectionClasses = new Set([ "jwt-auth", "service-token", "service-or-tenant-api-key", "optional-api-key-validation", "webhook-signature", "public-analytics-token", "public-widget", "auth-credential-exchange", ]); const expectedInventory: InventoryEntry[] = [ { method: "POST", path: "/v1/auth/login", protectionClass: "auth-credential-exchange", note: "Exchanges email/password credentials for a JWT after password verification.", }, { method: "POST", path: "/v1/auth/refresh-token", protectionClass: "jwt-auth", note: "Requires an existing bearer JWT before issuing a replacement JWT.", }, { method: "POST", path: "/v1/auth/logout", protectionClass: "jwt-auth", note: "Requires an existing bearer JWT before revoking the active API session and clearing the API cookie.", }, { method: "POST", path: "/v1/api-keys/validate", protectionClass: "optional-api-key-validation", note: "Public validation surface that classifies supplied API keys or service tokens without requiring browser CSRF state.", }, { method: "POST", path: "/v1/ai/upload", protectionClass: "service-token", note: "Writes uploaded content using configured S3-compatible storage credentials for the API runtime.", }, { method: "POST", path: "/v1/support/inbound-email", protectionClass: "webhook-signature", note: "Creates support tickets only after Mailjet webhook Basic-Auth credentials verify.", }, { method: "POST", path: "/v1/analytics/events", protectionClass: "public-analytics-token", note: "Public analytics write endpoint scoped to analytics payload identifiers rather than browser CSRF state.", }, { method: "POST", path: "/v1/analytics/page-views", protectionClass: "public-analytics-token", note: "Public analytics page-view write endpoint scoped to analytics payload identifiers rather than browser CSRF state.", }, { method: "POST", path: "/v1/events/webhooks/attempts/:id", protectionClass: "webhook-signature", note: "Queues a known webhook attempt id only after Mailjet webhook Basic-Auth credentials verify.", }, { method: "POST", path: "/v1/email-sequences/mailjet/webhooks", protectionClass: "webhook-signature", note: "Accepts sequence provider bounce events only after Mailjet webhook Basic-Auth credentials verify.", }, { method: "POST", path: "/v1/billing/stripe/webhooks", protectionClass: "webhook-signature", note: "Forwards a raw billing lifecycle webhook payload only with Stripe signing headers preserved.", }, { method: "POST", path: "/v1/maintenance/export-retention/enqueue", protectionClass: "service-token", note: "Queues export-artifact retention cleanup only after service-token authentication.", }, { method: "POST", path: "/v1/entities/:entity/rows", protectionClass: "service-or-tenant-api-key", note: "Creates entity rows only after x-api-key or bearer service-token authentication.", }, { method: "PATCH", path: "/v1/entities/:entity/rows/:rowId", protectionClass: "service-or-tenant-api-key", note: "Updates entity rows only after x-api-key or bearer service-token authentication.", }, { method: "DELETE", path: "/v1/entities/:entity/rows/:rowId", protectionClass: "service-or-tenant-api-key", note: "Deletes entity rows only after x-api-key or bearer service-token authentication.", }, { method: "POST", path: "/v1/relationships", protectionClass: "service-or-tenant-api-key", note: "Creates row relationships only after x-api-key or bearer service-token authentication.", }, { method: "DELETE", path: "/v1/relationships/:id", protectionClass: "service-or-tenant-api-key", note: "Deletes row relationships only after x-api-key or bearer service-token authentication.", }, { method: "POST", path: "/v1/workspaces/:tenantId/context/revalidate", protectionClass: "service-token", note: "Workspace context revalidation requires a service token before tenant and user context is refreshed.", }, { method: "POST", path: "/v1/workspaces/:tenantId/invites", protectionClass: "service-token", note: "Workspace invite creation requires a service token before a tenant-scoped invitation is issued.", }, { method: "POST", path: "/v1/workspaces/:tenantId/invites/:inviteId/accept", protectionClass: "service-token", note: "Workspace invite acceptance requires a service token before the invite is consumed for membership state.", }, { method: "POST", path: "/v1/workspaces/:tenantId/invites/:inviteId/revoke", protectionClass: "service-token", note: "Workspace invite revocation requires a service token before tenant-scoped invite state is revoked.", }, { method: "POST", path: "/v1/devices/register", protectionClass: "service-token", note: "Mobile push-token registration requires a service-token bearer credential carrying user identity; body userId + tenantId are reconciled against the authenticated principal downstream.", }, { method: "POST", path: "/v1/devices/revoke", protectionClass: "service-token", note: "Mobile push-token revocation requires a service-token bearer credential; revokes by token hash without echoing the raw token.", }, ]; function routeKey(route: MutatingRoute) { return `${route.method} ${route.path}`; } function discoverMutatingRoutes(routes: readonly ApiRoute[]): MutatingRoute[] { return routes .filter((route): route is ApiRoute & { method: MutatingMethod } => ["POST", "PATCH", "DELETE"].includes(route.method)) .filter((route) => route.path.startsWith("/v1")) .map(({ method, path }) => ({ method, path })); } describe("API mutating route CSRF/auth inventory", () => { let discoveredRoutes: MutatingRoute[]; beforeAll(async () => { const scanResult = await scanApiRoutesFromFile(join(dirname(fileURLToPath(import.meta.url)), "app.ts")); discoveredRoutes = discoverMutatingRoutes(scanResult.routes); }); test("classifies every mutating v1 route and has no stale inventory entries", () => { const discoveredKeys = new Set(discoveredRoutes.map(routeKey)); const inventoryKeys = new Set(expectedInventory.map(routeKey)); const missingFromInventory = discoveredRoutes.map(routeKey).filter((key) => !inventoryKeys.has(key)); const staleInventoryEntries = expectedInventory.map(routeKey).filter((key) => !discoveredKeys.has(key)); expect(missingFromInventory, `Unclassified mutating API routes:\n${missingFromInventory.join("\n")}`).toEqual([]); expect(staleInventoryEntries, `Stale mutating API inventory entries:\n${staleInventoryEntries.join("\n")}`).toEqual([]); }); test("uses an allowed protection class and note for every inventory entry", () => { const malformedEntries = expectedInventory .filter((entry) => !allowedProtectionClasses.has(entry.protectionClass) || entry.note.trim().length === 0) .map((entry) => `${routeKey(entry)} class=${entry.protectionClass} note=${JSON.stringify(entry.note)}`); expect(malformedEntries, `Invalid mutating API inventory entries:\n${malformedEntries.join("\n")}`).toEqual([]); }); });