test: harden housekeeping preview boundaries
This commit is contained in:
1 parent
d19ba88005
commit
ff2b2af6d4
1 file changed
+236
-20
@@ -1,5 +1,5 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import { posix, resolve } from "node:path";
|
||||||
import { createElement, type ReactNode } from "react";
|
import { createElement, type ReactNode } from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -8,13 +8,13 @@ import type { HousekeepingCapabilityContext } from "./contracts";
|
|||||||
|
|
||||||
const routeMocks = vi.hoisted(() => {
|
const routeMocks = vi.hoisted(() => {
|
||||||
const messages: Record<string, string> = {
|
const messages: Record<string, string> = {
|
||||||
"preview.badge": "Localized preview",
|
"preview.badge": "HK::preview-badge",
|
||||||
"preview.commandDisabled": "Localized disabled command",
|
"preview.commandDisabled": "HK::command-disabled",
|
||||||
"preview.backToSite": "Localized back to site",
|
"preview.backToSite": "HK::back-to-site",
|
||||||
"navigation.skipToContent": "Localized skip to content",
|
"navigation.skipToContent": "HK::skip-to-content",
|
||||||
"navigation.primary": "Localized primary navigation",
|
"navigation.primary": "HK::primary-navigation",
|
||||||
"navigation.contextual": "Localized contextual navigation",
|
"navigation.contextual": "HK::contextual-navigation",
|
||||||
"domains.people.title": "Localized People",
|
"domains.people.title": "HK::people-title",
|
||||||
"domains.people.description": "Localized People description",
|
"domains.people.description": "Localized People description",
|
||||||
"domains.economy.title": "Localized Economy",
|
"domains.economy.title": "Localized Economy",
|
||||||
"domains.economy.description": "Localized Economy description",
|
"domains.economy.description": "Localized Economy description",
|
||||||
@@ -89,6 +89,155 @@ const routeFiles = [
|
|||||||
"src/app/admin-next/[domain]/page.tsx",
|
"src/app/admin-next/[domain]/page.tsx",
|
||||||
] as const;
|
] 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(
|
function capabilityContext(
|
||||||
granted: readonly string[],
|
granted: readonly string[],
|
||||||
actor = { id: 42, username: "refreshed-moderator", rank: 3 },
|
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("refreshed-moderator");
|
||||||
expect(html).toContain("Localized preview");
|
expect(html).toContain("HK::skip-to-content");
|
||||||
expect(html).toContain("Localized primary navigation");
|
expect(html).toContain("HK::primary-navigation");
|
||||||
expect(html).toContain("Localized People");
|
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).toContain("People body");
|
||||||
expect(html).not.toContain("Localized Economy");
|
expect(html).not.toContain("Localized Economy");
|
||||||
expect(routeMocks.translate).not.toHaveBeenCalledWith(
|
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 People description");
|
||||||
expect(html).toContain("Localized empty title");
|
expect(html).toContain("Localized empty title");
|
||||||
expect(html).toContain("Localized empty description");
|
expect(html).toContain("Localized empty description");
|
||||||
@@ -291,18 +444,81 @@ describe("/admin-next/[domain] page", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("preview route import boundary", () => {
|
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) {
|
for (const path of routeFiles) {
|
||||||
const source = readFileSync(resolve(process.cwd(), path), "utf8");
|
const source = readFileSync(resolve(process.cwd(), path), "utf8");
|
||||||
|
|
||||||
expect(source, path).not.toMatch(
|
expect(findForbiddenRouteImports(source, path), path).toEqual([]);
|
||||||
/@\/lib\/db|@\/(?:app\/)?actions(?:\/|["'])/,
|
|
||||||
);
|
|
||||||
expect(source, path).not.toMatch(/AdminSidebarNav|AdminHubChrome/);
|
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([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user