feat(housekeeping): dispatch gated preview routes

This commit is contained in:
Simo committed 2026-08-31 18:45:46 +02:00
1 parent d8fb8edff6
commit 2ed00bb949
11 files changed
+384 -116

No files matched your search

@@ -19,6 +19,8 @@ const requiredKeys = [
"pages.housekeeping.states.partial.description",
"pages.housekeeping.states.error.title",
"pages.housekeeping.states.error.description",
"pages.housekeeping.states.forbidden.title",
"pages.housekeeping.states.forbidden.description",
];
const expectedDomainMessages = [
@@ -15,10 +15,19 @@ const routeMocks = vi.hoisted(() => {
"navigation.skipToContent": "HK::skip-to-content",
"navigation.primary": "HK::primary-navigation",
"navigation.contextual": "HK::contextual-navigation",
"domains.operations.title": "HK::operations-title",
"domains.operations.description": "Localized Operations description",
"domains.people.title": "HK::people-title",
"routes.operations.queue": "HK::operations-queue",
"routes.people.users": "HK::people-users",
"routes.people.moderation": "HK::people-moderation",
"routes.people.tickets": "HK::people-tickets",
"routes.economy.catalog": "HK::economy-catalog",
"domains.people.description": "Localized People description",
"domains.economy.title": "Localized Economy",
"domains.economy.description": "Localized Economy description",
"states.forbidden.title": "HK::access-denied",
"states.forbidden.description": "Localized insufficient access",
"states.empty.title": "Localized empty title",
"states.empty.description": "Localized empty description",
};
@@ -29,12 +38,106 @@ const routeMocks = vi.hoisted(() => {
return message;
});
const capability = (...slugs: string[]) => ({
mode: "any" as const,
slugs,
});
const manifests = [
{
id: "operations",
labelKey: "pages.housekeeping.domains.operations.title",
descriptionKey: "pages.housekeeping.domains.operations.description",
iconId: "inbox",
previewHref: "/ase-next/operations",
capability: capability("admin.dashboard"),
landingRouteId: "operations.queue",
routes: [
{
id: "operations.queue",
labelKey: "pages.housekeeping.routes.operations.queue",
href: "/ase-next/operations/queue",
capability: capability("admin.dashboard"),
},
],
searchProviders: [],
inboxSources: [],
widgets: [],
},
{
id: "people",
labelKey: "pages.housekeeping.domains.people.title",
descriptionKey: "pages.housekeeping.domains.people.description",
iconId: "users",
previewHref: "/ase-next/people",
capability: capability(
"admin.users.view",
"admin.tickets.view",
"mod.cfh.view",
),
landingRouteId: "people.users",
routes: [
{
id: "people.users",
labelKey: "pages.housekeeping.routes.people.users",
href: "/ase-next/people/users",
capability: capability("admin.users.view"),
},
{
id: "people.moderation",
labelKey: "pages.housekeeping.routes.people.moderation",
href: "/ase-next/people/moderation/cfh",
capability: capability("mod.cfh.view"),
},
{
id: "people.tickets",
labelKey: "pages.housekeeping.routes.people.tickets",
href: "/ase-next/people/support/tickets",
capability: capability("admin.tickets.view"),
},
],
searchProviders: [],
inboxSources: [],
widgets: [],
},
{
id: "economy",
labelKey: "pages.housekeeping.domains.economy.title",
descriptionKey: "pages.housekeeping.domains.economy.description",
iconId: "gem",
previewHref: "/ase-next/economy",
capability: capability("admin.catalog.view"),
landingRouteId: "economy.catalog",
routes: [
{
id: "economy.catalog",
labelKey: "pages.housekeeping.routes.economy.catalog",
href: "/ase-next/economy/catalog",
capability: capability("admin.catalog.view"),
},
],
searchProviders: [],
inboxSources: [],
widgets: [],
},
];
const handlers = manifests.flatMap((manifest) =>
manifest.routes.map((route) => ({
routeId: route.id,
render: async () => `Rendered ${route.id}`,
})),
);
return {
env: {
NODE_ENV: "test" as "development" | "test" | "production",
HOUSEKEEPING_NEXT_PREVIEW_ENABLED: true,
},
getHousekeepingCapabilityContext: vi.fn(),
forbidden: vi.fn((): never => {
throw new Error("NEXT_FORBIDDEN");
}),
handlers,
manifests,
getTranslations: vi.fn(async (namespace: string) => {
if (namespace !== "pages.housekeeping") {
throw new Error(`Unexpected namespace: ${namespace}`);
@@ -53,6 +156,7 @@ const routeMocks = vi.hoisted(() => {
vi.mock("@/env", () => ({ env: routeMocks.env }));
vi.mock("next/navigation", () => ({
forbidden: routeMocks.forbidden,
notFound: routeMocks.notFound,
redirect: routeMocks.redirect,
}));
@@ -62,6 +166,12 @@ vi.mock("next-intl/server", () => ({
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: routeMocks.getHousekeepingCapabilityContext,
}));
vi.mock("@/features/housekeeping/manifests", () => ({
HOUSEKEEPING_MANIFESTS: routeMocks.manifests,
}));
vi.mock("@/features/housekeeping/route-handlers", () => ({
HOUSEKEEPING_ROUTE_HANDLERS: routeMocks.handlers,
}));
vi.mock("@/lib/db", () => {
throw new Error("preview routes must not import the database");
});
@@ -78,16 +188,18 @@ vi.mock("@/app/actions", () => {
throw new Error("preview routes must not import actions");
});
import AdminNextDomainPage from "@/app/ase-next/[domain]/[[...segments]]/page";
import AdminNextDomainLayout from "@/app/ase-next/[domain]/layout";
import AdminNextDomainPage from "@/app/ase-next/[domain]/page";
import AdminNextForbidden from "@/app/ase-next/forbidden";
import AdminNextLayout from "@/app/ase-next/layout";
import AdminNextPage from "@/app/ase-next/page";
const routeFiles = [
"src/app/ase-next/layout.tsx",
"src/app/ase-next/forbidden.tsx",
"src/app/ase-next/page.tsx",
"src/app/ase-next/[domain]/layout.tsx",
"src/app/ase-next/[domain]/page.tsx",
"src/app/ase-next/[domain]/[[...segments]]/page.tsx",
] as const;
const forbiddenModuleRoots = [
@@ -402,54 +514,67 @@ describe("/ase-next preview gate", () => {
);
});
describe("/ase-next first visible domain", () => {
describe("/ase-next forbidden boundary", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("redirects an administrator to Operations in locked registry order", async () => {
it("renders localized access denial without sensitive details", async () => {
const html = await renderRoute(AdminNextForbidden());
expect(html).toContain("HK::access-denied");
expect(html).toContain("Localized insufficient access");
expect(html).toContain('href="/"');
expect(html).toContain("HK::back-to-site");
expect(html).not.toMatch(/admin\.[a-z.]+|stack|database/i);
});
});
describe("/ase-next first accessible route", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("redirects an administrator to the Operations landing route", async () => {
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
capabilityContext([PERMS.ADMIN_DASHBOARD, PERMS.USERS_VIEW]),
);
await expect(AdminNextPage()).rejects.toThrow(
"NEXT_REDIRECT:/ase-next/operations",
"NEXT_REDIRECT:/ase-next/operations/queue",
);
expect(routeMocks.redirect).toHaveBeenCalledWith(
"/ase-next/operations/queue",
);
expect(routeMocks.redirect).toHaveBeenCalledWith("/ase-next/operations");
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
1,
);
expect(routeMocks.getTranslations).not.toHaveBeenCalled();
});
it("redirects a moderator with only an approved mod view capability to People", async () => {
it("redirects a moderator to the first accessible People route", async () => {
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
capabilityContext([PERMS.MOD_CFH_VIEW]),
);
await expect(AdminNextPage()).rejects.toThrow(
"NEXT_REDIRECT:/ase-next/people",
"NEXT_REDIRECT:/ase-next/people/moderation/cfh",
);
expect(routeMocks.redirect).toHaveBeenCalledWith("/ase-next/people");
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
1,
expect(routeMocks.redirect).toHaveBeenCalledWith(
"/ase-next/people/moderation/cfh",
);
});
it("returns 404 when the operator has no visible domain", async () => {
it("returns forbidden when the operator has no accessible route", async () => {
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
capabilityContext([]),
);
await expect(AdminNextPage()).rejects.toThrow("NEXT_NOT_FOUND");
expect(routeMocks.notFound).toHaveBeenCalledTimes(1);
await expect(AdminNextPage()).rejects.toThrow("NEXT_FORBIDDEN");
expect(routeMocks.forbidden).toHaveBeenCalledTimes(1);
expect(routeMocks.notFound).not.toHaveBeenCalled();
expect(routeMocks.redirect).not.toHaveBeenCalled();
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
1,
);
});
});
describe("/ase-next/[domain] layout", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -466,7 +591,7 @@ describe("/ase-next/[domain] layout", () => {
expect(routeMocks.getTranslations).not.toHaveBeenCalled();
});
it("rejects a known domain that the operator cannot access", async () => {
it("returns forbidden for a known domain the operator cannot access", async () => {
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
capabilityContext([PERMS.MOD_CFH_VIEW]),
);
@@ -476,14 +601,13 @@ describe("/ase-next/[domain] layout", () => {
children: createElement("p", null, "Economy body"),
params: Promise.resolve({ domain: "economy" }),
}),
).rejects.toThrow("NEXT_NOT_FOUND");
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
1,
);
).rejects.toThrow("NEXT_FORBIDDEN");
expect(routeMocks.forbidden).toHaveBeenCalledTimes(1);
expect(routeMocks.notFound).not.toHaveBeenCalled();
expect(routeMocks.getTranslations).not.toHaveBeenCalled();
});
it("renders the shell without exposing a domain that has no concrete routes", async () => {
it("renders a localized shell from one refreshed capability context", async () => {
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
capabilityContext([PERMS.MOD_CFH_VIEW]),
);
@@ -496,59 +620,98 @@ describe("/ase-next/[domain] layout", () => {
);
expect(html).toContain("refreshed-moderator");
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).not.toContain("HK::people-title");
expect(html).toContain("HK::people-title");
expect(html).toContain("HK::people-moderation");
expect(html).toContain("People body");
expect(html).not.toContain("Localized Economy");
expect(routeMocks.translate).not.toHaveBeenCalledWith(
"domains.people.title",
);
expect(routeMocks.translate).not.toHaveBeenCalledWith(
"domains.economy.title",
);
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
1,
);
expect(routeMocks.getTranslations).toHaveBeenCalledTimes(1);
});
});
describe("/ase-next/[domain] page", () => {
describe("/ase-next/[domain]/[[...segments]] page", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders the real localized manifest and empty state without reloading access", async () => {
const html = await renderRoute(
AdminNextDomainPage({
params: Promise.resolve({ domain: "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");
expect(routeMocks.getHousekeepingCapabilityContext).not.toHaveBeenCalled();
expect(routeMocks.getTranslations).toHaveBeenCalledTimes(1);
});
it("rejects an unknown domain before translating", async () => {
it("rejects an unknown domain before loading capability context", async () => {
await expect(
AdminNextDomainPage({
params: Promise.resolve({ domain: "unknown" }),
params: Promise.resolve({ domain: "unknown", segments: ["users"] }),
}),
).rejects.toThrow("NEXT_NOT_FOUND");
expect(routeMocks.getHousekeepingCapabilityContext).not.toHaveBeenCalled();
expect(routeMocks.getTranslations).not.toHaveBeenCalled();
});
it("redirects a bare domain to its accessible handled landing page", async () => {
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
capabilityContext([PERMS.USERS_VIEW]),
);
await expect(
AdminNextDomainPage({
params: Promise.resolve({ domain: "people", segments: [] }),
}),
).rejects.toThrow("NEXT_REDIRECT:/ase-next/people/users");
expect(routeMocks.redirect).toHaveBeenCalledWith("/ase-next/people/users");
});
it("falls back to the first accessible handled route", async () => {
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
capabilityContext([PERMS.TICKETS_VIEW]),
);
await expect(
AdminNextDomainPage({
params: Promise.resolve({ domain: "people", segments: [] }),
}),
).rejects.toThrow("NEXT_REDIRECT:/ase-next/people/support/tickets");
});
it("renders a known permitted handled route", async () => {
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
capabilityContext([PERMS.USERS_VIEW]),
);
const html = await renderRoute(
AdminNextDomainPage({
params: Promise.resolve({ domain: "people", segments: ["users"] }),
}),
);
expect(html).toContain("Rendered people.users");
expect(routeMocks.getTranslations).toHaveBeenCalledTimes(1);
});
it("returns forbidden for a known route without capability", async () => {
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
capabilityContext([PERMS.TICKETS_VIEW]),
);
await expect(
AdminNextDomainPage({
params: Promise.resolve({ domain: "people", segments: ["users"] }),
}),
).rejects.toThrow("NEXT_FORBIDDEN");
expect(routeMocks.forbidden).toHaveBeenCalledTimes(1);
expect(routeMocks.notFound).not.toHaveBeenCalled();
});
it("returns not found for an unknown path", async () => {
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
capabilityContext([PERMS.USERS_VIEW]),
);
await expect(
AdminNextDomainPage({
params: Promise.resolve({ domain: "people", segments: ["missing"] }),
}),
).rejects.toThrow("NEXT_NOT_FOUND");
expect(routeMocks.notFound).toHaveBeenCalledTimes(1);
expect(routeMocks.forbidden).not.toHaveBeenCalled();
});
});
describe("preview route import boundary", () => {
it("rejects normalized forbidden imports and legacy chrome in real routes", () => {
for (const path of routeFiles) {
@@ -702,8 +865,8 @@ describe("preview route import boundary", () => {
],
[
"nested template-expression dynamic action import",
"src/app/ase-next/[domain]/page.tsx",
`const x = \`${interpolationOpen}ready ? \`${interpolationOpen}import("../../../actions/nested")}\` : ""}\`;`,
"src/app/ase-next/[domain]/[[...segments]]/page.tsx",
`const x = \`${interpolationOpen}ready ? \`${interpolationOpen}import("../../../../actions/nested")}\` : ""}\`;`,
"src/actions/nested",
],
[
@@ -762,8 +925,8 @@ describe("preview route import boundary", () => {
],
[
"domain relative permissions export",
"src/app/ase-next/[domain]/page.tsx",
'export { getAdminContext } from "../../../lib/permissions";',
"src/app/ase-next/[domain]/[[...segments]]/page.tsx",
'export { getAdminContext } from "../../../../lib/permissions";',
"src/lib/permissions",
],
[