Files
Ekklesia/packages/contracts/tests/module-http.test.ts
T
ekklesiaandClaude Sonnet 5 e296e083ec feat: define module HTTP routes
Phase 5B, pass 1: framework-neutral module HTTP route contracts and runtime
registration. Adds @ekklesia/contracts' module-http.ts (methods, canonical
path grammar, route definition/context/handler types, and validation)
and Core's module runtime staging/publication of a module's declared
routes alongside its capabilities, gated on the module's own manifest
permissions. Routes are collected into StartedModuleRuntime as an
immutable snapshot but are not yet mounted into apps/api; that is pass 2.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-24 18:36:23 -05:00

256 lines
9.2 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
isModuleHttpMethod,
MODULE_HTTP_METHODS,
type ModuleRouteDefinitionInput,
moduleRoutePathSchema,
moduleRouteShapeKey,
parseModuleRouteDefinition,
parseModuleRoutePath,
safeParseModuleRouteDefinition,
safeParseModuleRoutePath,
} from "../src/index.js";
function noopHandler(): Response {
return new Response(null, { status: 204 });
}
function validDefinition(
overrides: Partial<ModuleRouteDefinitionInput> = {},
): ModuleRouteDefinitionInput {
return {
method: "GET",
path: "/events/:eventId",
permission: "calendar.events.read",
handler: noopHandler,
...overrides,
};
}
describe("ModuleHttpMethod", () => {
it("accepts every supported method", () => {
for (const method of MODULE_HTTP_METHODS) {
expect(isModuleHttpMethod(method)).toBe(true);
}
expect(MODULE_HTTP_METHODS).toEqual(["DELETE", "GET", "PATCH", "POST", "PUT"]);
});
it("rejects lowercase, unknown, OPTIONS, HEAD, and wildcard methods", () => {
for (const method of ["get", "post", "TRACE", "OPTIONS", "HEAD", "*", "ALL", ""]) {
expect(isModuleHttpMethod(method)).toBe(false);
}
expect(isModuleHttpMethod(42)).toBe(false);
expect(isModuleHttpMethod(undefined)).toBe(false);
});
});
describe("moduleRoutePathSchema", () => {
it("accepts the root, static, nested, and parameterized canonical paths", () => {
for (const path of [
"/",
"/events",
"/events/upcoming",
"/events/:eventId",
"/events/:eventId/attendees/:attendeeId",
]) {
expect(parseModuleRoutePath(path)).toBe(path);
}
});
it("rejects paths that do not begin with /", () => {
expect(safeParseModuleRoutePath("events").success).toBe(false);
expect(safeParseModuleRoutePath("").success).toBe(false);
});
it("rejects a non-root path ending with /", () => {
expect(safeParseModuleRoutePath("/events/").success).toBe(false);
});
it("rejects empty and repeated segments", () => {
expect(safeParseModuleRoutePath("/events//upcoming").success).toBe(false);
expect(safeParseModuleRoutePath("//events").success).toBe(false);
});
it("rejects query strings and fragments", () => {
expect(safeParseModuleRoutePath("/events?limit=10").success).toBe(false);
expect(safeParseModuleRoutePath("/events#upcoming").success).toBe(false);
});
it("rejects backslashes and percent-encoded path syntax", () => {
expect(safeParseModuleRoutePath("/events\\upcoming").success).toBe(false);
expect(safeParseModuleRoutePath("/events%2Fupcoming").success).toBe(false);
expect(safeParseModuleRoutePath("/%2e%2e/events").success).toBe(false);
});
it("rejects dot segments and traversal syntax", () => {
expect(safeParseModuleRoutePath("/events/..").success).toBe(false);
expect(safeParseModuleRoutePath("/events/.").success).toBe(false);
expect(safeParseModuleRoutePath("/../events").success).toBe(false);
});
it("rejects wildcard or catch-all segments", () => {
expect(safeParseModuleRoutePath("/events/*").success).toBe(false);
expect(safeParseModuleRoutePath("/events/**").success).toBe(false);
expect(safeParseModuleRoutePath("/*").success).toBe(false);
});
it("rejects non-kebab-case static segments", () => {
expect(safeParseModuleRoutePath("/Events").success).toBe(false);
expect(safeParseModuleRoutePath("/events_list").success).toBe(false);
expect(safeParseModuleRoutePath("/events--upcoming").success).toBe(false);
expect(safeParseModuleRoutePath("/-events").success).toBe(false);
expect(safeParseModuleRoutePath("/events-").success).toBe(false);
});
it("rejects a malformed parameter segment", () => {
expect(safeParseModuleRoutePath("/events/:").success).toBe(false);
expect(safeParseModuleRoutePath("/events/:Event-Id").success).toBe(false);
expect(safeParseModuleRoutePath("/events/:1id").success).toBe(false);
});
it("rejects duplicate parameter names within one path", () => {
expect(safeParseModuleRoutePath("/events/:id/attendees/:id").success).toBe(false);
});
it("rejects an unreasonably long path", () => {
expect(safeParseModuleRoutePath(`/${"a".repeat(300)}`).success).toBe(false);
});
it("never echoes the rejected raw path in its issue messages", () => {
const secret = "/machine/private/path/traversal-attempt-%2e%2e";
const result = moduleRoutePathSchema.safeParse(secret);
expect(result.success).toBe(false);
expect(JSON.stringify(result.error?.issues)).not.toContain(secret);
});
});
describe("moduleRouteShapeKey", () => {
it("collapses different parameter names into the same shape for the same method", () => {
const eventId = parseModuleRoutePath("/events/:eventId");
const id = parseModuleRoutePath("/events/:id");
expect(moduleRouteShapeKey("GET", eventId)).toBe(moduleRouteShapeKey("GET", id));
});
it("distinguishes shapes by method and by static path structure", () => {
const path = parseModuleRoutePath("/events/:eventId");
expect(moduleRouteShapeKey("GET", path)).not.toBe(moduleRouteShapeKey("POST", path));
expect(moduleRouteShapeKey("GET", parseModuleRoutePath("/events"))).not.toBe(
moduleRouteShapeKey("GET", parseModuleRoutePath("/events/:eventId")),
);
});
it("treats the root path as its own shape", () => {
expect(moduleRouteShapeKey("GET", parseModuleRoutePath("/"))).toBe("GET /");
});
});
describe("safeParseModuleRouteDefinition", () => {
it("accepts a well-formed definition and brands its path and permission", () => {
const result = safeParseModuleRouteDefinition(validDefinition());
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual({
method: "GET",
path: "/events/:eventId",
permission: "calendar.events.read",
handler: noopHandler,
});
expect(Object.isFrozen(result.data)).toBe(true);
}
});
it("rejects a non-object, null, or array definition", () => {
for (const value of [null, undefined, "route", 42, []]) {
expect(safeParseModuleRouteDefinition(value)).toEqual({
success: false,
code: "MODULE_ROUTE_DEFINITION_INVALID",
});
}
});
it("rejects an unknown route-definition field", () => {
expect(safeParseModuleRouteDefinition({ ...validDefinition(), baseline: "public" })).toEqual({
success: false,
code: "MODULE_ROUTE_DEFINITION_INVALID",
});
expect(
safeParseModuleRouteDefinition({ ...validDefinition(), mountPrefix: "/custom" }),
).toEqual({ success: false, code: "MODULE_ROUTE_DEFINITION_INVALID" });
});
it("rejects a missing required field", () => {
const { handler: _handler, ...withoutHandler } = validDefinition();
expect(safeParseModuleRouteDefinition(withoutHandler)).toEqual({
success: false,
code: "MODULE_ROUTE_DEFINITION_INVALID",
});
});
it("rejects a non-function handler", () => {
expect(
safeParseModuleRouteDefinition({ ...validDefinition(), handler: "not-a-function" }),
).toEqual({ success: false, code: "MODULE_ROUTE_DEFINITION_INVALID" });
});
it("rejects an invalid method", () => {
expect(safeParseModuleRouteDefinition({ ...validDefinition(), method: "get" })).toEqual({
success: false,
code: "MODULE_ROUTE_METHOD_INVALID",
});
});
it("rejects an invalid path", () => {
expect(safeParseModuleRouteDefinition({ ...validDefinition(), path: "events" })).toEqual({
success: false,
code: "MODULE_ROUTE_PATH_INVALID",
});
});
it("rejects an invalid permission id", () => {
expect(
safeParseModuleRouteDefinition({ ...validDefinition(), permission: "not namespaced" }),
).toEqual({ success: false, code: "MODULE_ROUTE_PERMISSION_INVALID" });
expect(safeParseModuleRouteDefinition({ ...validDefinition(), permission: "*" })).toEqual({
success: false,
code: "MODULE_ROUTE_PERMISSION_INVALID",
});
});
it("does not retain the caller's mutable definition object: mutating it afterward cannot change the parsed result", () => {
const source = { ...validDefinition() };
const result = safeParseModuleRouteDefinition(source);
expect(result.success).toBe(true);
source.method = "POST";
source.path = "/mutated";
source.permission = "mutated.permission.id";
if (result.success) {
expect(result.data.method).toBe("GET");
expect(result.data.path).toBe("/events/:eventId");
expect(result.data.permission).toBe("calendar.events.read");
}
});
});
describe("parseModuleRouteDefinition", () => {
it("returns the parsed definition for valid input", () => {
expect(parseModuleRouteDefinition(validDefinition())).toMatchObject({
method: "GET",
path: "/events/:eventId",
permission: "calendar.events.read",
});
});
it("throws without echoing the rejected input", () => {
const secret = "secret-token-value";
expect(() => parseModuleRouteDefinition({ ...validDefinition(), permission: secret })).toThrow(
"Invalid Ekklesia module route definition.",
);
try {
parseModuleRouteDefinition({ ...validDefinition(), permission: secret });
throw new Error("expected parseModuleRouteDefinition to throw");
} catch (error) {
expect(String(error)).not.toContain(secret);
}
});
});