feat(housekeeping): dispatch gated preview routes
This commit is contained in:
1 parent
d8fb8edff6
commit
2ed00bb949
11 files changed
+384
-116
No files matched your search
@@ -103,6 +103,7 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
experimental: {
|
experimental: {
|
||||||
|
authInterrupts: true,
|
||||||
optimizePackageImports: ["lucide-react", "date-fns"],
|
optimizePackageImports: ["lucide-react", "date-fns"],
|
||||||
useTypeScriptCli: true,
|
useTypeScriptCli: true,
|
||||||
hideLogsAfterAbort: true,
|
hideLogsAfterAbort: true,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { HousekeepingPageState } from "@/features/housekeeping/foundation/page/housekeeping-page-state";
|
||||||
|
|
||||||
|
export default async function HousekeepingRouteLoading() {
|
||||||
|
const translate = await getTranslations("pages.housekeeping");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HousekeepingPageState
|
||||||
|
state="loading"
|
||||||
|
title={translate("states.loading.title")}
|
||||||
|
description={translate("states.loading.description")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { forbidden, notFound, redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context";
|
||||||
|
import { buildHousekeepingNavigation } from "@/features/housekeeping/foundation/navigation";
|
||||||
|
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
|
||||||
|
import { createHousekeepingRouteRuntime } from "@/features/housekeeping/foundation/routing/runtime";
|
||||||
|
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
|
||||||
|
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
|
||||||
|
import { HOUSEKEEPING_ROUTE_HANDLERS } from "@/features/housekeeping/route-handlers";
|
||||||
|
|
||||||
|
const MESSAGE_PREFIX = "pages.housekeeping.";
|
||||||
|
|
||||||
|
type HousekeepingSearchParams = Readonly<
|
||||||
|
Record<string, string | readonly string[] | undefined>
|
||||||
|
>;
|
||||||
|
|
||||||
|
function namespaceKey(key: string): string {
|
||||||
|
if (!key.startsWith(MESSAGE_PREFIX)) {
|
||||||
|
throw new Error(`invalid housekeeping message key: ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return key.slice(MESSAGE_PREFIX.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function HousekeepingDomainPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ domain: string; segments?: readonly string[] }>;
|
||||||
|
searchParams?: Promise<HousekeepingSearchParams>;
|
||||||
|
}) {
|
||||||
|
const { domain, segments = [] } = await params;
|
||||||
|
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
||||||
|
const activeDomain = registry.domains.find((entry) => entry.id === domain);
|
||||||
|
|
||||||
|
if (!activeDomain) notFound();
|
||||||
|
|
||||||
|
const runtime = createHousekeepingRouteRuntime(
|
||||||
|
registry,
|
||||||
|
HOUSEKEEPING_ROUTE_HANDLERS,
|
||||||
|
);
|
||||||
|
const context = await getHousekeepingCapabilityContext();
|
||||||
|
|
||||||
|
if (segments.length === 0) {
|
||||||
|
const navigation = buildHousekeepingNavigation(
|
||||||
|
runtime,
|
||||||
|
context,
|
||||||
|
(key) => key,
|
||||||
|
);
|
||||||
|
const activeNavigation = navigation.find((entry) => entry.id === domain);
|
||||||
|
if (!activeNavigation) forbidden();
|
||||||
|
redirect(activeNavigation.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffix = segments
|
||||||
|
.map((segment) => encodeURIComponent(segment))
|
||||||
|
.join("/");
|
||||||
|
const canonicalPath = suffix
|
||||||
|
? `${activeDomain.previewHref}/${suffix}`
|
||||||
|
: activeDomain.previewHref;
|
||||||
|
const match = runtime.match(canonicalPath);
|
||||||
|
|
||||||
|
if (!match || match.domain !== activeDomain.id) notFound();
|
||||||
|
|
||||||
|
const route = activeDomain.routes.find((entry) => entry.id === match.routeId);
|
||||||
|
const handler = runtime.handlers.get(match.routeId);
|
||||||
|
if (!route || !handler) notFound();
|
||||||
|
|
||||||
|
if (
|
||||||
|
!satisfiesCapability(context, activeDomain.capability) ||
|
||||||
|
!satisfiesCapability(context, route.capability)
|
||||||
|
) {
|
||||||
|
forbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
const translate = await getTranslations("pages.housekeeping");
|
||||||
|
|
||||||
|
return handler.render({
|
||||||
|
context,
|
||||||
|
match,
|
||||||
|
searchParams: searchParams ? await searchParams : undefined,
|
||||||
|
translate: (key) => translate(namespaceKey(key) as never),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { forbidden, notFound } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context";
|
import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context";
|
||||||
@@ -38,7 +38,7 @@ export default async function AdminNextDomainLayout({
|
|||||||
if (!activeDomain) notFound();
|
if (!activeDomain) notFound();
|
||||||
|
|
||||||
const context = await getHousekeepingCapabilityContext();
|
const context = await getHousekeepingCapabilityContext();
|
||||||
if (!satisfiesCapability(context, activeDomain.capability)) notFound();
|
if (!satisfiesCapability(context, activeDomain.capability)) forbidden();
|
||||||
|
|
||||||
const translate = await getTranslations("pages.housekeeping");
|
const translate = await getTranslations("pages.housekeeping");
|
||||||
const navigation = buildHousekeepingNavigation(runtime, context, (key) =>
|
const navigation = buildHousekeepingNavigation(runtime, context, (key) =>
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import { notFound } from "next/navigation";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import { HousekeepingPageShell } from "@/features/housekeeping/foundation/page/housekeeping-page-shell";
|
|
||||||
import { HousekeepingPageState } from "@/features/housekeeping/foundation/page/housekeeping-page-state";
|
|
||||||
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
|
|
||||||
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
|
|
||||||
|
|
||||||
const MESSAGE_PREFIX = "pages.housekeeping.";
|
|
||||||
|
|
||||||
function namespaceKey(key: string): string {
|
|
||||||
if (!key.startsWith(MESSAGE_PREFIX)) {
|
|
||||||
throw new Error(`invalid housekeeping message key: ${key}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return key.slice(MESSAGE_PREFIX.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function AdminNextDomainPage({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ domain: string }>;
|
|
||||||
}) {
|
|
||||||
const { domain } = await params;
|
|
||||||
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
|
||||||
const activeDomain = registry.domains.find((entry) => entry.id === domain);
|
|
||||||
|
|
||||||
if (!activeDomain) notFound();
|
|
||||||
|
|
||||||
const translate = await getTranslations("pages.housekeeping");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HousekeepingPageShell
|
|
||||||
title={translate(namespaceKey(activeDomain.labelKey) as never)}
|
|
||||||
description={translate(
|
|
||||||
namespaceKey(activeDomain.descriptionKey) as never,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<HousekeepingPageState
|
|
||||||
state="empty"
|
|
||||||
title={translate("states.empty.title")}
|
|
||||||
description={translate("states.empty.description")}
|
|
||||||
/>
|
|
||||||
</HousekeepingPageShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import Link from "@/components/link";
|
||||||
|
|
||||||
|
export default async function HousekeepingForbidden() {
|
||||||
|
const translate = await getTranslations("pages.housekeeping");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto flex min-h-[60vh] max-w-xl items-center px-4 py-12">
|
||||||
|
<section
|
||||||
|
role="alert"
|
||||||
|
aria-labelledby="housekeeping-forbidden-title"
|
||||||
|
className="w-full rounded-lg border border-[var(--admin-error)] bg-[var(--admin-surface)] p-6"
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
id="housekeeping-forbidden-title"
|
||||||
|
className="text-xl font-semibold text-[var(--admin-text)]"
|
||||||
|
>
|
||||||
|
{translate("states.forbidden.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-[var(--admin-text-muted)]">
|
||||||
|
{translate("states.forbidden.description")}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="mt-5 inline-flex rounded-md border border-[var(--admin-border)] px-3 py-2 text-sm font-medium text-[var(--admin-text)] hover:bg-[var(--admin-canvas)]"
|
||||||
|
>
|
||||||
|
{translate("preview.backToSite")}
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,26 @@
|
|||||||
import { notFound, redirect } from "next/navigation";
|
import { forbidden, redirect } from "next/navigation";
|
||||||
import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context";
|
import { buildHousekeepingNavigation } from "@/features/housekeeping/foundation/navigation";
|
||||||
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
|
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
|
||||||
|
import { createHousekeepingRouteRuntime } from "@/features/housekeeping/foundation/routing/runtime";
|
||||||
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
|
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
|
||||||
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
|
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
|
||||||
|
import { HOUSEKEEPING_ROUTE_HANDLERS } from "@/features/housekeeping/route-handlers";
|
||||||
|
|
||||||
export default async function AdminNextPage() {
|
export default async function HousekeepingPage() {
|
||||||
const context = await getHousekeepingCapabilityContext();
|
const context = await getHousekeepingCapabilityContext();
|
||||||
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
||||||
const firstVisibleDomain = registry.domains.find((domain) =>
|
const runtime = createHousekeepingRouteRuntime(
|
||||||
satisfiesCapability(context, domain.capability),
|
registry,
|
||||||
|
HOUSEKEEPING_ROUTE_HANDLERS,
|
||||||
);
|
);
|
||||||
|
const navigation = buildHousekeepingNavigation(
|
||||||
|
runtime,
|
||||||
|
context,
|
||||||
|
(key) => key,
|
||||||
|
);
|
||||||
|
const firstAccessibleRoute = navigation[0];
|
||||||
|
|
||||||
if (!firstVisibleDomain) notFound();
|
if (!firstAccessibleRoute) forbidden();
|
||||||
|
|
||||||
redirect(firstVisibleDomain.previewHref);
|
redirect(firstAccessibleRoute.href);
|
||||||
}
|
}
|
||||||
@@ -19,6 +19,8 @@ const requiredKeys = [
|
|||||||
"pages.housekeeping.states.partial.description",
|
"pages.housekeeping.states.partial.description",
|
||||||
"pages.housekeeping.states.error.title",
|
"pages.housekeeping.states.error.title",
|
||||||
"pages.housekeeping.states.error.description",
|
"pages.housekeeping.states.error.description",
|
||||||
|
"pages.housekeeping.states.forbidden.title",
|
||||||
|
"pages.housekeeping.states.forbidden.description",
|
||||||
];
|
];
|
||||||
|
|
||||||
const expectedDomainMessages = [
|
const expectedDomainMessages = [
|
||||||
|
|||||||
@@ -15,10 +15,19 @@ const routeMocks = vi.hoisted(() => {
|
|||||||
"navigation.skipToContent": "HK::skip-to-content",
|
"navigation.skipToContent": "HK::skip-to-content",
|
||||||
"navigation.primary": "HK::primary-navigation",
|
"navigation.primary": "HK::primary-navigation",
|
||||||
"navigation.contextual": "HK::contextual-navigation",
|
"navigation.contextual": "HK::contextual-navigation",
|
||||||
|
"domains.operations.title": "HK::operations-title",
|
||||||
|
"domains.operations.description": "Localized Operations description",
|
||||||
"domains.people.title": "HK::people-title",
|
"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.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",
|
||||||
|
"states.forbidden.title": "HK::access-denied",
|
||||||
|
"states.forbidden.description": "Localized insufficient access",
|
||||||
"states.empty.title": "Localized empty title",
|
"states.empty.title": "Localized empty title",
|
||||||
"states.empty.description": "Localized empty description",
|
"states.empty.description": "Localized empty description",
|
||||||
};
|
};
|
||||||
@@ -29,12 +38,106 @@ const routeMocks = vi.hoisted(() => {
|
|||||||
return message;
|
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 {
|
return {
|
||||||
env: {
|
env: {
|
||||||
NODE_ENV: "test" as "development" | "test" | "production",
|
NODE_ENV: "test" as "development" | "test" | "production",
|
||||||
HOUSEKEEPING_NEXT_PREVIEW_ENABLED: true,
|
HOUSEKEEPING_NEXT_PREVIEW_ENABLED: true,
|
||||||
},
|
},
|
||||||
getHousekeepingCapabilityContext: vi.fn(),
|
getHousekeepingCapabilityContext: vi.fn(),
|
||||||
|
forbidden: vi.fn((): never => {
|
||||||
|
throw new Error("NEXT_FORBIDDEN");
|
||||||
|
}),
|
||||||
|
handlers,
|
||||||
|
manifests,
|
||||||
getTranslations: vi.fn(async (namespace: string) => {
|
getTranslations: vi.fn(async (namespace: string) => {
|
||||||
if (namespace !== "pages.housekeeping") {
|
if (namespace !== "pages.housekeeping") {
|
||||||
throw new Error(`Unexpected namespace: ${namespace}`);
|
throw new Error(`Unexpected namespace: ${namespace}`);
|
||||||
@@ -53,6 +156,7 @@ const routeMocks = vi.hoisted(() => {
|
|||||||
|
|
||||||
vi.mock("@/env", () => ({ env: routeMocks.env }));
|
vi.mock("@/env", () => ({ env: routeMocks.env }));
|
||||||
vi.mock("next/navigation", () => ({
|
vi.mock("next/navigation", () => ({
|
||||||
|
forbidden: routeMocks.forbidden,
|
||||||
notFound: routeMocks.notFound,
|
notFound: routeMocks.notFound,
|
||||||
redirect: routeMocks.redirect,
|
redirect: routeMocks.redirect,
|
||||||
}));
|
}));
|
||||||
@@ -62,6 +166,12 @@ vi.mock("next-intl/server", () => ({
|
|||||||
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
|
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
|
||||||
getHousekeepingCapabilityContext: routeMocks.getHousekeepingCapabilityContext,
|
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", () => {
|
vi.mock("@/lib/db", () => {
|
||||||
throw new Error("preview routes must not import the database");
|
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");
|
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 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 AdminNextLayout from "@/app/ase-next/layout";
|
||||||
import AdminNextPage from "@/app/ase-next/page";
|
import AdminNextPage from "@/app/ase-next/page";
|
||||||
|
|
||||||
const routeFiles = [
|
const routeFiles = [
|
||||||
"src/app/ase-next/layout.tsx",
|
"src/app/ase-next/layout.tsx",
|
||||||
|
"src/app/ase-next/forbidden.tsx",
|
||||||
"src/app/ase-next/page.tsx",
|
"src/app/ase-next/page.tsx",
|
||||||
"src/app/ase-next/[domain]/layout.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;
|
] as const;
|
||||||
|
|
||||||
const forbiddenModuleRoots = [
|
const forbiddenModuleRoots = [
|
||||||
@@ -402,54 +514,67 @@ describe("/ase-next preview gate", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("/ase-next first visible domain", () => {
|
describe("/ase-next forbidden boundary", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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(
|
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
|
||||||
capabilityContext([PERMS.ADMIN_DASHBOARD, PERMS.USERS_VIEW]),
|
capabilityContext([PERMS.ADMIN_DASHBOARD, PERMS.USERS_VIEW]),
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(AdminNextPage()).rejects.toThrow(
|
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(
|
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
expect(routeMocks.getTranslations).not.toHaveBeenCalled();
|
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(
|
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
|
||||||
capabilityContext([PERMS.MOD_CFH_VIEW]),
|
capabilityContext([PERMS.MOD_CFH_VIEW]),
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(AdminNextPage()).rejects.toThrow(
|
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.redirect).toHaveBeenCalledWith(
|
||||||
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
|
"/ase-next/people/moderation/cfh",
|
||||||
1,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
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(
|
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
|
||||||
capabilityContext([]),
|
capabilityContext([]),
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(AdminNextPage()).rejects.toThrow("NEXT_NOT_FOUND");
|
await expect(AdminNextPage()).rejects.toThrow("NEXT_FORBIDDEN");
|
||||||
expect(routeMocks.notFound).toHaveBeenCalledTimes(1);
|
expect(routeMocks.forbidden).toHaveBeenCalledTimes(1);
|
||||||
|
expect(routeMocks.notFound).not.toHaveBeenCalled();
|
||||||
expect(routeMocks.redirect).not.toHaveBeenCalled();
|
expect(routeMocks.redirect).not.toHaveBeenCalled();
|
||||||
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("/ase-next/[domain] layout", () => {
|
describe("/ase-next/[domain] layout", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -466,7 +591,7 @@ describe("/ase-next/[domain] layout", () => {
|
|||||||
expect(routeMocks.getTranslations).not.toHaveBeenCalled();
|
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(
|
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
|
||||||
capabilityContext([PERMS.MOD_CFH_VIEW]),
|
capabilityContext([PERMS.MOD_CFH_VIEW]),
|
||||||
);
|
);
|
||||||
@@ -476,14 +601,13 @@ describe("/ase-next/[domain] layout", () => {
|
|||||||
children: createElement("p", null, "Economy body"),
|
children: createElement("p", null, "Economy body"),
|
||||||
params: Promise.resolve({ domain: "economy" }),
|
params: Promise.resolve({ domain: "economy" }),
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow("NEXT_NOT_FOUND");
|
).rejects.toThrow("NEXT_FORBIDDEN");
|
||||||
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
|
expect(routeMocks.forbidden).toHaveBeenCalledTimes(1);
|
||||||
1,
|
expect(routeMocks.notFound).not.toHaveBeenCalled();
|
||||||
);
|
|
||||||
expect(routeMocks.getTranslations).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(
|
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
|
||||||
capabilityContext([PERMS.MOD_CFH_VIEW]),
|
capabilityContext([PERMS.MOD_CFH_VIEW]),
|
||||||
);
|
);
|
||||||
@@ -496,59 +620,98 @@ describe("/ase-next/[domain] layout", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(html).toContain("refreshed-moderator");
|
expect(html).toContain("refreshed-moderator");
|
||||||
expect(html).toContain("HK::skip-to-content");
|
expect(html).toContain("HK::people-title");
|
||||||
expect(html).toContain("HK::primary-navigation");
|
expect(html).toContain("HK::people-moderation");
|
||||||
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("People body");
|
expect(html).toContain("People body");
|
||||||
expect(html).not.toContain("Localized Economy");
|
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(
|
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
expect(routeMocks.getTranslations).toHaveBeenCalledTimes(1);
|
expect(routeMocks.getTranslations).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe("/ase-next/[domain]/[[...segments]] page", () => {
|
||||||
describe("/ase-next/[domain] page", () => {
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the real localized manifest and empty state without reloading access", async () => {
|
it("rejects an unknown domain before loading capability context", 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 () => {
|
|
||||||
await expect(
|
await expect(
|
||||||
AdminNextDomainPage({
|
AdminNextDomainPage({
|
||||||
params: Promise.resolve({ domain: "unknown" }),
|
params: Promise.resolve({ domain: "unknown", segments: ["users"] }),
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow("NEXT_NOT_FOUND");
|
).rejects.toThrow("NEXT_NOT_FOUND");
|
||||||
expect(routeMocks.getHousekeepingCapabilityContext).not.toHaveBeenCalled();
|
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", () => {
|
describe("preview route import boundary", () => {
|
||||||
it("rejects normalized forbidden imports and legacy chrome in real routes", () => {
|
it("rejects normalized forbidden imports and legacy chrome in real routes", () => {
|
||||||
for (const path of routeFiles) {
|
for (const path of routeFiles) {
|
||||||
@@ -702,8 +865,8 @@ describe("preview route import boundary", () => {
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
"nested template-expression dynamic action import",
|
"nested template-expression dynamic action import",
|
||||||
"src/app/ase-next/[domain]/page.tsx",
|
"src/app/ase-next/[domain]/[[...segments]]/page.tsx",
|
||||||
`const x = \`${interpolationOpen}ready ? \`${interpolationOpen}import("../../../actions/nested")}\` : ""}\`;`,
|
`const x = \`${interpolationOpen}ready ? \`${interpolationOpen}import("../../../../actions/nested")}\` : ""}\`;`,
|
||||||
"src/actions/nested",
|
"src/actions/nested",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -762,8 +925,8 @@ describe("preview route import boundary", () => {
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
"domain relative permissions export",
|
"domain relative permissions export",
|
||||||
"src/app/ase-next/[domain]/page.tsx",
|
"src/app/ase-next/[domain]/[[...segments]]/page.tsx",
|
||||||
'export { getAdminContext } from "../../../lib/permissions";',
|
'export { getAdminContext } from "../../../../lib/permissions";',
|
||||||
"src/lib/permissions",
|
"src/lib/permissions",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -3318,6 +3318,10 @@
|
|||||||
"error": {
|
"error": {
|
||||||
"title": "Unable to load housekeeping",
|
"title": "Unable to load housekeeping",
|
||||||
"description": "Try again later or contact an administrator."
|
"description": "Try again later or contact an administrator."
|
||||||
|
},
|
||||||
|
"forbidden": {
|
||||||
|
"title": "Access denied",
|
||||||
|
"description": "Your account does not have permission to use this housekeeping area."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3317,6 +3317,10 @@
|
|||||||
"error": {
|
"error": {
|
||||||
"title": "Impossibile caricare housekeeping",
|
"title": "Impossibile caricare housekeeping",
|
||||||
"description": "Riprova più tardi o contatta un amministratore."
|
"description": "Riprova più tardi o contatta un amministratore."
|
||||||
|
},
|
||||||
|
"forbidden": {
|
||||||
|
"title": "Accesso negato",
|
||||||
|
"description": "Il tuo account non dispone dei permessi necessari per usare questa area di housekeeping."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user