fix(housekeeping): make route matching deterministic
This commit is contained in:
1 parent
e5c230ba35
commit
0117b45d74
4 files changed
+256
-45
No files matched your search
@@ -121,6 +121,24 @@ vi.mock("@/features/housekeeping/manifests", async (importOriginal) => {
|
||||
slugs: ["admin.bans.view"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "people.tool-detail",
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
href: "/ase/people/tools/:id" as const,
|
||||
capability: {
|
||||
mode: "any" as const,
|
||||
slugs: ["admin.users.view"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "people.literal-tool",
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
href: "/ase/people/tools/a+b[1]" as const,
|
||||
capability: {
|
||||
mode: "any" as const,
|
||||
slugs: ["admin.users.view"],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: manifest,
|
||||
@@ -135,6 +153,14 @@ vi.mock("@/features/housekeeping/route-handlers", () => ({
|
||||
render: routeMocks.renderHousekeepingRoute,
|
||||
},
|
||||
{ routeId: "people.bans", render: routeMocks.renderHousekeepingRoute },
|
||||
{
|
||||
routeId: "people.tool-detail",
|
||||
render: routeMocks.renderHousekeepingRoute,
|
||||
},
|
||||
{
|
||||
routeId: "people.literal-tool",
|
||||
render: routeMocks.renderHousekeepingRoute,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
@@ -631,6 +657,30 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps catch-all encoding from changing the literal route handler", async () => {
|
||||
const context = capabilityContext([PERMS.USERS_VIEW]);
|
||||
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(context);
|
||||
|
||||
await renderRoute(
|
||||
AdminNextDomainPage({
|
||||
params: Promise.resolve({
|
||||
domain: "people",
|
||||
segments: ["tools", "a+b[1]"],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
|
||||
context,
|
||||
match: {
|
||||
routeId: "people.literal-tool",
|
||||
domain: "people",
|
||||
params: {},
|
||||
canonicalHref: "/ase/people/tools/a%2Bb%5B1%5D",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 404 for an inaccessible matched route without invoking it", async () => {
|
||||
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
|
||||
capabilityContext([PERMS.MOD_CFH_VIEW]),
|
||||
|
||||
@@ -102,6 +102,35 @@ describe("matchHousekeepingRoute", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
routes: [
|
||||
route("people.kind-settings", "/ase/people/:kind/settings"),
|
||||
route("people.user-detail", "/ase/people/users/:id"),
|
||||
],
|
||||
},
|
||||
{
|
||||
routes: [
|
||||
route("people.user-detail", "/ase/people/users/:id"),
|
||||
route("people.kind-settings", "/ase/people/:kind/settings"),
|
||||
],
|
||||
},
|
||||
])(
|
||||
"prefers the earliest static segment regardless of registration order",
|
||||
({ routes }) => {
|
||||
const orderedRegistry = createHousekeepingRegistry([
|
||||
peopleManifest(routes),
|
||||
]);
|
||||
|
||||
expect(
|
||||
matchHousekeepingRoute(orderedRegistry, "/ase/people/users/settings"),
|
||||
)?.toMatchObject({
|
||||
routeId: "people.user-detail",
|
||||
params: { id: "settings" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"/ase/people/unknown",
|
||||
"/ase/economy/users",
|
||||
|
||||
@@ -10,15 +10,18 @@ export interface HousekeepingRouteMatch {
|
||||
}
|
||||
|
||||
interface ParsedCanonicalPath {
|
||||
rawSegments: readonly string[];
|
||||
decodedSegments: readonly string[];
|
||||
segments: readonly string[];
|
||||
}
|
||||
|
||||
interface RoutePatternSegment {
|
||||
literal: string | null;
|
||||
parameterName: string | null;
|
||||
}
|
||||
|
||||
interface RouteCandidate {
|
||||
routeId: string;
|
||||
domain: HousekeepingDomainId;
|
||||
patternSegments: readonly string[];
|
||||
dynamicSegments: number;
|
||||
patternSegments: readonly RoutePatternSegment[];
|
||||
}
|
||||
|
||||
export function matchHousekeepingRoute(
|
||||
@@ -30,24 +33,16 @@ export function matchHousekeepingRoute(
|
||||
|
||||
const candidates = registry.domains
|
||||
.flatMap((domain) =>
|
||||
domain.routes
|
||||
.filter((route) =>
|
||||
routeBelongsToDomain(route.href, domain.canonicalHref),
|
||||
)
|
||||
.map((route) => {
|
||||
const patternSegments = route.href.slice(1).split("/");
|
||||
domain.routes.flatMap((route) => {
|
||||
if (!routeBelongsToDomain(route.href, domain.canonicalHref)) return [];
|
||||
|
||||
return {
|
||||
routeId: route.id,
|
||||
domain: domain.id,
|
||||
patternSegments,
|
||||
dynamicSegments: patternSegments.filter((segment) =>
|
||||
segment.startsWith(":"),
|
||||
).length,
|
||||
};
|
||||
}),
|
||||
const patternSegments = parseRoutePattern(route.href);
|
||||
return patternSegments
|
||||
? [{ routeId: route.id, domain: domain.id, patternSegments }]
|
||||
: [];
|
||||
}),
|
||||
)
|
||||
.sort((left, right) => left.dynamicSegments - right.dynamicSegments);
|
||||
.sort(compareSpecificity);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const params = matchSegments(candidate, path);
|
||||
@@ -81,28 +76,71 @@ function parseCanonicalPath(canonicalPath: string): ParsedCanonicalPath | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const decodedSegments: string[] = [];
|
||||
const segments: string[] = [];
|
||||
for (const segment of rawSegments) {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(segment);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
!decoded ||
|
||||
decoded === "." ||
|
||||
decoded === ".." ||
|
||||
decoded.includes("/") ||
|
||||
decoded.includes("\\")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
decodedSegments.push(decoded);
|
||||
const decoded = decodeCanonicalSegment(segment);
|
||||
if (decoded === null) return null;
|
||||
segments.push(decoded);
|
||||
}
|
||||
|
||||
return { rawSegments, decodedSegments };
|
||||
return { segments };
|
||||
}
|
||||
|
||||
function parseRoutePattern(
|
||||
routeHref: CanonicalHousekeepingHref,
|
||||
): readonly RoutePatternSegment[] | null {
|
||||
const segments: RoutePatternSegment[] = [];
|
||||
|
||||
for (const segment of routeHref.slice(1).split("/")) {
|
||||
if (segment.startsWith(":")) {
|
||||
const parameterName = segment.slice(1);
|
||||
if (!parameterName) return null;
|
||||
segments.push({ literal: null, parameterName });
|
||||
continue;
|
||||
}
|
||||
|
||||
const literal = decodeCanonicalSegment(segment);
|
||||
if (literal === null) return null;
|
||||
segments.push({ literal, parameterName: null });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function decodeCanonicalSegment(segment: string): string | null {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(segment);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return !decoded ||
|
||||
decoded === "." ||
|
||||
decoded === ".." ||
|
||||
decoded.includes("/") ||
|
||||
decoded.includes("\\")
|
||||
? null
|
||||
: decoded;
|
||||
}
|
||||
|
||||
function compareSpecificity(
|
||||
left: RouteCandidate,
|
||||
right: RouteCandidate,
|
||||
): number {
|
||||
const sharedLength = Math.min(
|
||||
left.patternSegments.length,
|
||||
right.patternSegments.length,
|
||||
);
|
||||
|
||||
for (let index = 0; index < sharedLength; index += 1) {
|
||||
const leftDynamic = left.patternSegments[index]?.parameterName !== null;
|
||||
const rightDynamic = right.patternSegments[index]?.parameterName !== null;
|
||||
if (leftDynamic === rightDynamic) continue;
|
||||
return leftDynamic ? 1 : -1;
|
||||
}
|
||||
|
||||
return left.patternSegments.length - right.patternSegments.length;
|
||||
}
|
||||
|
||||
function routeBelongsToDomain(
|
||||
@@ -120,18 +158,18 @@ function matchSegments(
|
||||
candidate: RouteCandidate,
|
||||
path: ParsedCanonicalPath,
|
||||
): Record<string, string> | null {
|
||||
if (candidate.patternSegments.length !== path.rawSegments.length) return null;
|
||||
if (candidate.patternSegments.length !== path.segments.length) return null;
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
for (const [index, patternSegment] of candidate.patternSegments.entries()) {
|
||||
if (!patternSegment.startsWith(":")) {
|
||||
if (patternSegment !== path.rawSegments[index]) return null;
|
||||
const pathSegment = path.segments[index] ?? "";
|
||||
if (patternSegment.parameterName === null) {
|
||||
if (patternSegment.literal !== pathSegment) return null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const parameterName = patternSegment.slice(1);
|
||||
if (!parameterName || parameterName in params) return null;
|
||||
params[parameterName] = path.decodedSegments[index] ?? "";
|
||||
if (patternSegment.parameterName in params) return null;
|
||||
params[patternSegment.parameterName] = pathSegment;
|
||||
}
|
||||
|
||||
return params;
|
||||
|
||||
Reference in new issue
Block a user