From d19ba8800540d6d3c3de2c4152ef616b843adf35 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 25 Aug 2026 20:45:40 +0200 Subject: [PATCH] feat: add gated housekeeping foundation preview --- .env.example | 2 + src/app/admin-next/[domain]/layout.tsx | 59 ++++ src/app/admin-next/[domain]/page.tsx | 45 +++ src/app/admin-next/layout.tsx | 17 + src/app/admin-next/page.tsx | 17 + src/env.ts | 4 + .../foundation/preview-gate.test.ts | 58 ++++ .../housekeeping/foundation/preview-gate.ts | 6 + .../foundation/preview-route-contract.test.ts | 308 ++++++++++++++++++ 9 files changed, 516 insertions(+) create mode 100644 src/app/admin-next/[domain]/layout.tsx create mode 100644 src/app/admin-next/[domain]/page.tsx create mode 100644 src/app/admin-next/layout.tsx create mode 100644 src/app/admin-next/page.tsx create mode 100644 src/features/housekeeping/foundation/preview-gate.test.ts create mode 100644 src/features/housekeeping/foundation/preview-gate.ts create mode 100644 src/features/housekeeping/foundation/preview-route-contract.test.ts diff --git a/.env.example b/.env.example index 346d985b..869e715e 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,8 @@ NODE_ENV=production PORT=3002 NEXT_TELEMETRY_DISABLED=1 UV_THREADPOOL_SIZE=16 +# Non-production preview only; production always returns 404. +HOUSEKEEPING_NEXT_PREVIEW_ENABLED=false # --- HOTEL & URLS --- HOTEL_NAME=EPIC WEB CONTROL diff --git a/src/app/admin-next/[domain]/layout.tsx b/src/app/admin-next/[domain]/layout.tsx new file mode 100644 index 00000000..cedf4ca2 --- /dev/null +++ b/src/app/admin-next/[domain]/layout.tsx @@ -0,0 +1,59 @@ +import { notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import type { ReactNode } from "react"; +import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context"; +import { buildHousekeepingNavigation } from "@/features/housekeeping/foundation/navigation"; +import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry"; +import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context"; +import { HousekeepingShell } from "@/features/housekeeping/foundation/shell/housekeeping-shell"; +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 AdminNextDomainLayout({ + children, + params, +}: { + children: ReactNode; + 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 context = await getHousekeepingCapabilityContext(); + if (!satisfiesCapability(context, activeDomain.capability)) notFound(); + + const translate = await getTranslations("pages.housekeeping"); + const navigation = buildHousekeepingNavigation(registry, context, (key) => + translate(namespaceKey(key) as never), + ); + + return ( + + {children} + + ); +} diff --git a/src/app/admin-next/[domain]/page.tsx b/src/app/admin-next/[domain]/page.tsx new file mode 100644 index 00000000..cd9f51ef --- /dev/null +++ b/src/app/admin-next/[domain]/page.tsx @@ -0,0 +1,45 @@ +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 ( + + + + ); +} diff --git a/src/app/admin-next/layout.tsx b/src/app/admin-next/layout.tsx new file mode 100644 index 00000000..d99a5392 --- /dev/null +++ b/src/app/admin-next/layout.tsx @@ -0,0 +1,17 @@ +import { notFound } from "next/navigation"; +import type { ReactNode } from "react"; +import { env } from "@/env"; +import { isHousekeepingPreviewEnabled } from "@/features/housekeeping/foundation/preview-gate"; + +export default function AdminNextLayout({ children }: { children: ReactNode }) { + if ( + !isHousekeepingPreviewEnabled({ + nodeEnv: env.NODE_ENV, + flag: env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED, + }) + ) { + notFound(); + } + + return children; +} diff --git a/src/app/admin-next/page.tsx b/src/app/admin-next/page.tsx new file mode 100644 index 00000000..b36e4e7d --- /dev/null +++ b/src/app/admin-next/page.tsx @@ -0,0 +1,17 @@ +import { notFound, redirect } from "next/navigation"; +import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context"; +import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry"; +import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context"; +import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests"; + +export default async function AdminNextPage() { + const context = await getHousekeepingCapabilityContext(); + const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS); + const firstVisibleDomain = registry.domains.find((domain) => + satisfiesCapability(context, domain.capability), + ); + + if (!firstVisibleDomain) notFound(); + + redirect(firstVisibleDomain.previewHref); +} diff --git a/src/env.ts b/src/env.ts index 9d30081b..4b6e751a 100644 --- a/src/env.ts +++ b/src/env.ts @@ -9,6 +9,10 @@ const schema = z NODE_ENV: z .enum(["development", "test", "production"]) .default("development"), + HOUSEKEEPING_NEXT_PREVIEW_ENABLED: z + .string() + .optional() + .transform((value) => value === "true" || value === "1"), DATABASE_URL: z.string().url(), DATABASE_POOL_SIZE: z.coerce.number().int().positive().default(10), DATABASE_IDLE_TIMEOUT_MS: z.coerce diff --git a/src/features/housekeeping/foundation/preview-gate.test.ts b/src/features/housekeeping/foundation/preview-gate.test.ts new file mode 100644 index 00000000..3f322b70 --- /dev/null +++ b/src/features/housekeeping/foundation/preview-gate.test.ts @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { isHousekeepingPreviewEnabled } from "./preview-gate"; + +describe("isHousekeepingPreviewEnabled", () => { + it.each([ + ["development", true, true], + ["test", true, true], + ["development", false, false], + ["production", true, false], + ["production", false, false], + ] as const)("NODE_ENV=%s flag=%s => %s", (nodeEnv, flag, expected) => { + expect(isHousekeepingPreviewEnabled({ nodeEnv, flag })).toBe(expected); + }); +}); + +describe("HOUSEKEEPING_NEXT_PREVIEW_ENABLED", () => { + const originalSkipValidation = process.env.SKIP_ENV_VALIDATION; + const originalPreviewFlag = process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED; + + beforeEach(() => { + vi.resetModules(); + delete process.env.SKIP_ENV_VALIDATION; + }); + + afterEach(() => { + if (originalSkipValidation === undefined) { + delete process.env.SKIP_ENV_VALIDATION; + } else { + process.env.SKIP_ENV_VALIDATION = originalSkipValidation; + } + + if (originalPreviewFlag === undefined) { + delete process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED; + } else { + process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = originalPreviewFlag; + } + + vi.resetModules(); + }); + + it.each([ + ["true", true], + ["1", true], + ["false", false], + ["yes", false], + [undefined, false], + ] as const)("normalizes %s to %s", async (value, expected) => { + if (value === undefined) { + delete process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED; + } else { + process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = value; + } + + const { env } = await import("@/env"); + + expect(env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED).toBe(expected); + }); +}); diff --git a/src/features/housekeeping/foundation/preview-gate.ts b/src/features/housekeeping/foundation/preview-gate.ts new file mode 100644 index 00000000..5b121163 --- /dev/null +++ b/src/features/housekeeping/foundation/preview-gate.ts @@ -0,0 +1,6 @@ +export function isHousekeepingPreviewEnabled(input: { + nodeEnv: "development" | "test" | "production"; + flag: boolean; +}): boolean { + return input.nodeEnv !== "production" && input.flag; +} diff --git a/src/features/housekeeping/foundation/preview-route-contract.test.ts b/src/features/housekeeping/foundation/preview-route-contract.test.ts new file mode 100644 index 00000000..a78ec0ec --- /dev/null +++ b/src/features/housekeeping/foundation/preview-route-contract.test.ts @@ -0,0 +1,308 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { createElement, type ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PERMS } from "@/lib/permission-slugs"; +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", + "domains.people.description": "Localized People description", + "domains.economy.title": "Localized Economy", + "domains.economy.description": "Localized Economy description", + "states.empty.title": "Localized empty title", + "states.empty.description": "Localized empty description", + }; + const translate = vi.fn((key: string) => { + const message = messages[key]; + if (message === undefined) + throw new Error(`Unexpected translation: ${key}`); + return message; + }); + + 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") { + throw new Error(`Unexpected namespace: ${namespace}`); + } + return translate; + }), + notFound: vi.fn((): never => { + throw new Error("NEXT_NOT_FOUND"); + }), + redirect: vi.fn((href: string): never => { + throw new Error(`NEXT_REDIRECT:${href}`); + }), + translate, + }; +}); + +vi.mock("@/env", () => ({ env: routeMocks.env })); +vi.mock("next/navigation", () => ({ + notFound: routeMocks.notFound, + redirect: routeMocks.redirect, +})); +vi.mock("next-intl/server", () => ({ + getTranslations: routeMocks.getTranslations, +})); +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"); +}); +vi.mock("@/lib/auth", () => { + throw new Error("preview routes must not call auth directly"); +}); +vi.mock("@/lib/permissions", () => { + throw new Error("preview routes must not reload permissions directly"); +}); +vi.mock("@/actions", () => { + throw new Error("preview routes must not import actions"); +}); +vi.mock("@/app/actions", () => { + throw new Error("preview routes must not import actions"); +}); + +import AdminNextDomainLayout from "@/app/admin-next/[domain]/layout"; +import AdminNextDomainPage from "@/app/admin-next/[domain]/page"; +import AdminNextLayout from "@/app/admin-next/layout"; +import AdminNextPage from "@/app/admin-next/page"; + +const routeFiles = [ + "src/app/admin-next/layout.tsx", + "src/app/admin-next/page.tsx", + "src/app/admin-next/[domain]/layout.tsx", + "src/app/admin-next/[domain]/page.tsx", +] as const; + +function capabilityContext( + granted: readonly string[], + actor = { id: 42, username: "refreshed-moderator", rank: 3 }, +): HousekeepingCapabilityContext { + const capabilities = new Set(granted); + + return { + actor, + isSuperAdmin: false, + has: (slug) => capabilities.has(slug), + hasAny: (...slugs) => slugs.some((slug) => capabilities.has(slug)), + hasAll: (...slugs) => slugs.every((slug) => capabilities.has(slug)), + }; +} + +async function renderRoute(route: ReactNode | Promise) { + return renderToStaticMarkup(await route); +} + +describe("/admin-next preview gate", () => { + beforeEach(() => { + vi.clearAllMocks(); + routeMocks.env.NODE_ENV = "test"; + routeMocks.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = true; + }); + + 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("/admin-next first visible domain", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("redirects an administrator to Operations in locked registry order", async () => { + routeMocks.getHousekeepingCapabilityContext.mockResolvedValue( + capabilityContext([PERMS.ADMIN_DASHBOARD, PERMS.USERS_VIEW]), + ); + + await expect(AdminNextPage()).rejects.toThrow( + "NEXT_REDIRECT:/admin-next/operations", + ); + expect(routeMocks.redirect).toHaveBeenCalledWith("/admin-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 () => { + routeMocks.getHousekeepingCapabilityContext.mockResolvedValue( + capabilityContext([PERMS.MOD_CFH_VIEW]), + ); + + await expect(AdminNextPage()).rejects.toThrow( + "NEXT_REDIRECT:/admin-next/people", + ); + expect(routeMocks.redirect).toHaveBeenCalledWith("/admin-next/people"); + expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes( + 1, + ); + }); + + it("returns 404 when the operator has no visible domain", async () => { + routeMocks.getHousekeepingCapabilityContext.mockResolvedValue( + capabilityContext([]), + ); + + await expect(AdminNextPage()).rejects.toThrow("NEXT_NOT_FOUND"); + expect(routeMocks.notFound).toHaveBeenCalledTimes(1); + expect(routeMocks.redirect).not.toHaveBeenCalled(); + expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes( + 1, + ); + }); +}); + +describe("/admin-next/[domain] layout", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("rejects an unknown domain before loading capability context", async () => { + await expect( + AdminNextDomainLayout({ + children: createElement("p", null, "Unknown body"), + params: Promise.resolve({ domain: "unknown" }), + }), + ).rejects.toThrow("NEXT_NOT_FOUND"); + expect(routeMocks.getHousekeepingCapabilityContext).not.toHaveBeenCalled(); + expect(routeMocks.getTranslations).not.toHaveBeenCalled(); + }); + + it("rejects a known domain that the operator cannot access", async () => { + routeMocks.getHousekeepingCapabilityContext.mockResolvedValue( + capabilityContext([PERMS.MOD_CFH_VIEW]), + ); + + await expect( + AdminNextDomainLayout({ + children: createElement("p", null, "Economy body"), + params: Promise.resolve({ domain: "economy" }), + }), + ).rejects.toThrow("NEXT_NOT_FOUND"); + expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes( + 1, + ); + expect(routeMocks.getTranslations).not.toHaveBeenCalled(); + }); + + it("renders localized People shell from one refreshed capability context", async () => { + routeMocks.getHousekeepingCapabilityContext.mockResolvedValue( + capabilityContext([PERMS.MOD_CFH_VIEW]), + ); + + const html = await renderRoute( + AdminNextDomainLayout({ + children: createElement("p", null, "People body"), + params: Promise.resolve({ domain: "people" }), + }), + ); + + expect(html).toContain("refreshed-moderator"); + expect(html).toContain("Localized preview"); + expect(html).toContain("Localized primary navigation"); + expect(html).toContain("Localized People"); + expect(html).toContain("People body"); + expect(html).not.toContain("Localized Economy"); + expect(routeMocks.translate).not.toHaveBeenCalledWith( + "domains.economy.title", + ); + expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes( + 1, + ); + expect(routeMocks.getTranslations).toHaveBeenCalledTimes(1); + }); +}); + +describe("/admin-next/[domain] 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("Localized People"); + 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( + AdminNextDomainPage({ + params: Promise.resolve({ domain: "unknown" }), + }), + ).rejects.toThrow("NEXT_NOT_FOUND"); + expect(routeMocks.getHousekeepingCapabilityContext).not.toHaveBeenCalled(); + expect(routeMocks.getTranslations).not.toHaveBeenCalled(); + }); +}); + +describe("preview route import boundary", () => { + it("rejects data, actions, direct auth, old chrome, and legacy route imports", () => { + for (const path of routeFiles) { + const source = readFileSync(resolve(process.cwd(), path), "utf8"); + + expect(source, path).not.toMatch( + /@\/lib\/db|@\/(?:app\/)?actions(?:\/|["'])/, + ); + 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))(?:\/|["'])/, + ); + } + }); +});