feat: allow rank-gated housekeeping preview in production
CI / check (push) Successful in 29s
CI / deploy (push) Successful in 56s
CI / release (push) Skipped

This commit is contained in:
Simo committed 2026-08-31 20:29:38 +02:00
1 parent 1dc8d1d46f
commit 9af1e62655
7 files changed
+75 -12

No files matched your search

+1 -1
View File
@@ -17,7 +17,7 @@ NODE_ENV=production
PORT=3002 PORT=3002
NEXT_TELEMETRY_DISABLED=1 NEXT_TELEMETRY_DISABLED=1
UV_THREADPOOL_SIZE=16 UV_THREADPOOL_SIZE=16
# Non-production preview only; production always returns 404. # Production requires this kill switch plus housekeeping.preview.access.
HOUSEKEEPING_NEXT_PREVIEW_ENABLED=false HOUSEKEEPING_NEXT_PREVIEW_ENABLED=false
# --- HOTEL & URLS --- # --- HOTEL & URLS ---
@@ -0,0 +1,3 @@
INSERT INTO `acl_permissions` (`slug`, `title`)
VALUES ('housekeeping.preview.access', 'Access Housekeeping preview')
ON DUPLICATE KEY UPDATE `title` = VALUES(`title`);
+15 -1
View File
@@ -2,12 +2,26 @@ import { notFound } from "next/navigation";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { env } from "@/env"; import { env } from "@/env";
import { isHousekeepingPreviewEnabled } from "@/features/housekeeping/foundation/preview-gate"; import { isHousekeepingPreviewEnabled } from "@/features/housekeeping/foundation/preview-gate";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
import { PERMS } from "@/lib/permission-slugs";
export default async function AdminNextLayout({
children,
}: {
children: ReactNode;
}) {
const hasProductionAccess =
env.NODE_ENV === "production" && env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED
? (await getHousekeepingCapabilityContext()).has(
PERMS.HOUSEKEEPING_PREVIEW_ACCESS,
)
: false;
export default function AdminNextLayout({ children }: { children: ReactNode }) {
if ( if (
!isHousekeepingPreviewEnabled({ !isHousekeepingPreviewEnabled({
nodeEnv: env.NODE_ENV, nodeEnv: env.NODE_ENV,
flag: env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED, flag: env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED,
hasProductionAccess,
}) })
) { ) {
notFound(); notFound();
@@ -3,14 +3,24 @@ import { isHousekeepingPreviewEnabled } from "./preview-gate";
describe("isHousekeepingPreviewEnabled", () => { describe("isHousekeepingPreviewEnabled", () => {
it.each([ it.each([
["development", true, true], ["development", true, false, true],
["test", true, true], ["test", true, false, true],
["development", false, false], ["development", false, true, false],
["production", true, false], ["production", true, true, true],
["production", false, false], ["production", true, false, false],
] as const)("NODE_ENV=%s flag=%s => %s", (nodeEnv, flag, expected) => { ["production", false, true, false],
expect(isHousekeepingPreviewEnabled({ nodeEnv, flag })).toBe(expected); ] as const)(
}); "NODE_ENV=%s flag=%s productionAccess=%s => %s",
(nodeEnv, flag, hasProductionAccess, expected) => {
expect(
isHousekeepingPreviewEnabled({
nodeEnv,
flag,
hasProductionAccess,
}),
).toBe(expected);
},
);
}); });
describe("HOUSEKEEPING_NEXT_PREVIEW_ENABLED", () => { describe("HOUSEKEEPING_NEXT_PREVIEW_ENABLED", () => {
@@ -1,6 +1,10 @@
export function isHousekeepingPreviewEnabled(input: { export function isHousekeepingPreviewEnabled(input: {
nodeEnv: "development" | "test" | "production"; nodeEnv: "development" | "test" | "production";
flag: boolean; flag: boolean;
hasProductionAccess?: boolean;
}): boolean { }): boolean {
return input.nodeEnv !== "production" && input.flag; return (
input.flag &&
(input.nodeEnv !== "production" || input.hasProductionAccess === true)
);
} }
@@ -365,10 +365,12 @@ describe("/admin-next preview gate", () => {
vi.clearAllMocks(); vi.clearAllMocks();
routeMocks.env.NODE_ENV = "test"; routeMocks.env.NODE_ENV = "test";
routeMocks.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = true; routeMocks.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = true;
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
capabilityContext([]),
);
}); });
it.each([ it.each([
["production", true],
["production", false], ["production", false],
["development", false], ["development", false],
] as const)("returns 404 for NODE_ENV=%s flag=%s", async (nodeEnv, flag) => { ] as const)("returns 404 for NODE_ENV=%s flag=%s", async (nodeEnv, flag) => {
@@ -385,6 +387,35 @@ describe("/admin-next preview gate", () => {
expect(routeMocks.notFound).toHaveBeenCalledTimes(1); expect(routeMocks.notFound).toHaveBeenCalledTimes(1);
}); });
it("returns 404 in production without the dedicated preview permission", async () => {
routeMocks.env.NODE_ENV = "production";
await expect(async () =>
renderRoute(
AdminNextLayout({
children: createElement("p", null, "Preview child"),
}),
),
).rejects.toThrow("NEXT_NOT_FOUND");
expect(routeMocks.notFound).toHaveBeenCalledTimes(1);
});
it("renders in production for a rank with the dedicated preview permission", async () => {
routeMocks.env.NODE_ENV = "production";
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
capabilityContext(["housekeeping.preview.access"]),
);
const html = await renderRoute(
AdminNextLayout({
children: createElement("p", null, "Preview child"),
}),
);
expect(html).toContain("Preview child");
expect(routeMocks.notFound).not.toHaveBeenCalled();
});
it.each(["development", "test"] as const)( it.each(["development", "test"] as const)(
"renders children in %s when explicitly enabled", "renders children in %s when explicitly enabled",
async (nodeEnv) => { async (nodeEnv) => {
+1
View File
@@ -3,6 +3,7 @@
// (modules.ts → sidebar.tsx) without pulling in db/server-only deps. // (modules.ts → sidebar.tsx) without pulling in db/server-only deps.
export const PERMS = { export const PERMS = {
HOUSEKEEPING_PREVIEW_ACCESS: "housekeeping.preview.access",
ADMIN_DASHBOARD: "admin.dashboard", ADMIN_DASHBOARD: "admin.dashboard",
USERS_VIEW: "admin.users.view", USERS_VIEW: "admin.users.view",
USERS_EDIT: "admin.users.edit", USERS_EDIT: "admin.users.edit",