feat: allow rank-gated housekeeping preview in production
This commit is contained in:
1 parent
1dc8d1d46f
commit
9af1e62655
7 files changed
+75
-12
No files matched your search
+1
-1
@@ -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`);
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in new issue
Block a user