phase-5/security-hardening #14

Merged
Zayin_Admin merged 21 commits from phase-5/security-hardening into main 2026-07-30 03:04:37 +00:00
6 changed files with 468 additions and 0 deletions
Showing only changes of commit 800ecfdb92 - Show all commits
+151
View File
@@ -5,6 +5,7 @@ import {
type ModuleRouteHandler, type ModuleRouteHandler,
type ModuleRouteHandlerContext, type ModuleRouteHandlerContext,
type ModuleRuntimeServices, type ModuleRuntimeServices,
moduleRoutesOverlap,
parsePermissionId, parsePermissionId,
type PermissionId, type PermissionId,
type RegisteredModuleHttpRoute, type RegisteredModuleHttpRoute,
@@ -1454,3 +1455,153 @@ describe("module routes and the rest of the API", () => {
expect(response.status).toBe(200); 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);
});
});
+18
View File
@@ -12,6 +12,7 @@ import {
type ModuleRuntimeDefinition, type ModuleRuntimeDefinition,
type ModuleRuntimeServices, type ModuleRuntimeServices,
moduleRouteShapeKey, moduleRouteShapeKey,
moduleRoutesOverlap,
parseModuleRuntimeDefinition, parseModuleRuntimeDefinition,
type RegisteredCapability, type RegisteredCapability,
type RegisteredModuleHttpRoute, type RegisteredModuleHttpRoute,
@@ -37,6 +38,7 @@ export type ModuleRuntimeIssueCode =
| "MODULE_ROUTE_DUPLICATE" | "MODULE_ROUTE_DUPLICATE"
| "MODULE_ROUTE_MAX_BODY_BYTES_INVALID" | "MODULE_ROUTE_MAX_BODY_BYTES_INVALID"
| "MODULE_ROUTE_METHOD_INVALID" | "MODULE_ROUTE_METHOD_INVALID"
| "MODULE_ROUTE_OVERLAP"
| "MODULE_ROUTE_PATH_INVALID" | "MODULE_ROUTE_PATH_INVALID"
| "MODULE_ROUTE_PERMISSION_INVALID" | "MODULE_ROUTE_PERMISSION_INVALID"
| "MODULE_ROUTE_PERMISSION_UNDECLARED" | "MODULE_ROUTE_PERMISSION_UNDECLARED"
@@ -331,6 +333,22 @@ class StagedModuleHttpRoutes {
), ),
); );
} }
// Distinct from a duplicate: these are *different* routes that can both match at least one
// request path. With an order-sensitive matcher, whichever registered first would decide
// which permission governs that path — a permission-policy decision no routing order may
// make. Core rejects the ambiguity here instead of ranking by specificity, so the module
// publishes none of its staged routes and no request can reach an ambiguous match.
const overlapping = local.find((existing) => moduleRoutesOverlap(existing, parsed.data));
if (overlapping) {
throw new ModuleRuntimeSignal(
runtimeIssue(
"MODULE_ROUTE_OVERLAP",
"register",
module.manifest.id,
`Module ${module.manifest.id} registered HTTP routes that can both match the same request path.`,
),
);
}
shapes.add(shapeKey); shapes.add(shapeKey);
local.push( local.push(
Object.freeze({ Object.freeze({
+135
View File
@@ -562,6 +562,141 @@ describe("module HTTP routes", () => {
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_DUPLICATE"]); expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_DUPLICATE"]);
}); });
it.each([
["parameter registered first", "/events/:id", "/events/new"],
["static registered first", "/events/new", "/events/:id"],
["multi-position, parameter first", "/events/:id/edit", "/events/new/:action"],
["multi-position, static first", "/events/new/:action", "/events/:id/edit"],
["parameters on both sides", "/a/:left/c", "/a/b/:right"],
])("rejects routes that can both match one request path (%s) regardless of registration order", async (_label, firstPath, secondPath) => {
const modulesDirectory = await createModulesDirectory();
// Deliberately *different* permissions: the defect this rejects is that whichever route
// registered first would decide which permission governs the ambiguous path.
await createRuntimeModule(modulesDirectory, "overlap", {
manifest: manifestFor("overlap", {
permissions: [
permissionDeclaration("overlap.events.read"),
permissionDeclaration("overlap.events.create"),
],
}),
serverSource: `export default {
id: "overlap", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "${firstPath}", permission: "overlap.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "${secondPath}", permission: "overlap.events.create", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
}
};`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_OVERLAP"]);
});
it.each([
["two different static literals", "/events/new", "/events/archive"],
["different segment counts", "/events/:id", "/events/:id/edit"],
["a different static prefix", "/events/:id", "/people/:id"],
["module root against a one-segment path", "/", "/events"],
])("still allows disjoint routes (%s) and preserves their registration order", async (_label, firstPath, secondPath) => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "disjoint", {
manifest: manifestFor("disjoint", {
permissions: [permissionDeclaration("disjoint.events.read")],
}),
serverSource: `export default {
id: "disjoint", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "${firstPath}", permission: "disjoint.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "${secondPath}", permission: "disjoint.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
}
};`,
});
const runtime = await startModuleRuntime({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
services: fakeModuleRuntimeServices(),
});
expect(runtime.routes.map((route) => route.path)).toEqual([firstPath, secondPath]);
await runtime.stop();
});
it("allows the same overlapping local path under different methods", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "methods", {
manifest: manifestFor("methods", {
permissions: [permissionDeclaration("methods.events.read")],
}),
serverSource: `export default {
id: "methods", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/events/:id", permission: "methods.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
http.route({ method: "POST", path: "/events/new", permission: "methods.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
}
};`,
});
const runtime = await startModuleRuntime({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
services: fakeModuleRuntimeServices(),
});
expect(runtime.routes).toHaveLength(2);
await runtime.stop();
});
it("allows two different modules to declare the same overlapping local paths", async () => {
const modulesDirectory = await createModulesDirectory();
for (const moduleId of ["alpha", "beta"]) {
await createRuntimeModule(modulesDirectory, moduleId, {
manifest: manifestFor(moduleId, {
permissions: [permissionDeclaration(`${moduleId}.events.read`)],
}),
serverSource: `export default {
id: "${moduleId}", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/events/:id", permission: "${moduleId}.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
}
};`,
});
}
// Each module has its own platform-owned mount prefix, so these can never collide.
const runtime = await startModuleRuntime({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
services: fakeModuleRuntimeServices(),
});
expect(runtime.routes.map((route) => route.moduleId).sort()).toEqual(["alpha", "beta"]);
await runtime.stop();
});
it("publishes no route from a module whose registration was rejected for an overlap", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "partial", {
manifest: manifestFor("partial", {
permissions: [permissionDeclaration("partial.events.read")],
}),
serverSource: `export default {
id: "partial", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/healthy", permission: "partial.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "/events/:id", permission: "partial.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "/events/new", permission: "partial.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
},
start() { throw new Error("start must never run for a rejected registration"); }
};`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_OVERLAP"]);
// The earlier, perfectly valid `/healthy` route is not published either: registration is
// all-or-nothing, and the start hook above would have thrown had it ever run.
});
it("fails a malformed route with its specific structured issue code", async () => { it("fails a malformed route with its specific structured issue code", async () => {
const badMethodDirectory = await createModulesDirectory(); const badMethodDirectory = await createModulesDirectory();
await createRuntimeModule(badMethodDirectory, "bad-method", { await createRuntimeModule(badMethodDirectory, "bad-method", {
+59
View File
@@ -219,6 +219,65 @@ registration outright rather than being ignored. `method` is one of `GET`, `POST
`DELETE`, always uppercase; there is no `OPTIONS` (the platform owns CORS preflight) and no `DELETE`, always uppercase; there is no `OPTIONS` (the platform owns CORS preflight) and no
wildcard/all-method registration. wildcard/all-method registration.
### Two routes may not both match one request path
Within one module and one HTTP method, two route patterns must never be able to match the same
request path. `/events/:id` and `/events/new` can — `/events/new` matches both — and that pair is
rejected during registration with `MODULE_ROUTE_OVERLAP`. So are `/events/:id/edit` against
`/events/new/:action`, and `/a/:left/c` against `/a/b/:right`. Two identical shapes, including paths
differing only in parameter name (`/events/:id` and `/events/:eventId`), keep the existing
`MODULE_ROUTE_DUPLICATE` result.
This is rejected rather than resolved by a "static beats parameter" rule because the routes may
carry *different permissions*, and letting declaration order decide which one guards `/events/new`
would make a permission policy depend on the order lines appear in your `register` hook. A rejected
registration publishes **none** of that module's routes or capabilities and runs no start hook, so
the failure is loud at startup rather than subtle at request time.
Still perfectly legal: two different static literals (`/events/new` and `/events/archive`),
different segment counts (`/events/:id` and `/events/:id/edit`), the same path under different
methods, and the same local path in two different modules — each module has its own
platform-owned mount prefix. Declaration order among disjoint routes is preserved exactly.
### Route parameters are decoded, untrusted input — validate them yourself
`context.params` holds the router's **decoded** path values. Decoding is not validation, and the
fact that your *declared* path passed the platform's path grammar says nothing about what a caller
may *send*. A single encoded segment routinely carries characters no declared path could:
| Request segment | `context.params` value |
| --- | --- |
| `%2F..%2Fadmin` | `/../admin` |
| `a%5Cb` | `a\b` |
| `a%00b` | `a`, NUL, `b` |
| `a%0D%0Ab` | `a`, CR, LF, `b` |
| `1%3B%20drop` | `1; drop` |
| `%C3%A9v%C3%A9nement` | `événement` |
**Validate every parameter you use against your own exact domain grammar** — a UUID, slug, date,
numeric identifier, or whatever your module owns — before any database, filesystem, command,
identifier, or other side-effecting use, and reject a missing, repeated, malformed, or semantically
invalid value with your own sanitized response:
```ts
const eventId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
async handler(request, context) {
const id = context.params.eventId;
if (id === undefined || !eventId.test(id)) {
return Response.json({ code: "INVALID_REQUEST" }, { status: 400 });
}
// ...only now is `id` safe to use as a domain identifier.
}
```
Re-encoding the value, or a decode/re-encode round trip, is **not** a substitute for that check. The
platform deliberately supplies no generic parameter validator: your parameters are your domain
types, and one platform-wide pattern would either reject legitimate values or approve meaningless
ones. The platform continues to own authentication, authorization, `Origin`, CORS, body limits,
cache behavior, and uncaught-handler sanitization, and permission enforcement always completes
before your handler runs — an unauthorized caller can never reach your validation at all.
`maxBodyBytes` is a conscious, required request-body contract, not an optional or defaulted field: `maxBodyBytes` is a conscious, required request-body contract, not an optional or defaulted field:
`0` means the route accepts no body at all, and a non-empty declared (`Content-Length`) or streamed `0` means the route accepts no body at all, and a non-empty declared (`Content-Length`) or streamed
body is rejected before the handler ever runs; any other value is the exact maximum number of bytes body is rejected before the handler ever runs; any other value is the exact maximum number of bytes
+65
View File
@@ -141,6 +141,49 @@ export function moduleRouteShapeKey(method: ModuleHttpMethod, path: ModuleRouteP
return `${method} /${shapeSegments.join("/")}`; return `${method} /${shapeSegments.join("/")}`;
} }
/** `"/"` is the module root and has no segments; every other canonical path is its `/`-separated
* segments. Takes an already-validated `ModuleRoutePath`. */
function moduleRoutePathSegments(path: ModuleRoutePath): readonly string[] {
return path === "/" ? [] : path.slice(1).split("/");
}
/**
* Whether two routes of the same module can both match at least one request path.
*
* Two routes overlap when they share an HTTP method and a segment count, and at every position
* either both segments are the same static literal or at least one is a parameter. Under this
* route grammar — static kebab-case literals or `:name` parameters, with no wildcard, regex,
* optional, or catch-all segment — that condition is exactly "some concrete path matches both".
*
* This exists because an overlap is a *permission* ambiguity, not a routing preference:
* `GET /events/:id` and `GET /events/new` can carry different permissions, and with an
* order-sensitive matcher whichever registered first decides which permission governs
* `/events/new`. Core rejects the overlap at registration instead of picking a winner, so no
* router's matching order can ever select the governing permission. Identical shapes (including
* paths differing only in parameter name) remain the pre-existing duplicate result; callers check
* that first.
*
* Deliberately framework-neutral: path semantics already live in this package, and nothing about
* this rule may depend on Hono, which stays confined to `apps/api`.
*/
export function moduleRoutesOverlap(
first: { readonly method: ModuleHttpMethod; readonly path: ModuleRoutePath },
second: { readonly method: ModuleHttpMethod; readonly path: ModuleRoutePath },
): boolean {
if (first.method !== second.method) {
return false;
}
const firstSegments = moduleRoutePathSegments(first.path);
const secondSegments = moduleRoutePathSegments(second.path);
if (firstSegments.length !== secondSegments.length) {
return false;
}
return firstSegments.every((segment, index) => {
const other = secondSegments[index] as string;
return segment.startsWith(":") || other.startsWith(":") || segment === other;
});
}
/** /**
* What a module's route handler receives about the caller, the match, and the platform services * What a module's route handler receives about the caller, the match, and the platform services
* available to it — never an authorization-store, grant-management, storage, jobs, configuration, * available to it — never an authorization-store, grant-management, storage, jobs, configuration,
@@ -149,6 +192,28 @@ export function moduleRouteShapeKey(method: ModuleHttpMethod, path: ModuleRouteP
* before ever invoking its handler; this contracts-layer module never invokes a handler itself. * before ever invoking its handler; this contracts-layer module never invokes a handler itself.
* `services` is the same frozen `ModuleRuntimeServices` object the owning module's `start`/`stop` * `services` is the same frozen `ModuleRuntimeServices` object the owning module's `start`/`stop`
* hooks receive for this API runtime — never a fresh one built per request. * hooks receive for this API runtime — never a fresh one built per request.
*
* ## `params` values are untrusted input
*
* `params` holds the HTTP router's **decoded** path values, frozen alongside this context. Decoding
* is not validation, and the declaration grammar above constrains only what a module may *declare*
* — it says nothing about what a request may *send*. A caller can percent-encode anything into a
* single segment, so a decoded value routinely contains characters no declared path could:
* verified against the pinned router, `%2F..%2Fadmin` arrives as `/../admin`, `a%5Cb` as `a\b`,
* `a%00b` with an embedded NUL, `a%0D%0Ab` with CR/LF, and `%C3%A9v%C3%A9nement` as `événement`.
*
* Every handler must therefore validate each parameter it uses against its own exact domain
* grammar — a UUID, slug, date, numeric id, or whatever that module owns — **before** any
* database, filesystem, command, identifier, or other side-effecting use, and reject a missing,
* repeated, malformed, or semantically invalid value with its own sanitized response. Re-encoding,
* decode/re-encode round-tripping, or inspecting the raw URL is not a substitute for that check.
* The platform deliberately supplies no generic parameter grammar: module parameters are
* module-owned domain types, and one platform-wide pattern would either reject legitimate values
* or approve meaningless ones.
*
* The platform continues to own authentication, authorization, `Origin`, CORS, body limits, cache
* behavior, and uncaught-handler sanitization; permission enforcement always completes before a
* handler — and therefore any parameter validation it performs — can run.
*/ */
export interface ModuleRouteHandlerContext { export interface ModuleRouteHandlerContext {
readonly moduleId: ModuleId; readonly moduleId: ModuleId;
@@ -6,6 +6,7 @@ import {
type ModuleRouteDefinitionInput, type ModuleRouteDefinitionInput,
moduleRoutePathSchema, moduleRoutePathSchema,
moduleRouteShapeKey, moduleRouteShapeKey,
moduleRoutesOverlap,
parseModuleRouteDefinition, parseModuleRouteDefinition,
parseModuleRoutePath, parseModuleRoutePath,
safeParseModuleRouteDefinition, safeParseModuleRouteDefinition,
@@ -294,3 +295,42 @@ describe("parseModuleRouteDefinition", () => {
} }
}); });
}); });
describe("moduleRoutesOverlap", () => {
const route = (method: "GET" | "POST", path: string) => ({
method,
path: parseModuleRoutePath(path),
});
it.each([
["a parameter against a static literal", "/events/:id", "/events/new"],
["a later position", "/events/:id/edit", "/events/new/:action"],
["parameters on both sides at different positions", "/a/:left/c", "/a/b/:right"],
["identical parameter shapes with different names", "/events/:id", "/events/:eventId"],
["identical static paths", "/events/new", "/events/new"],
["root against root", "/", "/"],
])("reports an overlap for %s", (_label, first, second) => {
expect(moduleRoutesOverlap(route("GET", first), route("GET", second))).toBe(true);
// The relation is symmetric, so registration order cannot change the verdict.
expect(moduleRoutesOverlap(route("GET", second), route("GET", first))).toBe(true);
});
it.each([
["two different static literals", "/events/new", "/events/archive"],
["different segment counts", "/events/:id", "/events/:id/edit"],
["a static prefix that differs earlier", "/events/:id", "/people/:id"],
["root against a one-segment path", "/", "/events"],
])("reports no overlap for %s", (_label, first, second) => {
expect(moduleRoutesOverlap(route("GET", first), route("GET", second))).toBe(false);
expect(moduleRoutesOverlap(route("GET", second), route("GET", first))).toBe(false);
});
it("never reports an overlap across different methods", () => {
expect(moduleRoutesOverlap(route("GET", "/events/:id"), route("POST", "/events/new"))).toBe(
false,
);
expect(moduleRoutesOverlap(route("GET", "/events/new"), route("POST", "/events/new"))).toBe(
false,
);
});
});