217 lines
7.9 KiB
TypeScript
217 lines
7.9 KiB
TypeScript
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<ProtectionClass>([
|
|
"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([]);
|
|
});
|
|
});
|