Files
HiveGrid-Wireframes/hivegrid-cloud/apps/api/src/csrf-inventory.test.ts
T

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([]);
});
});