feat(housekeeping): cut over administration to ase

This commit is contained in:
Simo committed 2026-08-30 20:35:22 +02:00
1 parent c9e35cf602
commit 2b8f73a91d
457 files changed
+589 -55936

No files matched your search

@@ -9,7 +9,6 @@ import type { HousekeepingCapabilityContext } from "./contracts";
const routeMocks = vi.hoisted(() => {
const messages: Record<string, string> = {
"preview.badge": "HK::preview-badge",
"preview.commandDisabled": "HK::command-disabled",
"preview.backToSite": "HK::back-to-site",
"navigation.skipToContent": "HK::skip-to-content",
@@ -161,10 +160,6 @@ const routeMocks = vi.hoisted(() => {
});
return {
env: {
NODE_ENV: "test" as "development" | "test" | "production",
HOUSEKEEPING_NEXT_PREVIEW_ENABLED: true,
},
getHousekeepingCapabilityContext: vi.fn(),
getTranslations: vi.fn(async (namespace: string) => {
if (namespace !== "pages.housekeeping") {
@@ -189,7 +184,6 @@ const routeMocks = vi.hoisted(() => {
};
});
vi.mock("@/env", () => ({ env: routeMocks.env }));
vi.mock("next/navigation", () => ({
notFound: routeMocks.notFound,
redirect: routeMocks.redirect,
@@ -201,19 +195,19 @@ vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: routeMocks.getHousekeepingCapabilityContext,
}));
vi.mock("@/lib/db", () => {
throw new Error("preview routes must not import the database");
throw new Error("route entrypoints must not import the database");
});
vi.mock("@/lib/auth", () => {
throw new Error("preview routes must not call auth directly");
throw new Error("route entrypoints must not call auth directly");
});
vi.mock("@/lib/permissions", () => {
throw new Error("preview routes must not reload permissions directly");
throw new Error("route entrypoints must not reload permissions directly");
});
vi.mock("@/actions", () => {
throw new Error("preview routes must not import actions");
throw new Error("route entrypoints must not import actions");
});
vi.mock("@/app/actions", () => {
throw new Error("preview routes must not import actions");
throw new Error("route entrypoints must not import actions");
});
vi.mock("@/features/housekeeping/manifests", async (importOriginal) => {
const actual =
@@ -299,16 +293,16 @@ vi.mock("@/features/housekeeping/route-handlers", () => ({
],
}));
import AdminNextDomainPage from "@/app/ase-next/[domain]/[[...segments]]/page";
import AdminNextDomainLayout from "@/app/ase-next/[domain]/layout";
import AdminNextLayout from "@/app/ase-next/layout";
import AdminNextPage from "@/app/ase-next/page";
import HousekeepingDomainPage from "@/app/ase/[domain]/[[...segments]]/page";
import HousekeepingDomainLayout from "@/app/ase/[domain]/layout";
import HousekeepingLayout from "@/app/ase/layout";
import HousekeepingPage from "@/app/ase/page";
const routeFiles = [
"src/app/ase-next/layout.tsx",
"src/app/ase-next/page.tsx",
"src/app/ase-next/[domain]/layout.tsx",
"src/app/ase-next/[domain]/[[...segments]]/page.tsx",
"src/app/ase/layout.tsx",
"src/app/ase/page.tsx",
"src/app/ase/[domain]/layout.tsx",
"src/app/ase/[domain]/[[...segments]]/page.tsx",
] as const;
const forbiddenModuleRoots = [
@@ -581,49 +575,20 @@ async function renderRoute(route: ReactNode | Promise<ReactNode>) {
return renderToStaticMarkup(await route);
}
describe("/ase-next preview gate", () => {
beforeEach(() => {
vi.clearAllMocks();
routeMocks.env.NODE_ENV = "test";
routeMocks.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = true;
describe("/ase canonical layout", () => {
it("renders without a runtime preview gate", async () => {
const html = await renderRoute(
HousekeepingLayout({
children: createElement("p", null, "Canonical child"),
}),
);
expect(html).toContain("Canonical child");
expect(routeMocks.notFound).not.toHaveBeenCalled();
});
it.each([
["production", true],
["production", false],
["development", false],
] as const)("returns 404 for NODE_ENV=%s flag=%s", async (nodeEnv, flag) => {
routeMocks.env.NODE_ENV = nodeEnv;
routeMocks.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = flag;
await expect(async () =>
renderRoute(
AdminNextLayout({
children: createElement("p", null, "Preview child"),
}),
),
).rejects.toThrow("NEXT_NOT_FOUND");
expect(routeMocks.notFound).toHaveBeenCalledTimes(1);
});
it.each(["development", "test"] as const)(
"renders children in %s when explicitly enabled",
async (nodeEnv) => {
routeMocks.env.NODE_ENV = nodeEnv;
const html = await renderRoute(
AdminNextLayout({
children: createElement("p", null, "Preview child"),
}),
);
expect(html).toContain("Preview child");
expect(routeMocks.notFound).not.toHaveBeenCalled();
},
);
});
describe("/ase-next operational home", () => {
describe("/ase operational home", () => {
beforeEach(() => {
vi.clearAllMocks();
});
@@ -633,13 +598,12 @@ describe("/ase-next operational home", () => {
capabilityContext([PERMS.ADMIN_DASHBOARD, PERMS.USERS_VIEW]),
);
const html = await renderRoute(AdminNextPage());
const html = await renderRoute(HousekeepingPage());
expect(html).toContain("Rendered operations.workspace");
expect(html).toContain("HK::operations-workspace");
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith(
expect.objectContaining({
match: expect.objectContaining({ routeId: "operations.workspace" }),
surface: "preview",
}),
);
expect(routeMocks.redirect).not.toHaveBeenCalled();
@@ -654,10 +618,10 @@ describe("/ase-next operational home", () => {
capabilityContext([PERMS.MOD_CFH_VIEW]),
);
await expect(AdminNextPage()).rejects.toThrow(
"NEXT_REDIRECT:/ase-next/people/users",
await expect(HousekeepingPage()).rejects.toThrow(
"NEXT_REDIRECT:/ase/people/users",
);
expect(routeMocks.redirect).toHaveBeenCalledWith("/ase-next/people/users");
expect(routeMocks.redirect).toHaveBeenCalledWith("/ase/people/users");
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
1,
);
@@ -668,7 +632,7 @@ describe("/ase-next operational home", () => {
capabilityContext([]),
);
await expect(AdminNextPage()).rejects.toThrow("NEXT_NOT_FOUND");
await expect(HousekeepingPage()).rejects.toThrow("NEXT_NOT_FOUND");
expect(routeMocks.notFound).toHaveBeenCalledTimes(1);
expect(routeMocks.redirect).not.toHaveBeenCalled();
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
@@ -677,14 +641,14 @@ describe("/ase-next operational home", () => {
});
});
describe("/ase-next/[domain] layout", () => {
describe("/ase/[domain] layout", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("rejects an unknown domain before loading capability context", async () => {
await expect(
AdminNextDomainLayout({
HousekeepingDomainLayout({
children: createElement("p", null, "Unknown body"),
params: Promise.resolve({ domain: "unknown" }),
}),
@@ -699,7 +663,7 @@ describe("/ase-next/[domain] layout", () => {
);
await expect(
AdminNextDomainLayout({
HousekeepingDomainLayout({
children: createElement("p", null, "Economy body"),
params: Promise.resolve({ domain: "economy" }),
}),
@@ -716,7 +680,7 @@ describe("/ase-next/[domain] layout", () => {
);
const html = await renderRoute(
AdminNextDomainLayout({
HousekeepingDomainLayout({
children: createElement("p", null, "People body"),
params: Promise.resolve({ domain: "people" }),
}),
@@ -727,7 +691,7 @@ describe("/ase-next/[domain] layout", () => {
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).not.toContain("HK::preview-badge");
expect(html).toContain("HK::back-to-site");
expect(html).toContain("HK::people-title");
expect(html).toContain("People body");
@@ -748,7 +712,7 @@ describe("/ase-next/[domain] layout", () => {
});
const html = await renderRoute(
AdminNextDomainLayout({
HousekeepingDomainLayout({
children: createElement("p", null, "System body"),
params: Promise.resolve({ domain: "system" }),
}),
@@ -780,17 +744,17 @@ describe("/ase-next/[domain] layout", () => {
});
});
describe("/ase-next/[domain]/[[...segments]] page", () => {
describe("/ase/[domain]/[[...segments]] page", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("maps a permitted preview path and renders its handler with one context", async () => {
it("maps a permitted canonical path and renders its handler with one context", async () => {
const context = capabilityContext([PERMS.MOD_CFH_VIEW]);
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(context);
const html = await renderRoute(
AdminNextDomainPage({
HousekeepingDomainPage({
params: Promise.resolve({ domain: "people", segments: ["users"] }),
}),
);
@@ -802,7 +766,6 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledTimes(1);
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
context,
surface: "preview",
translate: expect.any(Function),
match: {
routeId: "people.users",
@@ -814,12 +777,12 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
expect(routeMocks.getTranslations).toHaveBeenCalledOnce();
});
it("maps nested preview segments to canonical dynamic parameters", async () => {
it("maps nested canonical segments to canonical dynamic parameters", async () => {
const context = capabilityContext([PERMS.USERS_VIEW]);
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(context);
await renderRoute(
AdminNextDomainPage({
HousekeepingDomainPage({
params: Promise.resolve({
domain: "people",
segments: ["users", "42"],
@@ -829,7 +792,6 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
context,
surface: "preview",
translate: expect.any(Function),
match: {
routeId: "people.user-detail",
@@ -845,7 +807,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(context);
await renderRoute(
AdminNextDomainPage({
HousekeepingDomainPage({
params: Promise.resolve({
domain: "people",
segments: ["tools", "a+b[1]"],
@@ -855,7 +817,6 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
context,
surface: "preview",
translate: expect.any(Function),
match: {
routeId: "people.literal-tool",
@@ -872,7 +833,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
);
await expect(
AdminNextDomainPage({
HousekeepingDomainPage({
params: Promise.resolve({ domain: "people", segments: ["bans"] }),
}),
).rejects.toThrow("NEXT_NOT_FOUND");
@@ -884,7 +845,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
it("rejects an unknown route before loading capability context", async () => {
await expect(
AdminNextDomainPage({
HousekeepingDomainPage({
params: Promise.resolve({ domain: "people", segments: ["unknown"] }),
}),
).rejects.toThrow("NEXT_NOT_FOUND");
@@ -908,247 +869,247 @@ describe("preview route import boundary", () => {
it.each([
[
"relative database import with resolver extension",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'import db from "../../lib/db.js";',
"src/lib/db",
],
[
"aliased database import with resolver extension",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'import db from "@/lib/db.js";',
"src/lib/db",
],
[
"action root import with resolver extension",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'import actions from "../../actions.mjs";',
"src/actions",
],
[
"legacy mod root import with resolver extension",
"src/app/ase-next/layout.tsx",
"src/app/ase/layout.tsx",
'import mod from "../mod.cjs";',
"src/app/mod",
],
[
"database import with query suffix",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'import db from "../../lib/db?server-only";',
"src/lib/db",
],
[
"action import with hash suffix",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'import("../../actions/users#server")',
"src/actions/users",
],
[
"Windows-style relative database import",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
String.raw`import db from "..\\..\\lib\\db";`,
"src/lib/db",
],
[
"Windows-style aliased database import",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
String.raw`import db from "@\\lib\\db";`,
"src/lib/db",
],
[
"percent-encoded database import",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'import db from "../../lib/%64%62";',
"src/lib/db",
],
[
"optional CommonJS database require",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'require?.("../../lib/db")',
"src/lib/db",
],
[
"module database require",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'module.require("../../lib/db")',
"src/lib/db",
],
[
"require.resolve database access",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'require.resolve("../../lib/db")',
"src/lib/db",
],
[
"optional module database require",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'module.require?.("../../lib/db")',
"src/lib/db",
],
[
"optional require.resolve database access",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'require.resolve?.("../../lib/db")',
"src/lib/db",
],
[
"TypeScript import-equals database access",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'import db = require("../../lib/db");',
"src/lib/db",
],
[
"U+2028 line-continuation database import",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'import db from "../\\' + "\u2028" + '../lib/db";',
"src/lib/db",
],
[
"U+2029 line-continuation database import",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'import db from "../\\' + "\u2029" + '../lib/db";',
"src/lib/db",
],
[
"parenthesized dynamic action import",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'import(("../../actions/users"))',
"src/actions/users",
],
[
"regex-brace template-expression action import",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
`const x = \`${interpolationOpen}/}/.test(value) ? import("../../actions/users") : null}\`;`,
"src/actions/users",
],
[
"CommonJS database require",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'require("../../lib/db")',
"src/lib/db",
],
[
"template-literal dynamic action import",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
"import(`../../actions/users`)",
"src/actions/users",
],
[
"TypeScript-asserted dynamic action import",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'import(("../../actions/users" as string))',
"src/actions/users",
],
[
"template-expression dynamic action import",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
`const x = \`${interpolationOpen}import("../../actions/users")}\`;`,
"src/actions/users",
],
[
"nested template-expression dynamic action import",
"src/app/ase-next/[domain]/[[...segments]]/page.tsx",
"src/app/ase/[domain]/[[...segments]]/page.tsx",
`const x = \`${interpolationOpen}ready ? \`${interpolationOpen}import("../../../../actions/nested")}\` : ""}\`;`,
"src/actions/nested",
],
[
"unicode escaped dynamic app-action import",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'import("\\u002e\\u002e/actions/users")',
"src/app/actions/users",
],
[
"code-point escaped dynamic app-action import",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'import("\\u{2e}\\u{2e}/actions/users")',
"src/app/actions/users",
],
[
"hex escaped export-from auth import",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'export * from "\\x2e\\x2e/\\x2e\\x2e/lib/auth";',
"src/lib/auth",
],
[
"escaped-slash permissions import",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'import permissions from "..\\/..\\/lib\\/permissions";',
"src/lib/permissions",
],
[
"unknown escape database import",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'import db from "../../\\lib/db";',
"src/lib/db",
],
[
"line-continuation database import",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'import db from "../\\' + "\n" + '../lib/db";',
"src/lib/db",
],
[
"aliased database descendant import",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'import { query } from "@/lib/db/query";',
"src/lib/db/query",
],
[
"root relative database import",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'import { db } from "../../lib/db";',
"src/lib/db",
],
[
"domain relative auth side-effect import",
"src/app/ase-next/[domain]/layout.tsx",
"src/app/ase/[domain]/layout.tsx",
'import "../../../lib/auth";',
"src/lib/auth",
],
[
"domain relative permissions export",
"src/app/ase-next/[domain]/[[...segments]]/page.tsx",
"src/app/ase/[domain]/[[...segments]]/page.tsx",
'export { getAdminContext } from "../../../../lib/permissions";',
"src/lib/permissions",
],
[
"root relative action dynamic import",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'import("../../actions/users")',
"src/actions/users",
],
[
"root relative app action export",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'export * from "../actions";',
"src/app/actions",
],
[
"domain relative legacy admin import",
"src/app/ase-next/[domain]/layout.tsx",
"src/app/ase/[domain]/layout.tsx",
'import page from "../../admin/users/page";',
"src/app/admin/users/page",
],
[
"root relative legacy mod dynamic import",
"src/app/ase-next/layout.tsx",
"src/app/ase/layout.tsx",
'import("../mod/users/page")',
"src/app/mod/users/page",
],
[
"Prisma TypeScript import type",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'type PrismaClient = import("@prisma/client").PrismaClient;',
"@prisma/client",
],
[
"Drizzle package import",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'import { sql } from "drizzle-orm";',
"drizzle-orm",
],
[
"mysql2 package import",
"src/app/ase-next/page.tsx",
"src/app/ase/page.tsx",
'import type { Pool } from "mysql2";',
"mysql2",
],
@@ -1181,7 +1142,7 @@ describe("preview route import boundary", () => {
],
] as const)("fails closed for non-literal %s", (_name, source, violation) => {
expect(
findRouteImportBoundaryViolations(source, "src/app/ase-next/page.tsx"),
findRouteImportBoundaryViolations(source, "src/app/ase/page.tsx"),
).toContain(violation);
});
@@ -1190,7 +1151,7 @@ describe("preview route import boundary", () => {
'import database from "@/lib/database.js?raw";',
'import dbTools from "../../lib/db-tools.ts";',
'import auth from "../../lib/authentication";',
'import preview from "../ase-next-shared";',
'import preview from "../ase-shared";',
'import prismaTools from "@prisma/client-tools";',
'import drizzleTools from "drizzle-orm-kit";',
'import mysqlTools from "mysql2-wrapper";',
@@ -1200,7 +1161,7 @@ describe("preview route import boundary", () => {
].join("\n");
expect(
findRouteImportBoundaryViolations(source, "src/app/ase-next/page.tsx"),
findRouteImportBoundaryViolations(source, "src/app/ase/page.tsx"),
).toEqual([]);
});
});