CI / verify (pull_request) Successful in 1m2s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / standalone-module (pull_request) Successful in 26s
CI / image-build (pull_request) Successful in 28s
CI / deploy-smoke (same-origin) (pull_request) Successful in 43s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
@ekklesia/contracts still emitted a type-only import of drizzle-orm/node-postgres for the legacy ModuleDatabase facade, so a standalone module type-checked only because skipLibCheck hid it. - ModuleDatabase, LegacyModuleRuntimeServices and AnyModuleRuntimeServices move to core/src/modules/database.ts, beside the facade Core builds; the facade itself is unchanged. - contracts' runtime, route, and services generics no longer name an ORM: the database type is a plain parameter defaulting to the ABI v1 executor, and the legacy definition's database is opaque until Core specializes it. Core and apps/api name the legacy types from Core. - contracts drops its drizzle-orm optional peer and devDependency; Drizzle stays public only through @ekklesia/module-database/drizzle. - The facade's type-level test moves from contracts to Core. - Regression proof: the standalone fixture type-checks with skipLibCheck: false against the packed tarballs, with no drizzle-orm installed (the smoke asserts both). Against the previous contracts tarball the same check fails with TS2307 on drizzle-orm/node-postgres. Co-Authored-By: Claude Opus 5.5 <[email protected]>
2047 lines
74 KiB
TypeScript
2047 lines
74 KiB
TypeScript
import {
|
|
ANONYMOUS_PRINCIPAL,
|
|
createAuthenticatedPrincipal,
|
|
type ModuleRouteHandler,
|
|
type ModuleRouteHandlerContext,
|
|
moduleRoutesOverlap,
|
|
type PermissionId,
|
|
parsePermissionId,
|
|
type RegisteredModuleHttpRoute,
|
|
} from "@ekklesia/contracts";
|
|
import {
|
|
type AuthorizationEvaluator,
|
|
type AuthorizationStore,
|
|
createPermissionRegistry,
|
|
type PermissionRegistry,
|
|
} from "@ekklesia/core/authorization";
|
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
|
import {
|
|
createModuleUiPublicationRegistry,
|
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
|
type LegacyModuleRuntimeServices,
|
|
type ModuleDatabase,
|
|
type ModuleRuntimeServicesLookup,
|
|
} from "@ekklesia/core/modules";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { createApiApp } from "../src/app.js";
|
|
import {
|
|
canonicalizeVaryHeader,
|
|
ModuleRouteConfigurationError,
|
|
mergedVaryHeader,
|
|
} from "../src/module-http-routes.js";
|
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
|
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
|
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
|
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
|
import { readyHealth } from "./support/ready-health.js";
|
|
|
|
const trustedOrigin = "https://dashboard.example.test";
|
|
const untrustedOrigin = "https://evil.example.test";
|
|
const readPermission = parsePermissionId("calendar.events.read");
|
|
const writePermission = parsePermissionId("calendar.events.write");
|
|
|
|
function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): EkklesiaIdentityRuntime {
|
|
return {
|
|
handleAuthRequest: vi.fn(),
|
|
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function fakeStore(): AuthorizationStore {
|
|
return {
|
|
createGrant: vi.fn(),
|
|
grantPlatformOwner: vi.fn(),
|
|
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
|
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
|
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
|
revokeGrant: vi.fn(),
|
|
revokePlatformOwner: vi.fn(),
|
|
};
|
|
}
|
|
|
|
/** Defaults `maxBodyBytes` and `bodyMode` for tests that are not themselves exercising the
|
|
* body-size contract, so the many pre-existing routing/authorization/CORS scenarios below do not
|
|
* each need to restate an unrelated body-size choice; the dedicated "module route body limits"
|
|
* suite further down overrides them explicitly to prove the contract itself. */
|
|
function moduleRoute(
|
|
fields: Omit<RegisteredModuleHttpRoute, "bodyMode" | "maxBodyBytes"> & {
|
|
bodyMode?: RegisteredModuleHttpRoute["bodyMode"];
|
|
maxBodyBytes?: number;
|
|
},
|
|
): RegisteredModuleHttpRoute {
|
|
return Object.freeze({ bodyMode: "buffered", maxBodyBytes: 1024, ...fields });
|
|
}
|
|
|
|
/** A small typed fake `LegacyModuleRuntimeServices` — these tests exercise route mounting,
|
|
* authorization, and response sanitization, never real database behavior, so no PostgreSQL is
|
|
* required. Each `database` method stays a distinct spy so a test can assert none of them were
|
|
* ever called (denied, trusted-origin-rejected, and `OPTIONS` requests never reach a handler). */
|
|
function fakeModuleServices(): LegacyModuleRuntimeServices {
|
|
const database = Object.freeze({
|
|
delete: vi.fn(),
|
|
execute: vi.fn(),
|
|
insert: vi.fn(),
|
|
select: vi.fn(),
|
|
selectDistinct: vi.fn(),
|
|
selectDistinctOn: vi.fn(),
|
|
transaction: vi.fn(),
|
|
update: vi.fn(),
|
|
}) as unknown as ModuleDatabase;
|
|
return Object.freeze({ database });
|
|
}
|
|
|
|
function expectDatabaseFakeUntouched(services: LegacyModuleRuntimeServices): void {
|
|
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(services.database.selectDistinct)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(services.database.selectDistinctOn)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(services.database.insert)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(services.database.update)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(services.database.delete)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(services.database.execute)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(services.database.transaction)).not.toHaveBeenCalled();
|
|
}
|
|
|
|
function buildModuleApp(options: {
|
|
readonly evaluator?: AuthorizationEvaluator;
|
|
readonly registry: PermissionRegistry;
|
|
readonly routes: readonly RegisteredModuleHttpRoute[];
|
|
readonly runtime?: EkklesiaIdentityRuntime;
|
|
readonly services?: LegacyModuleRuntimeServices | ModuleRuntimeServicesLookup;
|
|
readonly trustedOrigins?: readonly string[];
|
|
}) {
|
|
const evaluator = options.evaluator ?? {
|
|
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
|
};
|
|
const runtime = options.runtime ?? fakeRuntime();
|
|
const services = options.services ?? fakeModuleServices();
|
|
const app = createApiApp({
|
|
authorization: { evaluator, registry: options.registry, store: fakeStore() },
|
|
moduleRoutes: options.routes,
|
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
|
moduleServices: services,
|
|
moduleUi: createModuleUiPublicationRegistry([]),
|
|
moduleLifecycle: emptyModuleLifecycle(),
|
|
moduleConfiguration: emptyModuleConfiguration(),
|
|
moduleStorage: emptyModuleStorage(),
|
|
dashboard: createInMemoryDashboardStore(),
|
|
dashboardHostAssets: testDashboardHostAssets(),
|
|
health: readyHealth(),
|
|
runtime,
|
|
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
|
|
});
|
|
return { app, evaluator, runtime, services };
|
|
}
|
|
|
|
function permissionRegistry(
|
|
declarations: ReadonlyArray<{
|
|
readonly id: PermissionId;
|
|
readonly baseline: "authenticated" | "public" | "restricted";
|
|
}>,
|
|
): PermissionRegistry {
|
|
return createPermissionRegistry(
|
|
declarations.map((declaration) => ({ ...declaration, description: "Test permission." })),
|
|
);
|
|
}
|
|
|
|
describe("module HTTP route mounting", () => {
|
|
it("selects the owning module's stable service entry for its handler", async () => {
|
|
const calendar = fakeModuleServices();
|
|
const people = fakeModuleServices();
|
|
const services: ModuleRuntimeServicesLookup = new Map([
|
|
["calendar", calendar],
|
|
["people", people],
|
|
]);
|
|
let received: unknown;
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler: (_request, context) => {
|
|
received = context.services;
|
|
return Response.json({ ok: true });
|
|
},
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/services",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
services,
|
|
});
|
|
|
|
expect((await app.request("/api/v1/modules/calendar/services")).status).toBe(200);
|
|
expect(received).toBe(calendar);
|
|
expect(received).not.toBe(people);
|
|
});
|
|
|
|
it('mounts a "/" route at exactly the module root, never with an added trailing slash', async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => new Response("root", { status: 200 }));
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const rootResponse = await app.request("/api/v1/modules/calendar");
|
|
expect(rootResponse.status).toBe(200);
|
|
expect(await rootResponse.text()).toBe("root");
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
|
|
const trailingSlashResponse = await app.request("/api/v1/modules/calendar/");
|
|
expect(trailingSlashResponse.status).toBe(404);
|
|
});
|
|
|
|
it("mounts static and nested paths directly under the module prefix", async () => {
|
|
const staticHandler = vi.fn<ModuleRouteHandler>(async () => Response.json({ kind: "static" }));
|
|
const nestedHandler = vi.fn<ModuleRouteHandler>(async () => Response.json({ kind: "nested" }));
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler: staticHandler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
moduleRoute({
|
|
handler: nestedHandler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events/upcoming/soon",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
expect(await (await app.request("/api/v1/modules/calendar/events")).json()).toEqual({
|
|
kind: "static",
|
|
});
|
|
expect(
|
|
await (await app.request("/api/v1/modules/calendar/events/upcoming/soon")).json(),
|
|
).toEqual({ kind: "nested" });
|
|
});
|
|
|
|
it("resolves a same-depth static path and its parameterized sibling by published route order, without silently reordering them", async () => {
|
|
// Hono's router matches overlapping patterns in registration order, not by specificity —
|
|
// mounting must therefore preserve `StartedModuleRuntime.routes`' exact publication order
|
|
// rather than sorting it, or a module author's more-specific-first declaration would silently
|
|
// stop taking precedence. The static route is declared first here, matching how a module
|
|
// author would order these two routes to get the static path resolved on its own.
|
|
const staticHandler = vi.fn<ModuleRouteHandler>(async () => Response.json({ kind: "static" }));
|
|
const paramHandler = vi.fn<ModuleRouteHandler>(async (_request, context) =>
|
|
Response.json({ id: context.params.eventId, kind: "param" }),
|
|
);
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler: staticHandler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events/upcoming",
|
|
permission: readPermission,
|
|
}),
|
|
moduleRoute({
|
|
handler: paramHandler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events/:eventId",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
expect(await (await app.request("/api/v1/modules/calendar/events/upcoming")).json()).toEqual({
|
|
kind: "static",
|
|
});
|
|
expect(staticHandler).toHaveBeenCalledTimes(1);
|
|
expect(paramHandler).not.toHaveBeenCalled();
|
|
|
|
expect(await (await app.request("/api/v1/modules/calendar/events/other-id")).json()).toEqual({
|
|
id: "other-id",
|
|
kind: "param",
|
|
});
|
|
expect(paramHandler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("mounts every supported method", async () => {
|
|
const methods = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
|
|
const permissions = new Map(
|
|
methods.map((method) => [method, parsePermissionId(`multi.${method.toLowerCase()}.action`)]),
|
|
);
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry(
|
|
methods.map((method) => ({
|
|
baseline: "public" as const,
|
|
id: permissions.get(method) as PermissionId,
|
|
})),
|
|
),
|
|
routes: methods.map((method) =>
|
|
moduleRoute({
|
|
handler: async () => Response.json({ method }),
|
|
method,
|
|
moduleId: "multi",
|
|
path: "/action",
|
|
permission: permissions.get(method) as PermissionId,
|
|
}),
|
|
),
|
|
});
|
|
|
|
for (const method of methods) {
|
|
const response = await app.request("/api/v1/modules/multi/action", {
|
|
headers: method === "GET" ? {} : { origin: trustedOrigin },
|
|
method,
|
|
});
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toEqual({ method });
|
|
}
|
|
});
|
|
|
|
it("keeps two modules' identical local method/path routes independent", async () => {
|
|
const alphaPermission = parsePermissionId("alpha.items.list");
|
|
const betaPermission = parsePermissionId("beta.items.list");
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([
|
|
{ id: alphaPermission, baseline: "public" },
|
|
{ id: betaPermission, baseline: "public" },
|
|
]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler: async () => Response.json({ moduleId: "alpha" }),
|
|
method: "GET",
|
|
moduleId: "alpha",
|
|
path: "/items",
|
|
permission: alphaPermission,
|
|
}),
|
|
moduleRoute({
|
|
handler: async () => Response.json({ moduleId: "beta" }),
|
|
method: "GET",
|
|
moduleId: "beta",
|
|
path: "/items",
|
|
permission: betaPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
expect(await (await app.request("/api/v1/modules/alpha/items")).json()).toEqual({
|
|
moduleId: "alpha",
|
|
});
|
|
expect(await (await app.request("/api/v1/modules/beta/items")).json()).toEqual({
|
|
moduleId: "beta",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("module route handler adaptation", () => {
|
|
it("adapts the request into a raw Fetch Request and a frozen context with decoded parameters", async () => {
|
|
let capturedRequest: Request | undefined;
|
|
let capturedContext: ModuleRouteHandlerContext | undefined;
|
|
const handler: ModuleRouteHandler = async (request, context) => {
|
|
capturedRequest = request;
|
|
capturedContext = context;
|
|
const body = await request.text();
|
|
return Response.json({ body, url: request.url });
|
|
};
|
|
const runtime = fakeRuntime({
|
|
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_1")),
|
|
});
|
|
const services = fakeModuleServices();
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "POST",
|
|
moduleId: "calendar",
|
|
path: "/events/:eventId/rsvp",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
runtime,
|
|
services,
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true", {
|
|
body: "hello",
|
|
headers: { origin: trustedOrigin, "x-custom": "abc" },
|
|
method: "POST",
|
|
});
|
|
const body = (await response.json()) as { body: string; url: string };
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(body.body).toBe("hello");
|
|
expect(body.url).toContain("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true");
|
|
expect(capturedRequest).toBeInstanceOf(Request);
|
|
expect(capturedRequest?.headers.get("x-custom")).toBe("abc");
|
|
// Exactly config, moduleId, params, principal, services, storage — and nothing else. `config`
|
|
// is this module's own frozen configuration snapshot (ADR 0029) and `storage` its own scoped
|
|
// object storage (ADR 0030); a Hono Context, the authorization store or evaluator, raw
|
|
// PostgreSQL, the storage provider, an S3 endpoint or credential, platform configuration, and
|
|
// another module's configuration or storage all remain unreachable, which is what this
|
|
// exact-shape assertion exists to keep true.
|
|
expect(Object.keys(capturedContext ?? {}).sort()).toEqual([
|
|
"config",
|
|
"moduleId",
|
|
"params",
|
|
"principal",
|
|
"realtime",
|
|
"services",
|
|
"storage",
|
|
]);
|
|
expect(capturedContext).toMatchObject({
|
|
moduleId: "calendar",
|
|
params: { eventId: "evt-1" },
|
|
principal: { kind: "authenticated", userId: "user_1" },
|
|
});
|
|
// Reference-identical to the exact services object the caller passed in — never a fresh one
|
|
// built per request.
|
|
expect(capturedContext?.services).toBe(services);
|
|
expect(Object.isFrozen(capturedContext)).toBe(true);
|
|
expect(Object.isFrozen(capturedContext?.params)).toBe(true);
|
|
expect(Object.isFrozen(capturedContext?.services)).toBe(true);
|
|
expect(Object.isFrozen(capturedContext?.config)).toBe(true);
|
|
// This module declared no configuration, so its snapshot is empty rather than absent — module
|
|
// code never has to branch on whether the platform supplied one.
|
|
expect(capturedContext?.config.keys()).toEqual([]);
|
|
// Likewise storage: this module declared no buckets, so it gets a frozen facade that refuses
|
|
// every one rather than `undefined`.
|
|
expect(Object.isFrozen(capturedContext?.storage)).toBe(true);
|
|
expect(capturedContext?.storage.buckets()).toEqual([]);
|
|
});
|
|
|
|
it("gives static and root routes a frozen, empty params object", async () => {
|
|
let rootContext: ModuleRouteHandlerContext | undefined;
|
|
let staticContext: ModuleRouteHandlerContext | undefined;
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler: async (_request, context) => {
|
|
rootContext = context;
|
|
return Response.json({ ok: true });
|
|
},
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/",
|
|
permission: readPermission,
|
|
}),
|
|
moduleRoute({
|
|
handler: async (_request, context) => {
|
|
staticContext = context;
|
|
return Response.json({ ok: true });
|
|
},
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
await app.request("/api/v1/modules/calendar");
|
|
await app.request("/api/v1/modules/calendar/events");
|
|
|
|
expect(rootContext?.params).toEqual({});
|
|
expect(Object.isFrozen(rootContext?.params)).toBe(true);
|
|
expect(staticContext?.params).toEqual({});
|
|
expect(Object.isFrozen(staticContext?.params)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("module route authorization", () => {
|
|
it("uses the registry's exact baseline for public, authenticated, and restricted module permissions", async () => {
|
|
const publicPermission = parsePermissionId("calendar.events.list");
|
|
const authenticatedPermission = parsePermissionId("calendar.events.subscribe");
|
|
const restrictedPermission = parsePermissionId("calendar.events.read");
|
|
const evaluator: AuthorizationEvaluator = {
|
|
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
|
};
|
|
const { app } = buildModuleApp({
|
|
evaluator,
|
|
registry: permissionRegistry([
|
|
{ id: publicPermission, baseline: "public" },
|
|
{ id: authenticatedPermission, baseline: "authenticated" },
|
|
{ id: restrictedPermission, baseline: "restricted" },
|
|
]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler: async () => Response.json({ ok: true }),
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/public",
|
|
permission: publicPermission,
|
|
}),
|
|
moduleRoute({
|
|
handler: async () => Response.json({ ok: true }),
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/authenticated",
|
|
permission: authenticatedPermission,
|
|
}),
|
|
moduleRoute({
|
|
handler: async () => Response.json({ ok: true }),
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/restricted",
|
|
permission: restrictedPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
await app.request("/api/v1/modules/calendar/public");
|
|
await app.request("/api/v1/modules/calendar/authenticated");
|
|
await app.request("/api/v1/modules/calendar/restricted");
|
|
|
|
expect(evaluator.authorize).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.objectContaining({ baseline: "public", permission: publicPermission }),
|
|
);
|
|
expect(evaluator.authorize).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.objectContaining({ baseline: "authenticated", permission: authenticatedPermission }),
|
|
);
|
|
expect(evaluator.authorize).toHaveBeenNthCalledWith(
|
|
3,
|
|
expect.objectContaining({ baseline: "restricted", permission: restrictedPermission }),
|
|
);
|
|
});
|
|
|
|
it("does not invoke the handler or touch the module database when authorization denies", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const evaluator: AuthorizationEvaluator = {
|
|
authorize: vi.fn().mockResolvedValue({ allowed: false }),
|
|
};
|
|
const { app, services } = buildModuleApp({
|
|
evaluator,
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events");
|
|
expect(response.status).toBe(403);
|
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
|
expect(handler).not.toHaveBeenCalled();
|
|
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices);
|
|
});
|
|
|
|
it("does not invoke the handler when principal resolution fails", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const runtime = fakeRuntime({
|
|
resolvePrincipal: vi.fn().mockRejectedValue(new Error("connection terminated")),
|
|
});
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
runtime,
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events");
|
|
expect(response.status).toBe(503);
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not invoke the handler when the evaluator itself fails", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const evaluator: AuthorizationEvaluator = {
|
|
authorize: vi.fn().mockRejectedValue(new Error("connection terminated")),
|
|
};
|
|
const { app } = buildModuleApp({
|
|
evaluator,
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events");
|
|
expect(response.status).toBe(503);
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails app construction safely when a route names a permission the registry does not register", () => {
|
|
expect(() =>
|
|
buildModuleApp({
|
|
registry: permissionRegistry([]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler: vi.fn(),
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
}),
|
|
).toThrow(ModuleRouteConfigurationError);
|
|
});
|
|
|
|
it("never leaks the offending permission, route path, or module id in the configuration error", () => {
|
|
let captured: unknown;
|
|
try {
|
|
buildModuleApp({
|
|
registry: permissionRegistry([]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler: vi.fn(),
|
|
method: "GET",
|
|
moduleId: "secret-module",
|
|
path: "/secret-path",
|
|
permission: parsePermissionId("secret-module.secret.permission"),
|
|
}),
|
|
],
|
|
});
|
|
} catch (error) {
|
|
captured = error;
|
|
}
|
|
|
|
expect(captured).toBeInstanceOf(ModuleRouteConfigurationError);
|
|
const text = `${(captured as Error).message}\n${(captured as Error).stack}`;
|
|
expect(text).not.toContain("secret-module");
|
|
expect(text).not.toContain("secret-path");
|
|
expect(text).not.toContain("secret.permission");
|
|
});
|
|
});
|
|
|
|
describe("module route trusted-origin enforcement", () => {
|
|
it("rejects a state-changing module route with no or untrusted Origin before authorization runs", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const evaluator: AuthorizationEvaluator = {
|
|
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
|
};
|
|
const methods = ["POST", "PUT", "PATCH", "DELETE"] as const;
|
|
const { app, services } = buildModuleApp({
|
|
evaluator,
|
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
|
routes: methods.map((method) =>
|
|
moduleRoute({
|
|
handler,
|
|
method,
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: writePermission,
|
|
}),
|
|
),
|
|
});
|
|
|
|
for (const method of methods) {
|
|
const noOriginResponse = await app.request("/api/v1/modules/calendar/events", { method });
|
|
expect(noOriginResponse.status).toBe(403);
|
|
expect(await noOriginResponse.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
|
|
|
const untrustedResponse = await app.request("/api/v1/modules/calendar/events", {
|
|
headers: { origin: untrustedOrigin },
|
|
method,
|
|
});
|
|
expect(untrustedResponse.status).toBe(403);
|
|
}
|
|
expect(handler).not.toHaveBeenCalled();
|
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
|
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices);
|
|
});
|
|
|
|
it("allows a trusted-origin state-changing request through to the handler", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "POST",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: writePermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", {
|
|
headers: { origin: trustedOrigin },
|
|
method: "POST",
|
|
});
|
|
expect(response.status).toBe(200);
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not require an Origin header for a GET module route", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events");
|
|
expect(response.status).toBe(200);
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("module route CORS", () => {
|
|
it("advertises GET, POST, PUT, PATCH, and DELETE on preflight", async () => {
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler: vi.fn(),
|
|
method: "PUT",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: writePermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", {
|
|
headers: { "access-control-request-method": "PUT", origin: trustedOrigin },
|
|
method: "OPTIONS",
|
|
});
|
|
|
|
expect(response.status).toBe(204);
|
|
const allowedMethods = response.headers.get("access-control-allow-methods") ?? "";
|
|
for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE"]) {
|
|
expect(allowedMethods).toContain(method);
|
|
}
|
|
});
|
|
|
|
it("keeps the OPTIONS preflight's own Vary canonical and never force-injects Cookie, exercising the returned-Response path of the CORS-Vary fix-up", async () => {
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler: vi.fn(),
|
|
method: "PUT",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: writePermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", {
|
|
headers: { "access-control-request-method": "PUT", origin: trustedOrigin },
|
|
method: "OPTIONS",
|
|
});
|
|
|
|
const vary = response.headers.get("vary") ?? "";
|
|
const tokens = vary.split(",").map((token) => token.trim());
|
|
// Hono's own cors() unconditionally appends "Access-Control-Request-Headers" here since
|
|
// allowHeaders is non-empty; that is legitimate and must survive canonicalization. Cookie must
|
|
// never be force-injected onto a preflight response, which has no cookie/personalization
|
|
// concept at all — unlike a module response, which always forces it (see mergedVaryHeader).
|
|
expect(tokens).toEqual(["Origin", "Access-Control-Request-Headers"]);
|
|
expect(tokens.filter((token) => token.toLowerCase() === "origin")).toHaveLength(1);
|
|
});
|
|
|
|
it("never invokes authorization, the handler, or the module database for an OPTIONS preflight", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const evaluator: AuthorizationEvaluator = {
|
|
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
|
};
|
|
const { app, services } = buildModuleApp({
|
|
evaluator,
|
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "POST",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: writePermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
await app.request("/api/v1/modules/calendar/events", {
|
|
headers: { "access-control-request-method": "POST", origin: trustedOrigin },
|
|
method: "OPTIONS",
|
|
});
|
|
|
|
expect(handler).not.toHaveBeenCalled();
|
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
|
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices);
|
|
});
|
|
|
|
it("never reflects an untrusted origin for a module route", async () => {
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler: async () => Response.json({ ok: true }),
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", {
|
|
headers: { origin: untrustedOrigin },
|
|
});
|
|
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("module route handler failure sanitization", () => {
|
|
it("returns a fixed sanitized 500 when the handler throws, never leaking the caught message", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => {
|
|
throw new Error("password=hunter2 host=db.internal.example");
|
|
});
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events");
|
|
const text = await response.text();
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(JSON.parse(text)).toEqual({ code: "MODULE_HANDLER_FAILED" });
|
|
expect(text).not.toContain("hunter2");
|
|
expect(text).not.toContain("db.internal.example");
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
});
|
|
|
|
it("returns the same fixed sanitized 500 when the handler rejects", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(() => Promise.reject(new Error("rejected")));
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events");
|
|
expect(response.status).toBe(500);
|
|
expect(await response.json()).toEqual({ code: "MODULE_HANDLER_FAILED" });
|
|
});
|
|
|
|
it("returns the same fixed sanitized 500 when the handler returns a non-Response value", async () => {
|
|
const handler = vi.fn(async () => ({ ok: true }) as unknown as Response);
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events");
|
|
expect(response.status).toBe(500);
|
|
expect(await response.json()).toEqual({ code: "MODULE_HANDLER_FAILED" });
|
|
});
|
|
|
|
it("passes through a valid module-owned 4xx/5xx Response unchanged", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () =>
|
|
Response.json({ code: "MODULE_NOT_FOUND" }, { status: 404 }),
|
|
);
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events");
|
|
expect(response.status).toBe(404);
|
|
expect(await response.json()).toEqual({ code: "MODULE_NOT_FOUND" });
|
|
});
|
|
});
|
|
|
|
describe("module route response header policy", () => {
|
|
it("strips module-supplied CORS-control headers so the platform policy remains authoritative", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(
|
|
async () =>
|
|
new Response("body", {
|
|
headers: {
|
|
"access-control-allow-credentials": "true",
|
|
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE",
|
|
"access-control-allow-origin": "*",
|
|
"access-control-expose-headers": "X-Secret",
|
|
},
|
|
status: 200,
|
|
}),
|
|
);
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", {
|
|
headers: { origin: untrustedOrigin },
|
|
});
|
|
expect(response.headers.get("access-control-allow-origin")).not.toBe("*");
|
|
expect(response.headers.get("access-control-expose-headers")).toBeNull();
|
|
});
|
|
|
|
it("forces Cache-Control: no-store and Vary: Origin, Cookie on a successful module response", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(
|
|
async () => new Response("body", { headers: { "content-type": "text/plain" }, status: 200 }),
|
|
);
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events");
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
const vary = response.headers.get("vary") ?? "";
|
|
expect(vary).toContain("Origin");
|
|
expect(vary).toContain("Cookie");
|
|
});
|
|
|
|
it("preserves an additional legitimate Vary value from the handler alongside the forced ones", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(
|
|
async () => new Response("body", { headers: { Vary: "Accept-Language" }, status: 200 }),
|
|
);
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events");
|
|
const vary = response.headers.get("vary") ?? "";
|
|
expect(vary).toContain("Accept-Language");
|
|
expect(vary).toContain("Origin");
|
|
expect(vary).toContain("Cookie");
|
|
});
|
|
|
|
// The following exercise `mergedVaryHeader` directly rather than through the full app: the
|
|
// outer, pre-existing `cors()` middleware (`apps/api/src/app.ts`, unchanged by module routes)
|
|
// unconditionally appends its own "Vary: Origin" to every response with a dynamic-function
|
|
// origin, on top of whatever this module already set — a real Hono behavior, present on every
|
|
// route including `/api/v1/principal` since Phase 4B, not something module routing introduces
|
|
// or can suppress. Testing the merge function directly proves this module's own logic is
|
|
// correct in isolation, without coupling the assertion to that separate middleware's behavior.
|
|
describe("mergedVaryHeader", () => {
|
|
it("collapses an existing * to a bare * instead of the invalid *, Origin, Cookie", () => {
|
|
// RFC 9110 §12.5.5: a Vary field-value of "*" must never be combined with any other value.
|
|
expect(mergedVaryHeader("*")).toBe("*");
|
|
expect(mergedVaryHeader("*, Accept-Language")).toBe("*");
|
|
expect(mergedVaryHeader(" * ")).toBe("*");
|
|
});
|
|
|
|
it("merges case-insensitively so a differently-cased Origin/Cookie never appears as a redundant duplicate", () => {
|
|
expect(mergedVaryHeader("origin, Accept-Language, COOKIE, origin")).toBe(
|
|
"Origin, Accept-Language, Cookie",
|
|
);
|
|
});
|
|
|
|
it("forces canonical Origin/Cookie casing even when absent, and tolerates irregular whitespace", () => {
|
|
expect(mergedVaryHeader(null)).toBe("Origin, Cookie");
|
|
expect(mergedVaryHeader("")).toBe("Origin, Cookie");
|
|
expect(mergedVaryHeader(" Accept-Language , ORIGIN ")).toBe(
|
|
"Accept-Language, Origin, Cookie",
|
|
);
|
|
});
|
|
|
|
it("de-duplicates a literal repeated token with identical casing", () => {
|
|
expect(mergedVaryHeader("Accept-Language, Accept-Language")).toBe(
|
|
"Accept-Language, Origin, Cookie",
|
|
);
|
|
});
|
|
});
|
|
|
|
// The generic app-level CORS-Vary fix-up (`apps/api/src/app.ts`) uses this instead of
|
|
// `mergedVaryHeader`: it must never inject a token (Origin, Cookie, or anything else) that was
|
|
// not already present, since it wraps every response through the shared CORS middleware —
|
|
// including an OPTIONS preflight, which has no Cookie/personalization concept at all.
|
|
describe("canonicalizeVaryHeader", () => {
|
|
it("collapses an existing * to a bare * instead of an invalid combination", () => {
|
|
expect(canonicalizeVaryHeader("*")).toBe("*");
|
|
expect(canonicalizeVaryHeader("Origin, *")).toBe("*");
|
|
});
|
|
|
|
it("de-duplicates case-insensitively without injecting Origin or Cookie", () => {
|
|
expect(canonicalizeVaryHeader("origin, Accept-Encoding, ORIGIN")).toBe(
|
|
"origin, Accept-Encoding",
|
|
);
|
|
});
|
|
|
|
it("never injects a token that was not already present", () => {
|
|
expect(canonicalizeVaryHeader("Accept-Encoding")).toBe("Accept-Encoding");
|
|
expect(canonicalizeVaryHeader("")).toBe("");
|
|
});
|
|
});
|
|
|
|
it("still forces Origin and Cookie into Vary end-to-end even when a handler supplies its own casing", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(
|
|
async () => new Response("body", { headers: { Vary: "origin" }, status: 200 }),
|
|
);
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events");
|
|
const vary = response.headers.get("vary") ?? "";
|
|
expect(vary).toContain("Origin");
|
|
expect(vary).toContain("Cookie");
|
|
});
|
|
|
|
it("does not read or buffer a streaming response body while sanitizing headers", async () => {
|
|
const chunks = ["first-chunk", "second-chunk"];
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => {
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
for (const chunk of chunks) {
|
|
controller.enqueue(new TextEncoder().encode(chunk));
|
|
}
|
|
controller.close();
|
|
},
|
|
});
|
|
return new Response(stream, { status: 200 });
|
|
});
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", {
|
|
headers: { origin: trustedOrigin },
|
|
});
|
|
expect(await response.text()).toBe(chunks.join(""));
|
|
});
|
|
|
|
// These two exercise the *final* response `app.fetch` actually returns — after Hono's own,
|
|
// separate `cors()` middleware (`apps/api/src/app.ts`) has also run and unconditionally appended
|
|
// its own "Vary: Origin" following every module response, on top of whatever this module's own
|
|
// sanitization already set. `sanitizeModuleResponse`'s merge alone cannot prove the wire-level
|
|
// result is well-formed, because cors's append happens strictly afterward, outside this file's
|
|
// code.
|
|
it("keeps the final response's Vary exactly \"*\" end-to-end, even after Hono's own CORS append", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(
|
|
async () => new Response("body", { headers: { Vary: "*" }, status: 200 }),
|
|
);
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", {
|
|
headers: { origin: trustedOrigin },
|
|
});
|
|
expect(response.headers.get("vary")).toBe("*");
|
|
});
|
|
|
|
it("collapses a differently-cased existing token with Hono's own appended Origin into one case-insensitive final token", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(
|
|
async () =>
|
|
new Response("body", { headers: { Vary: "origin, Accept-Encoding" }, status: 200 }),
|
|
);
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", {
|
|
headers: { origin: trustedOrigin },
|
|
});
|
|
const vary = response.headers.get("vary") ?? "";
|
|
const tokens = vary
|
|
.toLowerCase()
|
|
.split(",")
|
|
.map((token) => token.trim());
|
|
const originOccurrences = tokens.filter((token) => token === "origin").length;
|
|
expect(originOccurrences).toBe(1);
|
|
expect(tokens).toContain("accept-encoding");
|
|
expect(tokens).toContain("cookie");
|
|
});
|
|
});
|
|
|
|
describe("module route HEAD semantics", () => {
|
|
it("traverses the GET route's permission middleware and returns no body when authorized", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(
|
|
async () => new Response("body-text", { status: 200 }),
|
|
);
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", { method: "HEAD" });
|
|
expect(response.status).toBe(200);
|
|
expect(await response.text()).toBe("");
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("denies a HEAD request exactly like the GET route would, without invoking the handler", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(
|
|
async () => new Response("body-text", { status: 200 }),
|
|
);
|
|
const evaluator: AuthorizationEvaluator = {
|
|
authorize: vi.fn().mockResolvedValue({ allowed: false }),
|
|
};
|
|
const { app } = buildModuleApp({
|
|
evaluator,
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", { method: "HEAD" });
|
|
expect(response.status).toBe(403);
|
|
expect(await response.text()).toBe("");
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("module route body limits", () => {
|
|
it("rejects a non-empty declared body on a route declaring maxBodyBytes: 0", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
maxBodyBytes: 0,
|
|
method: "POST",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: writePermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", {
|
|
body: "a",
|
|
headers: { "content-length": "1", origin: trustedOrigin },
|
|
method: "POST",
|
|
});
|
|
|
|
expect(response.status).toBe(413);
|
|
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects a non-empty streamed body on a route declaring maxBodyBytes: 0", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
maxBodyBytes: 0,
|
|
method: "POST",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: writePermission,
|
|
}),
|
|
],
|
|
});
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode("a"));
|
|
controller.close();
|
|
},
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", {
|
|
body: stream,
|
|
duplex: "half",
|
|
headers: { origin: trustedOrigin },
|
|
method: "POST",
|
|
} as RequestInit);
|
|
|
|
expect(response.status).toBe(413);
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("accepts a genuinely empty body on a route declaring maxBodyBytes: 0", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
maxBodyBytes: 0,
|
|
method: "POST",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: writePermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", {
|
|
headers: { origin: trustedOrigin },
|
|
method: "POST",
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("accepts a body exactly at an explicit maxBodyBytes limit and rejects one byte over", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const limit = 32;
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
maxBodyBytes: limit,
|
|
method: "POST",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: writePermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const atLimit = "a".repeat(limit);
|
|
const atLimitResponse = await app.request("/api/v1/modules/calendar/events", {
|
|
body: atLimit,
|
|
headers: { "content-length": String(limit), origin: trustedOrigin },
|
|
method: "POST",
|
|
});
|
|
expect(atLimitResponse.status).toBe(200);
|
|
|
|
const overLimit = "a".repeat(limit + 1);
|
|
const overLimitResponse = await app.request("/api/v1/modules/calendar/events", {
|
|
body: overLimit,
|
|
headers: { "content-length": String(limit + 1), origin: trustedOrigin },
|
|
method: "POST",
|
|
});
|
|
expect(overLimitResponse.status).toBe(413);
|
|
expect(await overLimitResponse.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("rejects an oversized streamed body on a route with an explicit limit, with no declared Content-Length", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const limit = 64;
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
maxBodyBytes: limit,
|
|
method: "POST",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: writePermission,
|
|
}),
|
|
],
|
|
});
|
|
const chunk = new Uint8Array(16).fill(97);
|
|
let enqueued = 0;
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
pull(controller) {
|
|
if (enqueued > limit) {
|
|
controller.close();
|
|
return;
|
|
}
|
|
enqueued += chunk.length;
|
|
controller.enqueue(chunk);
|
|
},
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", {
|
|
body: stream,
|
|
duplex: "half",
|
|
headers: { origin: trustedOrigin },
|
|
method: "POST",
|
|
} as RequestInit);
|
|
|
|
expect(response.status).toBe(413);
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("enforces the body limit before authorization runs, so an oversized denied request never reaches the evaluator", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const evaluator: AuthorizationEvaluator = {
|
|
authorize: vi.fn().mockResolvedValue({ allowed: false }),
|
|
};
|
|
const { app } = buildModuleApp({
|
|
evaluator,
|
|
registry: permissionRegistry([{ id: writePermission, baseline: "restricted" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
maxBodyBytes: 8,
|
|
method: "POST",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: writePermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", {
|
|
body: "a".repeat(9),
|
|
headers: { "content-length": "9", origin: trustedOrigin },
|
|
method: "POST",
|
|
});
|
|
|
|
expect(response.status).toBe(413);
|
|
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("never reads an oversized body from an untrusted Origin: the trusted-origin rejection wins first", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
maxBodyBytes: 8,
|
|
method: "POST",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: writePermission,
|
|
}),
|
|
],
|
|
});
|
|
const chunk = new Uint8Array(16).fill(97);
|
|
let pullCount = 0;
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
pull(controller) {
|
|
pullCount += 1;
|
|
if (pullCount > 3) {
|
|
controller.close();
|
|
return;
|
|
}
|
|
controller.enqueue(chunk);
|
|
},
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", {
|
|
body: stream,
|
|
duplex: "half",
|
|
headers: { origin: untrustedOrigin },
|
|
method: "POST",
|
|
} as RequestInit);
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("applies the route's own declared limit to a GET route too", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
maxBodyBytes: 0,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events");
|
|
expect(response.status).toBe(200);
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("module routes and the rest of the API", () => {
|
|
it("does not invoke a handler for an unmatched module path", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/does-not-exist");
|
|
expect(response.status).toBe(404);
|
|
expect(await response.json()).toEqual({ code: "NOT_FOUND" });
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("never invokes a handler merely by constructing the app", () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("preserves existing API behavior with zero module routes", async () => {
|
|
const { app } = buildModuleApp({ registry: permissionRegistry([]), routes: [] });
|
|
const response = await app.request("/api/v1/principal");
|
|
expect(response.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Pass 4: the decoded-parameter boundary and the routing ambiguity that made it dangerous.
|
|
*
|
|
* These drive the pinned Hono matcher with real requests rather than asserting what a mock returns,
|
|
* because the whole point is what the real router actually hands a module handler.
|
|
*/
|
|
describe("module route parameters are decoded, untrusted input", () => {
|
|
function paramsApp() {
|
|
const seen: Array<Readonly<Record<string, string>>> = [];
|
|
const handler = vi.fn<ModuleRouteHandler>(async (_request, context) => {
|
|
seen.push(context.params);
|
|
return Response.json({ id: context.params.id });
|
|
});
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/items/:id",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
return { app, handler, seen };
|
|
}
|
|
|
|
it.each([
|
|
// A single encoded segment can carry characters the declaration grammar forbids outright.
|
|
["an encoded path separator and dot segments", "%2F..%2Fadmin", "/../admin"],
|
|
["an encoded backslash", "a%5Cb", "a\\b"],
|
|
["an embedded NUL", "a%00b", "a\u0000b"],
|
|
["encoded CR/LF", "a%0D%0Ab", "a\r\nb"],
|
|
["a percent sign", "100%25", "100%"],
|
|
["non-ASCII text", "%C3%A9v%C3%A9nement", "événement"],
|
|
["a statement separator", "1%3B%20drop", "1; drop"],
|
|
])("delivers %s decoded, exactly as the router produced it", async (_label, encoded, decoded) => {
|
|
const { app, seen } = paramsApp();
|
|
|
|
const response = await app.request(`/api/v1/modules/calendar/items/${encoded}`);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toEqual({ id: decoded });
|
|
// The declared path grammar (`/items/:id`) permits none of these characters — validating the
|
|
// *declaration* says nothing about what a request may send, which is exactly why a handler
|
|
// must validate the decoded value against its own domain grammar before using it.
|
|
expect(seen[0]?.id).toBe(decoded);
|
|
expect(Object.isFrozen(seen[0])).toBe(true);
|
|
});
|
|
|
|
it("keeps parameter delivery behind authorization", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => new Response("never"));
|
|
const { app, evaluator } = buildModuleApp({
|
|
evaluator: { authorize: vi.fn().mockResolvedValue({ allowed: false }) },
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/items/:id",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/items/%2F..%2Fadmin");
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(evaluator.authorize).toHaveBeenCalled();
|
|
// An unauthorized caller can never observe how the handler would treat a parameter, because
|
|
// the handler never runs.
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("lets a handler reject a decoded value against its own domain grammar", async () => {
|
|
// The platform supplies no generic parameter validator; this is the module's own check, which
|
|
// is exactly the documented contract.
|
|
const canonicalId = /^[0-9a-f]{8}$/u;
|
|
const handler = vi.fn<ModuleRouteHandler>(async (_request, context) => {
|
|
const id = context.params.id;
|
|
if (id === undefined || !canonicalId.test(id)) {
|
|
return Response.json({ code: "INVALID_REQUEST" }, { status: 400 });
|
|
}
|
|
return Response.json({ id });
|
|
});
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/items/:id",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
expect((await app.request("/api/v1/modules/calendar/items/deadbeef")).status).toBe(200);
|
|
const rejected = await app.request("/api/v1/modules/calendar/items/%2F..%2Fadmin");
|
|
expect(rejected.status).toBe(400);
|
|
expect(await rejected.json()).toEqual({ code: "INVALID_REQUEST" });
|
|
});
|
|
});
|
|
|
|
describe("module route overlap can never reach mounting", () => {
|
|
it("would otherwise let registration order choose the governing permission", async () => {
|
|
// Both orders are mounted directly here — bypassing the runtime rejection — to show precisely
|
|
// what `MODULE_ROUTE_OVERLAP` prevents: the same request reaching a different handler *and* a
|
|
// different permission depending only on the order the routes were registered in.
|
|
const createPermission = parsePermissionId("calendar.events.create");
|
|
const byId = vi.fn<ModuleRouteHandler>(async () => Response.json({ handler: "byId" }));
|
|
const create = vi.fn<ModuleRouteHandler>(async () => Response.json({ handler: "new" }));
|
|
const registry = permissionRegistry([
|
|
{ id: readPermission, baseline: "public" },
|
|
{ id: createPermission, baseline: "public" },
|
|
]);
|
|
const paramRoute = moduleRoute({
|
|
handler: byId,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events/:id",
|
|
permission: readPermission,
|
|
});
|
|
const staticRoute = moduleRoute({
|
|
handler: create,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events/new",
|
|
permission: createPermission,
|
|
});
|
|
|
|
const parameterFirst = buildModuleApp({ registry, routes: [paramRoute, staticRoute] });
|
|
const staticFirst = buildModuleApp({ registry, routes: [staticRoute, paramRoute] });
|
|
|
|
expect(
|
|
await (await parameterFirst.app.request("/api/v1/modules/calendar/events/new")).json(),
|
|
).toEqual({ handler: "byId" });
|
|
expect(
|
|
await (await staticFirst.app.request("/api/v1/modules/calendar/events/new")).json(),
|
|
).toEqual({ handler: "new" });
|
|
|
|
// Core rejects this pair during staged registration, so no such snapshot can ever be published
|
|
// to `mountModuleHttpRoutes` — see `core/tests/module-runtime.test.ts`.
|
|
expect(moduleRoutesOverlap(paramRoute, staticRoute)).toBe(true);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Explicit, bounded streaming uploads (ADR 0042).
|
|
*
|
|
* The distinction being proven throughout is *when* the platform knows a body is too large, and what
|
|
* it holds while finding out. In `buffered` mode the answer is "before the handler runs, holding at
|
|
* most the route's own declared allowance". In `streamed` mode it is "as the handler consumes it,
|
|
* holding nothing" — and the `413` still wins, even when the handler tried to answer anyway.
|
|
*/
|
|
describe("module route streamed body limits", () => {
|
|
const oneMebibyte = 1024 * 1024;
|
|
|
|
/** A body of `size` bytes delivered in realistic chunks, with no declared `Content-Length` — the
|
|
* chunked-transfer shape a browser `fetch` of a `ReadableStream` actually produces. */
|
|
function chunkedBody(size: number, chunkSize = 64 * 1024): ReadableStream<Uint8Array> {
|
|
let sent = 0;
|
|
return new ReadableStream<Uint8Array>({
|
|
pull(controller) {
|
|
if (sent >= size) {
|
|
controller.close();
|
|
return;
|
|
}
|
|
const next = Math.min(chunkSize, size - sent);
|
|
sent += next;
|
|
controller.enqueue(new Uint8Array(next).fill(97));
|
|
},
|
|
});
|
|
}
|
|
|
|
function streamingApp(options: {
|
|
readonly handler: ModuleRouteHandler;
|
|
readonly maxBodyBytes: number;
|
|
readonly evaluator?: AuthorizationEvaluator;
|
|
}) {
|
|
return buildModuleApp({
|
|
...(options.evaluator ? { evaluator: options.evaluator } : {}),
|
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
bodyMode: "streamed",
|
|
handler: options.handler,
|
|
maxBodyBytes: options.maxBodyBytes,
|
|
method: "POST",
|
|
moduleId: "calendar",
|
|
path: "/uploads",
|
|
permission: writePermission,
|
|
}),
|
|
],
|
|
});
|
|
}
|
|
|
|
/** Counts what the handler actually received, which is the only honest way to tell "streamed" from
|
|
* "buffered then handed over". */
|
|
async function countingHandler(received: { bytes: number }): Promise<ModuleRouteHandler> {
|
|
return async (request) => {
|
|
const body = request.body;
|
|
if (!body) {
|
|
return Response.json({ bytes: 0 });
|
|
}
|
|
const reader = body.getReader();
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) {
|
|
break;
|
|
}
|
|
received.bytes += value.byteLength;
|
|
}
|
|
return Response.json({ bytes: received.bytes });
|
|
};
|
|
}
|
|
|
|
it("accepts a body of exactly the declared limit", async () => {
|
|
const received = { bytes: 0 };
|
|
const { app } = streamingApp({
|
|
handler: await countingHandler(received),
|
|
maxBodyBytes: oneMebibyte,
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
|
body: chunkedBody(oneMebibyte),
|
|
duplex: "half",
|
|
headers: { origin: trustedOrigin },
|
|
method: "POST",
|
|
} as RequestInit);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toEqual({ bytes: oneMebibyte });
|
|
});
|
|
|
|
it("rejects a declared Content-Length over the limit before the handler runs", async () => {
|
|
const received = { bytes: 0 };
|
|
const counting = await countingHandler(received);
|
|
const handler = vi.fn<ModuleRouteHandler>(counting);
|
|
const { app } = streamingApp({ handler, maxBodyBytes: 1024 });
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
|
body: chunkedBody(2048, 16),
|
|
duplex: "half",
|
|
headers: { "content-length": String(1025), origin: trustedOrigin },
|
|
method: "POST",
|
|
} as RequestInit);
|
|
|
|
expect(response.status).toBe(413);
|
|
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
|
// The cheap rejection: the declared size alone decided it, so no handler work happened and no
|
|
// byte of the upload reached module code.
|
|
expect(handler).not.toHaveBeenCalled();
|
|
expect(received.bytes).toBe(0);
|
|
});
|
|
|
|
it("terminates a body whose actual size exceeds a smaller declared Content-Length", async () => {
|
|
// A misleading `Content-Length` buys nothing: the running byte count is what bounds the request,
|
|
// so a header claiming 8 bytes cannot smuggle a megabyte past the limit.
|
|
const received = { bytes: 0 };
|
|
const { app } = streamingApp({
|
|
handler: await countingHandler(received),
|
|
maxBodyBytes: 64,
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
|
body: chunkedBody(4096, 32),
|
|
duplex: "half",
|
|
headers: { "content-length": "8", origin: trustedOrigin },
|
|
method: "POST",
|
|
} as RequestInit);
|
|
|
|
expect(response.status).toBe(413);
|
|
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
|
// It stopped promptly rather than draining the whole upload first.
|
|
expect(received.bytes).toBeLessThanOrEqual(96);
|
|
});
|
|
|
|
it("answers 413 even when the handler swallowed the stream error and tried to respond", async () => {
|
|
// A limit a handler can swallow is not a limit.
|
|
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
|
|
try {
|
|
await request.arrayBuffer();
|
|
} catch {
|
|
// Deliberately ignored, exactly as a careless module might.
|
|
}
|
|
return Response.json({ ok: "handler won" });
|
|
});
|
|
const { app } = streamingApp({ handler, maxBodyBytes: 64 });
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
|
body: chunkedBody(4096, 32),
|
|
duplex: "half",
|
|
headers: { origin: trustedOrigin },
|
|
method: "POST",
|
|
} as RequestInit);
|
|
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
expect(response.status).toBe(413);
|
|
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
|
});
|
|
|
|
it("never buffers the body: the handler sees the first bytes before the last are sent", async () => {
|
|
let released: (() => void) | undefined;
|
|
const finished = new Promise<void>((resolve) => {
|
|
released = resolve;
|
|
});
|
|
let firstChunkSeen = false;
|
|
let secondChunkQueued = false;
|
|
const body = new ReadableStream<Uint8Array>({
|
|
async pull(controller) {
|
|
if (!secondChunkQueued) {
|
|
secondChunkQueued = true;
|
|
controller.enqueue(new Uint8Array(16).fill(97));
|
|
return;
|
|
}
|
|
// Only produce the rest once the handler has demonstrably consumed the first chunk. A
|
|
// buffering platform would deadlock here rather than reach the assertion below.
|
|
await finished;
|
|
controller.close();
|
|
},
|
|
});
|
|
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
|
|
const reader = (request.body as ReadableStream<Uint8Array>).getReader();
|
|
const first = await reader.read();
|
|
firstChunkSeen = !first.done;
|
|
released?.();
|
|
for (;;) {
|
|
const next = await reader.read();
|
|
if (next.done) {
|
|
break;
|
|
}
|
|
}
|
|
return Response.json({ ok: true });
|
|
});
|
|
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
|
body,
|
|
duplex: "half",
|
|
headers: { origin: trustedOrigin },
|
|
method: "POST",
|
|
} as RequestInit);
|
|
|
|
expect(firstChunkSeen).toBe(true);
|
|
expect(response.status).toBe(200);
|
|
});
|
|
|
|
it("releases an aborted upload without taking the platform down", async () => {
|
|
const aborted = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(new Uint8Array(16).fill(97));
|
|
controller.error(new Error("client went away"));
|
|
},
|
|
});
|
|
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
|
|
await request.arrayBuffer();
|
|
return Response.json({ ok: true });
|
|
});
|
|
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
|
body: aborted,
|
|
duplex: "half",
|
|
headers: { origin: trustedOrigin },
|
|
method: "POST",
|
|
} as RequestInit);
|
|
|
|
// An abort is a failed request, never an unhandled rejection or a 413: nothing exceeded a limit.
|
|
expect(response.status).toBe(500);
|
|
expect(await response.json()).toEqual({ code: "MODULE_HANDLER_FAILED" });
|
|
});
|
|
|
|
it("releases a body the handler never read", async () => {
|
|
let cancelled = false;
|
|
const body = new ReadableStream<Uint8Array>({
|
|
cancel() {
|
|
cancelled = true;
|
|
},
|
|
pull(controller) {
|
|
controller.enqueue(new Uint8Array(16).fill(97));
|
|
},
|
|
});
|
|
const { app } = streamingApp({
|
|
handler: async () => Response.json({ ok: true }),
|
|
maxBodyBytes: oneMebibyte,
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
|
body,
|
|
duplex: "half",
|
|
headers: { origin: trustedOrigin },
|
|
method: "POST",
|
|
} as RequestInit);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(cancelled).toBe(true);
|
|
});
|
|
|
|
it("refuses a declared oversize before authorization, and never streams for a denied caller", async () => {
|
|
const evaluator: AuthorizationEvaluator = {
|
|
authorize: vi.fn().mockResolvedValue({ allowed: false }),
|
|
};
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const { app } = buildModuleApp({
|
|
evaluator,
|
|
registry: permissionRegistry([{ id: writePermission, baseline: "restricted" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
bodyMode: "streamed",
|
|
handler,
|
|
maxBodyBytes: 64,
|
|
method: "POST",
|
|
moduleId: "calendar",
|
|
path: "/uploads",
|
|
permission: writePermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const declaredOversize = await app.request("/api/v1/modules/calendar/uploads", {
|
|
body: "a".repeat(65),
|
|
headers: { "content-length": "65", origin: trustedOrigin },
|
|
method: "POST",
|
|
});
|
|
expect(declaredOversize.status).toBe(413);
|
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
|
|
|
// A within-limit body from a denied caller is refused by authorization, and the handler — the
|
|
// only thing that ever reads a byte in streamed mode — never runs.
|
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
|
body: chunkedBody(32, 8),
|
|
duplex: "half",
|
|
headers: { origin: trustedOrigin },
|
|
method: "POST",
|
|
} as RequestInit);
|
|
expect(response.status).toBe(403);
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("still rejects an untrusted Origin before any body handling", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
|
body: chunkedBody(4096),
|
|
duplex: "half",
|
|
headers: { origin: untrustedOrigin },
|
|
method: "POST",
|
|
} as RequestInit);
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps every rejection sanitized, naming no limit, size, or body content", async () => {
|
|
const { app } = streamingApp({
|
|
handler: async () => Response.json({ ok: true }),
|
|
maxBodyBytes: 64,
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
|
body: "secret-payload-".repeat(64),
|
|
headers: { "content-length": String(15 * 64), origin: trustedOrigin },
|
|
method: "POST",
|
|
});
|
|
const body = await response.text();
|
|
|
|
expect(response.status).toBe(413);
|
|
expect(body).toBe('{"code":"REQUEST_BODY_TOO_LARGE"}');
|
|
expect(body).not.toContain("secret-payload");
|
|
expect(body).not.toContain("64");
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
});
|
|
|
|
it("still materializes a buffered body before the handler runs", async () => {
|
|
// The pre-existing promise, unchanged: a buffered handler may call `request.json()` and know the
|
|
// body is already there and already within its declared allowance.
|
|
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
|
|
const text = await request.text();
|
|
return Response.json({ length: text.length });
|
|
});
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
bodyMode: "buffered",
|
|
handler,
|
|
maxBodyBytes: 128,
|
|
method: "POST",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: writePermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const withinLimit = await app.request("/api/v1/modules/calendar/events", {
|
|
body: chunkedBody(128, 16),
|
|
duplex: "half",
|
|
headers: { origin: trustedOrigin },
|
|
method: "POST",
|
|
} as RequestInit);
|
|
expect(withinLimit.status).toBe(200);
|
|
expect(await withinLimit.json()).toEqual({ length: 128 });
|
|
|
|
const overLimit = await app.request("/api/v1/modules/calendar/events", {
|
|
body: chunkedBody(129, 16),
|
|
duplex: "half",
|
|
headers: { origin: trustedOrigin },
|
|
method: "POST",
|
|
} as RequestInit);
|
|
expect(overLimit.status).toBe(413);
|
|
// A buffered route's handler never sees an over-limit request at all.
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|