test: harden housekeeping preview boundaries

This commit is contained in:
Simo committed 2026-08-25 21:00:22 +02:00
1 parent d19ba88005
commit ff2b2af6d4
1 file changed
+236 -20
@@ -1,5 +1,5 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { posix, resolve } from "node:path";
import { createElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -8,13 +8,13 @@ import type { HousekeepingCapabilityContext } from "./contracts";
const routeMocks = vi.hoisted(() => {
const messages: Record<string, string> = {
"preview.badge": "Localized preview",
"preview.commandDisabled": "Localized disabled command",
"preview.backToSite": "Localized back to site",
"navigation.skipToContent": "Localized skip to content",
"navigation.primary": "Localized primary navigation",
"navigation.contextual": "Localized contextual navigation",
"domains.people.title": "Localized People",
"preview.badge": "HK::preview-badge",
"preview.commandDisabled": "HK::command-disabled",
"preview.backToSite": "HK::back-to-site",
"navigation.skipToContent": "HK::skip-to-content",
"navigation.primary": "HK::primary-navigation",
"navigation.contextual": "HK::contextual-navigation",
"domains.people.title": "HK::people-title",
"domains.people.description": "Localized People description",
"domains.economy.title": "Localized Economy",
"domains.economy.description": "Localized Economy description",
@@ -89,6 +89,155 @@ const routeFiles = [
"src/app/admin-next/[domain]/page.tsx",
] as const;
const forbiddenModuleRoots = [
"src/lib/db",
"src/lib/auth",
"src/lib/permissions",
"src/actions",
"src/app/actions",
"src/app/admin",
"src/app/mod",
] as const;
interface SourceToken {
kind: "word" | "string" | "punctuation";
value: string;
}
function tokenizeModuleSource(source: string): readonly SourceToken[] {
const tokens: SourceToken[] = [];
let index = 0;
while (index < source.length) {
const character = source[index];
const nextCharacter = source[index + 1];
if (/\s/.test(character)) {
index += 1;
continue;
}
if (character === "/" && nextCharacter === "/") {
index = source.indexOf("\n", index + 2);
if (index === -1) break;
continue;
}
if (character === "/" && nextCharacter === "*") {
const end = source.indexOf("*/", index + 2);
index = end === -1 ? source.length : end + 2;
continue;
}
if (character === '"' || character === "'" || character === "`") {
const quote = character;
let value = "";
let interpolated = false;
index += 1;
while (index < source.length) {
const current = source[index];
if (current === "\\") {
value += source[index + 1] ?? "";
index += 2;
continue;
}
if (quote === "`" && current === "$" && source[index + 1] === "{") {
interpolated = true;
}
if (current === quote) {
index += 1;
break;
}
value += current;
index += 1;
}
if (!interpolated) tokens.push({ kind: "string", value });
continue;
}
if (/[A-Za-z_$]/.test(character)) {
const start = index;
index += 1;
while (index < source.length && /[A-Za-z0-9_$]/.test(source[index])) {
index += 1;
}
tokens.push({ kind: "word", value: source.slice(start, index) });
continue;
}
tokens.push({ kind: "punctuation", value: character });
index += 1;
}
return tokens;
}
function extractModuleSpecifiers(source: string): readonly string[] {
const tokens = tokenizeModuleSource(source);
const specifiers: string[] = [];
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (
token.kind !== "word" ||
(token.value !== "import" && token.value !== "export")
) {
continue;
}
const next = tokens[index + 1];
if (token.value === "import" && next?.value === "(") {
const argument = tokens[index + 2];
if (argument?.kind === "string") specifiers.push(argument.value);
continue;
}
if (token.value === "import" && next?.kind === "string") {
specifiers.push(next.value);
continue;
}
for (let cursor = index + 1; cursor < tokens.length; cursor += 1) {
const candidate = tokens[cursor];
if (candidate.value === ";") break;
if (candidate.kind === "word" && candidate.value === "from") {
const moduleSpecifier = tokens[cursor + 1];
if (moduleSpecifier?.kind === "string") {
specifiers.push(moduleSpecifier.value);
}
break;
}
}
}
return specifiers;
}
function normalizeLocalSpecifier(
routeFile: string,
specifier: string,
): string | null {
if (specifier.startsWith("@/")) {
return posix.normalize(`src/${specifier.slice(2)}`);
}
if (specifier.startsWith(".")) {
return posix.normalize(posix.join(posix.dirname(routeFile), specifier));
}
return null;
}
function findForbiddenRouteImports(
source: string,
routeFile: string,
): readonly string[] {
return extractModuleSpecifiers(source)
.map((specifier) => normalizeLocalSpecifier(routeFile, specifier))
.filter((path): path is string => path !== null)
.filter((path) =>
forbiddenModuleRoots.some(
(root) => path === root || path.startsWith(`${root}/`),
),
);
}
function capabilityContext(
granted: readonly string[],
actor = { id: 42, username: "refreshed-moderator", rank: 3 },
@@ -244,9 +393,13 @@ describe("/admin-next/[domain] layout", () => {
);
expect(html).toContain("refreshed-moderator");
expect(html).toContain("Localized preview");
expect(html).toContain("Localized primary navigation");
expect(html).toContain("Localized People");
expect(html).toContain("HK::skip-to-content");
expect(html).toContain("HK::primary-navigation");
expect(html).toContain("HK::contextual-navigation");
expect(html).toContain("HK::command-disabled");
expect(html).toContain("HK::preview-badge");
expect(html).toContain("HK::back-to-site");
expect(html).toContain("HK::people-title");
expect(html).toContain("People body");
expect(html).not.toContain("Localized Economy");
expect(routeMocks.translate).not.toHaveBeenCalledWith(
@@ -271,7 +424,7 @@ describe("/admin-next/[domain] page", () => {
}),
);
expect(html).toContain("Localized People");
expect(html).toContain("HK::people-title");
expect(html).toContain("Localized People description");
expect(html).toContain("Localized empty title");
expect(html).toContain("Localized empty description");
@@ -291,18 +444,81 @@ describe("/admin-next/[domain] page", () => {
});
describe("preview route import boundary", () => {
it("rejects data, actions, direct auth, old chrome, and legacy route imports", () => {
it("rejects normalized forbidden imports and legacy chrome in real routes", () => {
for (const path of routeFiles) {
const source = readFileSync(resolve(process.cwd(), path), "utf8");
expect(source, path).not.toMatch(
/@\/lib\/db|@\/(?:app\/)?actions(?:\/|["'])/,
);
expect(findForbiddenRouteImports(source, path), path).toEqual([]);
expect(source, path).not.toMatch(/AdminSidebarNav|AdminHubChrome/);
expect(source, path).not.toMatch(/@\/lib\/(?:auth|permissions)/);
expect(source, path).not.toMatch(
/(?:@\/app\/(?:admin|mod)|\.\.\/+(?:admin|mod))(?:\/|["'])/,
);
}
});
it.each([
[
"aliased database descendant import",
"src/app/admin-next/page.tsx",
'import { query } from "@/lib/db/query";',
"src/lib/db/query",
],
[
"root relative database import",
"src/app/admin-next/page.tsx",
'import { db } from "../../lib/db";',
"src/lib/db",
],
[
"domain relative auth side-effect import",
"src/app/admin-next/[domain]/layout.tsx",
'import "../../../lib/auth";',
"src/lib/auth",
],
[
"domain relative permissions export",
"src/app/admin-next/[domain]/page.tsx",
'export { getAdminContext } from "../../../lib/permissions";',
"src/lib/permissions",
],
[
"root relative action dynamic import",
"src/app/admin-next/page.tsx",
'import("../../actions/users")',
"src/actions/users",
],
[
"root relative app action export",
"src/app/admin-next/page.tsx",
'export * from "../actions";',
"src/app/actions",
],
[
"domain relative legacy admin import",
"src/app/admin-next/[domain]/layout.tsx",
'import page from "../../admin/users/page";',
"src/app/admin/users/page",
],
[
"root relative legacy mod dynamic import",
"src/app/admin-next/layout.tsx",
'import("../mod/users/page")',
"src/app/mod/users/page",
],
] as const)("detects %s", (_name, routeFile, source, expectedPath) => {
expect(findForbiddenRouteImports(source, routeFile)).toContain(
expectedPath,
);
});
it("does not reject substring lookalikes, comments, or ordinary strings", () => {
const source = [
'import database from "@/lib/database";',
'import auth from "../../lib/authentication";',
'import preview from "../admin-next-shared";',
"const documentation = \"import db from '../../lib/db'\";",
'// import db from "../../lib/db";',
].join("\n");
expect(
findForbiddenRouteImports(source, "src/app/admin-next/page.tsx"),
).toEqual([]);
});
});