diff --git a/src/features/housekeeping/foundation/preview-route-contract.test.ts b/src/features/housekeeping/foundation/preview-route-contract.test.ts index a78ec0ec..cd5ffa79 100644 --- a/src/features/housekeeping/foundation/preview-route-contract.test.ts +++ b/src/features/housekeeping/foundation/preview-route-contract.test.ts @@ -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 = { - "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([]); + }); });