Compare commits

...
1 Commits
Author SHA1 Message Date
César Arroba ea310b1c68 feat(ui): full maintenance mode landing page and gate (cloud-only)
- Add maintenance mode landing page and view component
- Gate maintenance status check and redirect behind isCloud()
- Add changelog fragment
2026-07-08 13:23:08 +02:00
11 changed files with 872 additions and 9 deletions
@@ -0,0 +1,77 @@
import { render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MaintenanceView } from "./maintenance-view";
describe("MaintenanceView", () => {
const reloadMock = vi.fn();
const fetchMock = vi.fn();
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
// jsdom's `window.location.reload` is a no-op that throws if called; replace
// the whole `location` so we can observe reloads without navigation.
Object.defineProperty(window, "location", {
configurable: true,
value: { ...window.location, reload: reloadMock },
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
vi.useRealTimers();
});
it("renders the given message and refresh notice", () => {
// When
render(<MaintenanceView message="Scheduled DB maintenance." />);
// Then
expect(screen.getByText("Under maintenance")).toBeInTheDocument();
expect(screen.getByText("Scheduled DB maintenance.")).toBeInTheDocument();
expect(
screen.getByText(
"This page refreshes automatically once maintenance is complete.",
),
).toBeInTheDocument();
});
it("never makes a cross-origin fetch from the browser", () => {
// When
render(<MaintenanceView message="Down for maintenance." />);
// Then
expect(fetchMock).not.toHaveBeenCalled();
});
it("reloads the page on an interval so the edge gate can redirect away", () => {
// Given
vi.useFakeTimers();
// When
render(<MaintenanceView message="Down for maintenance." />);
expect(reloadMock).not.toHaveBeenCalled();
vi.advanceTimersByTime(15000); // one interval
expect(reloadMock).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(15000); // second interval
expect(reloadMock).toHaveBeenCalledTimes(2);
});
it("stops reloading after unmount", () => {
// Given
vi.useFakeTimers();
// When
const { unmount } = render(
<MaintenanceView message="Down for maintenance." />,
);
unmount();
vi.advanceTimersByTime(15000);
// Then
expect(reloadMock).not.toHaveBeenCalled();
});
});
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { useEffect } from "react";
import { ProwlerExtended } from "@/components/icons";
import { ThemeSwitch } from "@/components/ThemeSwitch";
// How often the page reloads itself while maintenance is on. On each reload the
// edge gate in `proxy.ts` re-evaluates maintenance state server-side (over the
// compose network, so no browser CORS) and redirects `/maintenance` → `/` as
// soon as the operator turns MM off. No client-side API call is made from here.
const RELOAD_INTERVAL_MS = 15000;
export interface MaintenanceViewProps {
message: string;
}
export function MaintenanceView({ message }: MaintenanceViewProps) {
useEffect(() => {
const interval = setInterval(() => {
window.location.reload();
}, RELOAD_INTERVAL_MS);
return () => {
clearInterval(interval);
};
}, []);
return (
<main className="relative flex min-h-screen w-full overflow-x-hidden overflow-y-auto">
<div className="relative flex w-full flex-col items-center justify-center px-4 py-32">
{/* Background Pattern — mirrors the sign-in screen (AuthLayout) */}
<div
className="absolute inset-0 mask-[radial-gradient(ellipse_50%_50%_at_50%_50%,#000_10%,transparent_80%)] bg-size-[16px_16px]"
style={{
backgroundImage:
"radial-gradient(var(--bg-button-primary) 1px, transparent 1px)",
}}
></div>
{/* Prowler Logo */}
<div className="relative z-10 mb-8 flex w-full max-w-[300px]">
<ProwlerExtended
width={300}
className="h-auto w-full"
role="img"
aria-label="Prowler"
/>
</div>
{/* Maintenance Card — same shell as the auth form container */}
<div className="rounded-large border-divider shadow-small dark:bg-background/85 relative z-10 flex w-full max-w-sm flex-col gap-4 border bg-white/90 px-8 py-10 md:max-w-md">
{/* Header with Title and Theme Toggle */}
<div className="flex items-center justify-between">
<h1 className="pb-2 text-xl font-medium">Under maintenance</h1>
<ThemeSwitch aria-label="Toggle theme" />
</div>
<p className="text-default-500 text-sm leading-relaxed">{message}</p>
<p className="text-default-500 text-xs">
This page refreshes automatically once maintenance is complete.
</p>
</div>
</div>
</main>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { render, screen } from "@testing-library/react";
import { headers } from "next/headers";
import { describe, expect, it, vi } from "vitest";
import MaintenancePage from "./page";
vi.mock("next/headers", () => ({
headers: vi.fn(),
}));
const mockHeaders = (entries: Record<string, string>) => {
vi.mocked(headers).mockResolvedValue(
new Headers(entries) as unknown as Awaited<ReturnType<typeof headers>>,
);
};
describe("MaintenancePage", () => {
it("renders the message forwarded by the proxy gate via x-maintenance-message", async () => {
// Given
mockHeaders({ "x-maintenance-message": "Scheduled DB maintenance." });
// When
render(await MaintenancePage());
// Then
expect(screen.getByText("Scheduled DB maintenance.")).toBeInTheDocument();
});
it("falls back to the default message when the header is missing", async () => {
// Given
mockHeaders({});
// When
render(await MaintenancePage());
// Then
expect(
screen.getByText(
"Prowler is currently undergoing scheduled maintenance. We will be back shortly.",
),
).toBeInTheDocument();
});
it("falls back to the default message when the header is empty", async () => {
// Given
mockHeaders({ "x-maintenance-message": "" });
// When
render(await MaintenancePage());
// Then
expect(
screen.getByText(
"Prowler is currently undergoing scheduled maintenance. We will be back shortly.",
),
).toBeInTheDocument();
});
});
+21
View File
@@ -0,0 +1,21 @@
import { headers } from "next/headers";
import { MaintenanceView } from "./maintenance-view";
const DEFAULT_MESSAGE =
"Prowler is currently undergoing scheduled maintenance. We will be back shortly.";
/**
* Server Component: reads the ops-set message forwarded by the `proxy.ts`
* maintenance gate as a request header (see `lib/maintenance.ts`
* `maintenanceResponse`'s rewrite branch) and passes it down to the client
* view. Falls back to `DEFAULT_MESSAGE` when the header is missing or empty
* — e.g. when this route is hit directly in dev without going through the
* gate.
*/
export default async function MaintenancePage() {
const headerList = await headers();
const message = headerList.get("x-maintenance-message") || DEFAULT_MESSAGE;
return <MaintenanceView message={message} />;
}
@@ -0,0 +1 @@
Maintenance mode landing page, gated to Prowler Cloud via `NEXT_PUBLIC_IS_CLOUD_ENV`
+238
View File
@@ -0,0 +1,238 @@
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
fetchMaintenanceStatus,
maintenanceResponse,
type MaintenanceStatus,
} from "./maintenance";
const API_BASE_URL = "https://api.example.com/api/v1";
const fetchMock = vi.fn();
const jsonResponse = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
const makeRequest = (path: string) =>
new NextRequest(new URL(`https://app.prowler.com${path}`));
describe("fetchMaintenanceStatus", () => {
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it("returns the parsed status when MM is enabled", async () => {
// Given
fetchMock.mockResolvedValueOnce(
jsonResponse({
enabled: true,
message: "Scheduled DB maintenance.",
started_at: "2026-06-17T10:00:00Z",
}),
);
// When
const status = await fetchMaintenanceStatus(API_BASE_URL);
// Then
expect(status).toEqual({
enabled: true,
message: "Scheduled DB maintenance.",
started_at: "2026-06-17T10:00:00Z",
});
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe(`${API_BASE_URL}/maintenance`);
expect(init).toMatchObject({
headers: { Accept: "application/json" },
next: { revalidate: 15 },
});
});
it("returns MM off when the endpoint reports disabled", async () => {
// Given
fetchMock.mockResolvedValueOnce(
jsonResponse({ enabled: false, message: null, started_at: null }),
);
// When
const status = await fetchMaintenanceStatus(API_BASE_URL);
// Then
expect(status.enabled).toBe(false);
});
it("fails open when the API base URL is missing", async () => {
// When
const status = await fetchMaintenanceStatus(undefined);
// Then
expect(status).toEqual({
enabled: false,
message: null,
started_at: null,
});
expect(fetchMock).not.toHaveBeenCalled();
});
it("fails open on a non-200 response", async () => {
// Given
fetchMock.mockResolvedValueOnce(jsonResponse({ enabled: true }, 500));
// When
const status = await fetchMaintenanceStatus(API_BASE_URL);
// Then
expect(status.enabled).toBe(false);
});
it("fails open on a network error / timeout", async () => {
// Given
fetchMock.mockRejectedValueOnce(new Error("network down"));
// When
const status = await fetchMaintenanceStatus(API_BASE_URL);
// Then
expect(status.enabled).toBe(false);
});
it("coerces a malformed body to MM off", async () => {
// Given
fetchMock.mockResolvedValueOnce(jsonResponse({ unexpected: "shape" }));
// When
const status = await fetchMaintenanceStatus(API_BASE_URL);
// Then
expect(status).toEqual({
enabled: false,
message: null,
started_at: null,
});
});
});
describe("maintenanceResponse", () => {
const enabled: MaintenanceStatus = {
enabled: true,
message: "Down for maintenance.",
started_at: "2026-06-17T10:00:00Z",
};
const disabled: MaintenanceStatus = {
enabled: false,
message: null,
started_at: null,
};
it("rewrites to /maintenance when MM is enabled and not already there", () => {
// When
const response = maintenanceResponse(makeRequest("/scans"), enabled);
// Then
expect(response).not.toBeNull();
expect(response?.headers.get("x-middleware-rewrite")).toBe(
"https://app.prowler.com/maintenance",
);
});
it("forwards the ops-set message and started_at as request headers on rewrite", () => {
// When
const response = maintenanceResponse(makeRequest("/scans"), enabled);
// Then
expect(response?.headers.get("x-middleware-override-headers")).toContain(
"x-maintenance-message",
);
expect(
response?.headers.get("x-middleware-request-x-maintenance-message"),
).toBe("Down for maintenance.");
expect(
response?.headers.get("x-middleware-request-x-maintenance-started-at"),
).toBe("2026-06-17T10:00:00Z");
});
it("forwards empty-string headers (not the literal string 'null') when message/started_at are null", () => {
// Given
const enabledNoMessage: MaintenanceStatus = {
enabled: true,
message: null,
started_at: null,
};
// When
const response = maintenanceResponse(
makeRequest("/scans"),
enabledNoMessage,
);
// Then
expect(
response?.headers.get("x-middleware-request-x-maintenance-message"),
).toBe("");
expect(
response?.headers.get("x-middleware-request-x-maintenance-started-at"),
).toBe("");
});
it("returns a terminal NextResponse.next() when MM is enabled and already on /maintenance (no auth fallthrough)", () => {
// When
const response = maintenanceResponse(makeRequest("/maintenance"), enabled);
// Then: must be truthy so `proxy()` returns it directly instead of
// falling through into `authProxy`, which would redirect an
// unauthenticated visitor to /sign-in.
expect(response).not.toBeNull();
expect(response?.headers.get("x-middleware-next")).toBe("1");
// And it must NOT be a redirect/rewrite — this is the actual page render.
expect(response?.headers.get("location")).toBeNull();
expect(response?.headers.get("x-middleware-rewrite")).toBeNull();
});
it("redirects /maintenance back to / when MM is disabled", () => {
// When
const response = maintenanceResponse(makeRequest("/maintenance"), disabled);
// Then
expect(response?.status).toBe(307);
expect(response?.headers.get("location")).toBe("https://app.prowler.com/");
});
it("is a no-op (fail-open) when MM is disabled on a normal route", () => {
// When
const response = maintenanceResponse(makeRequest("/scans"), disabled);
// Then
expect(response).toBeNull();
});
});
describe("proxy.ts matcher config", () => {
it("no longer excludes /maintenance from the gate", async () => {
// The gate must run on /maintenance too, otherwise its terminal branch
// (MM on + already on /maintenance) never executes and the request falls
// through to authProxy, which redirects unauthenticated visitors to
// /sign-in instead of serving the maintenance page. Read the raw matcher
// pattern to guard against a regression re-adding the exclusion.
const path = await import("node:path");
const fs = await import("node:fs/promises");
const proxyModule = await fs.readFile(
path.resolve(process.cwd(), "proxy.ts"),
"utf-8",
);
const matcherLine = proxyModule
.split("\n")
.find((line) => line.includes("api|_next/static"));
expect(matcherLine).toBeDefined();
expect(matcherLine).not.toContain("maintenance|");
});
});
+122
View File
@@ -0,0 +1,122 @@
import { NextRequest, NextResponse } from "next/server";
/**
* Shape of the public, unauthenticated `GET /api/v1/maintenance`
* response. Flat JSON (NOT JSON:API) by design — the endpoint must answer
* even when the DB is down, so it reads only from Redis.
*/
export interface MaintenanceStatus {
enabled: boolean;
message: string | null;
started_at: string | null;
}
export const MAINTENANCE_PATH = "/maintenance";
const MAINTENANCE_STATUS_PATH = "/maintenance";
/**
* Short timeout so a slow/hung status endpoint never blocks every request on
* the edge. On timeout we fail-open (treat MM as off).
*/
const STATUS_FETCH_TIMEOUT_MS = 2000;
/**
* Edge cache window for the status probe. Keeps the per-request fetch cheap
* while still recovering within ~15s of an operator toggling MM on/off.
*/
const STATUS_REVALIDATE_SECONDS = 15;
/**
* Fetch the public maintenance status from the API.
*
* Fail-open contract: ANY error (network, timeout, non-200, malformed body)
* resolves to `{ enabled: false }`. A status blip must never lock users out —
* the API itself is the enforcement layer (it returns 503 when MM is really
* on); the UI gate is purely cosmetic, so erring toward "off" is safe.
*/
export const fetchMaintenanceStatus = async (
// `@/lib`'s `apiBaseUrl` resolves via `readEnv`, which returns `string |
// null` (not `undefined`) when unset.
apiBaseUrl: string | null | undefined,
): Promise<MaintenanceStatus> => {
const fallback: MaintenanceStatus = {
enabled: false,
message: null,
started_at: null,
};
if (!apiBaseUrl) {
return fallback;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), STATUS_FETCH_TIMEOUT_MS);
try {
const response = await fetch(`${apiBaseUrl}${MAINTENANCE_STATUS_PATH}`, {
headers: { Accept: "application/json" },
signal: controller.signal,
next: { revalidate: STATUS_REVALIDATE_SECONDS },
});
if (!response.ok) {
return fallback;
}
const data = (await response.json()) as Partial<MaintenanceStatus>;
return {
enabled: data?.enabled === true,
message: typeof data?.message === "string" ? data.message : null,
started_at: typeof data?.started_at === "string" ? data.started_at : null,
};
} catch {
// Network error or aborted-by-timeout → fail open.
return fallback;
} finally {
clearTimeout(timeout);
}
};
/**
* Decide what the maintenance gate should do for a given request, given the
* current status. Pure and side-effect free so it can be unit-tested without
* the Next runtime:
*
* - MM on, not already on `/maintenance` → rewrite to `/maintenance`,
* forwarding the ops-set message/started_at as request headers so the page
* can render them (preserve the URL so the user lands back where they were
* on recovery).
* - MM on, already on `/maintenance` → TERMINAL `NextResponse.next()`. Must
* be truthy (not null) so `proxy()` returns it directly instead of falling
* through into `authProxy`, which would redirect an unauthenticated
* visitor to `/sign-in` instead of letting the maintenance page render.
* - MM off, currently on `/maintenance` → redirect to `/`.
* - Otherwise → no-op (let the request continue / the next handler run).
*/
export const maintenanceResponse = (
request: NextRequest,
status: MaintenanceStatus,
): NextResponse | null => {
const { pathname } = request.nextUrl;
const onMaintenancePage = pathname.startsWith(MAINTENANCE_PATH);
if (status.enabled) {
if (onMaintenancePage) {
return NextResponse.next();
}
const url = new URL(MAINTENANCE_PATH, request.url);
const headers = new Headers(request.headers);
headers.set("x-maintenance-message", status.message ?? "");
headers.set("x-maintenance-started-at", status.started_at ?? "");
return NextResponse.rewrite(url, { request: { headers } });
}
if (onMaintenancePage) {
return NextResponse.redirect(new URL("/", request.url));
}
return null;
};
+109 -6
View File
@@ -1,11 +1,22 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { captureExceptionMock, captureMessageMock, revalidatePathMock } =
vi.hoisted(() => ({
captureExceptionMock: vi.fn(),
captureMessageMock: vi.fn(),
revalidatePathMock: vi.fn(),
}));
const {
captureExceptionMock,
captureMessageMock,
revalidatePathMock,
redirectMock,
fetchMaintenanceStatusMock,
} = vi.hoisted(() => ({
captureExceptionMock: vi.fn(),
captureMessageMock: vi.fn(),
revalidatePathMock: vi.fn(),
redirectMock: vi.fn(() => {
// Mirror Next's real `redirect()`: it throws to unwind the call stack via
// the NEXT_REDIRECT digest rather than returning normally.
throw new Error("NEXT_REDIRECT");
}),
fetchMaintenanceStatusMock: vi.fn(),
}));
vi.mock("@sentry/nextjs", () => ({
captureException: captureExceptionMock,
@@ -16,6 +27,19 @@ vi.mock("next/cache", () => ({
revalidatePath: revalidatePathMock,
}));
vi.mock("next/navigation", () => ({
redirect: redirectMock,
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "http://api:8000/api/v1",
}));
vi.mock("@/lib/maintenance", () => ({
MAINTENANCE_PATH: "/maintenance",
fetchMaintenanceStatus: fetchMaintenanceStatusMock,
}));
vi.mock("./helper", () => ({
GENERIC_SERVER_ERROR_MESSAGE:
"Server is temporarily unavailable. Please try again in a few minutes.",
@@ -51,4 +75,83 @@ describe("server action error handling", () => {
"Server is temporarily unavailable. Please try again in a few minutes.",
);
});
describe('on Cloud (NEXT_PUBLIC_IS_CLOUD_ENV="true")', () => {
beforeEach(() => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
});
it("redirects to /maintenance on a 503 when maintenance mode is actually on", async () => {
// Given
fetchMaintenanceStatusMock.mockResolvedValueOnce({
enabled: true,
message: "Down for maintenance.",
started_at: "2026-06-17T10:00:00Z",
});
const response = new Response(null, { status: 503 });
// When / Then
const result = handleApiResponse(response);
await expect(result).rejects.toThrow("NEXT_REDIRECT");
expect(redirectMock).toHaveBeenCalledWith("/maintenance");
});
it("does NOT redirect on a 503 when maintenance mode is off (transient error, or the status probe itself fails open) — falls through to normal 5xx handling instead", async () => {
// Given: `fetchMaintenanceStatus` fails open (2s timeout → `enabled:
// false`) on any error, so a status-check blip resolves identically to a
// confirmed "MM off" here — this asserts the helper's behavior on that
// resolved value regardless of why it came back `false`. Falling through
// means the normal 5xx path takes over and throws the generic server
// error rather than redirecting to /maintenance.
fetchMaintenanceStatusMock.mockResolvedValueOnce({
enabled: false,
message: null,
started_at: null,
});
const response = new Response("Service Unavailable", {
status: 503,
statusText: "Service Unavailable",
});
// When / Then
const result = handleApiResponse(response);
await expect(result).rejects.toThrow(
"Server is temporarily unavailable. Please try again in a few minutes.",
);
expect(redirectMock).not.toHaveBeenCalled();
});
});
describe('self-hosted (NEXT_PUBLIC_IS_CLOUD_ENV is not "true")', () => {
it("is a no-op on a 503: never probes maintenance status and never redirects, falling straight through to normal 5xx handling", async () => {
// Given: Maintenance Mode is a Cloud-only feature (see
// lib/maintenance.ts) — self-hosted deployments have no MM status
// endpoint, so a 503 there is always a normal server error.
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
const response = new Response("Service Unavailable", {
status: 503,
statusText: "Service Unavailable",
});
// When / Then
const result = handleApiResponse(response);
await expect(result).rejects.toThrow(
"Server is temporarily unavailable. Please try again in a few minutes.",
);
expect(fetchMaintenanceStatusMock).not.toHaveBeenCalled();
expect(redirectMock).not.toHaveBeenCalled();
});
it("is also a no-op when NEXT_PUBLIC_IS_CLOUD_ENV is unset", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", undefined);
const response = new Response(null, { status: 503 });
// When / Then
const result = handleApiResponse(response);
await expect(result).rejects.toThrow();
expect(fetchMaintenanceStatusMock).not.toHaveBeenCalled();
expect(redirectMock).not.toHaveBeenCalled();
});
});
});
+28
View File
@@ -1,6 +1,10 @@
import * as Sentry from "@sentry/nextjs";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { apiBaseUrl } from "@/lib";
import { fetchMaintenanceStatus, MAINTENANCE_PATH } from "@/lib/maintenance";
import { isCloud } from "@/lib/shared/env";
import { SentryErrorSource, SentryErrorType } from "@/sentry";
import {
@@ -20,6 +24,30 @@ export const handleApiResponse = async (
parse = true,
) => {
if (!response.ok) {
// Maintenance Mode is Cloud-only (see lib/maintenance.ts): self-hosted
// has no MM status endpoint, so a 503 there is always a normal server
// error, never a maintenance redirect — skip the check entirely.
//
// On Cloud: when MM flips on between the proxy check and a server action
// firing, the API returns 503 for every endpoint. Redirect to the
// full-screen /maintenance landing page instead of surfacing a generic
// server error. `redirect()` throws NEXT_REDIRECT, which Next turns into
// a client navigation, so this short-circuits before the
// Sentry-capturing 5xx branch below.
//
// A 503 alone isn't proof of MM though — any transient upstream error
// (DB blip, deploy rollout) also returns 503 and must NOT be conflated
// with real maintenance. Confirm against the status endpoint first;
// `fetchMaintenanceStatus` fails open (2s timeout → `enabled: false`), so
// a status-check blip falls through to normal error handling instead of
// redirecting.
if (isCloud() && response.status === 503) {
const status = await fetchMaintenanceStatus(apiBaseUrl);
if (status.enabled) {
redirect(MAINTENANCE_PATH);
}
}
// Read error body safely; prefer JSON, fallback to plain text
const rawErrorText = await response.text().catch(() => "");
const contentType = response.headers.get("content-type")?.toLowerCase();
+104
View File
@@ -0,0 +1,104 @@
import type { NextFetchEvent } from "next/server";
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
authHandlerSpy,
authMock,
fetchMaintenanceStatusMock,
maintenanceResponseMock,
} = vi.hoisted(() => ({
authHandlerSpy: vi.fn(),
// Minimal stand-in for next-auth's `auth()` wrapper: it takes the route
// handler and returns a function with the (req, ctx) signature `proxy()`
// calls `authProxy` with. The real handler reads `req.auth`, which a
// plain NextRequest doesn't have, so `user`/`sessionError` resolve to
// `undefined` here — irrelevant to what this suite asserts (the MM gate
// itself), it only needs to observe whether authProxy ran.
authMock: vi.fn(
(handler: (req: unknown) => unknown) => (req: unknown, _ctx: unknown) => {
authHandlerSpy(req);
return handler(req);
},
),
fetchMaintenanceStatusMock: vi.fn(),
maintenanceResponseMock: vi.fn(),
}));
vi.mock("@/auth.config", () => ({
auth: authMock,
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "http://api:8000/api/v1",
}));
vi.mock("@/lib/maintenance", () => ({
fetchMaintenanceStatus: fetchMaintenanceStatusMock,
maintenanceResponse: maintenanceResponseMock,
MAINTENANCE_PATH: "/maintenance",
}));
import proxy from "./proxy";
const makeRequest = (path: string) =>
new NextRequest(new URL(`https://app.prowler.com${path}`));
describe("proxy() Maintenance Mode gate (isCloud gating)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('runs the maintenance status check when NEXT_PUBLIC_IS_CLOUD_ENV is "true"', async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
fetchMaintenanceStatusMock.mockResolvedValueOnce({
enabled: false,
message: null,
started_at: null,
});
maintenanceResponseMock.mockReturnValueOnce(null);
// When
const response = await proxy(
makeRequest("/scans") as any,
{} as NextFetchEvent,
);
// Then: the gate ran (and, since it returned null here, fell through to
// the auth-wrapped handler, which redirects unauthenticated visitors).
expect(fetchMaintenanceStatusMock).toHaveBeenCalledTimes(1);
expect(maintenanceResponseMock).toHaveBeenCalledTimes(1);
expect(authHandlerSpy).toHaveBeenCalledTimes(1);
expect(response.status).toBe(307);
});
it('is a no-op in self-hosted (NEXT_PUBLIC_IS_CLOUD_ENV is not "true"): the status fetch is never made and the request falls straight through to auth', async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
// When
const response = await proxy(
makeRequest("/scans") as any,
{} as NextFetchEvent,
);
// Then
expect(fetchMaintenanceStatusMock).not.toHaveBeenCalled();
expect(maintenanceResponseMock).not.toHaveBeenCalled();
expect(authHandlerSpy).toHaveBeenCalledTimes(1);
expect(response.status).toBe(307);
});
it("is also a no-op when NEXT_PUBLIC_IS_CLOUD_ENV is unset", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", undefined);
// When
await proxy(makeRequest("/scans") as any, {} as NextFetchEvent);
// Then
expect(fetchMaintenanceStatusMock).not.toHaveBeenCalled();
expect(maintenanceResponseMock).not.toHaveBeenCalled();
});
});
+46 -3
View File
@@ -1,7 +1,10 @@
import { NextResponse } from "next/server";
import { NextFetchEvent, NextResponse } from "next/server";
import type { NextAuthRequest } from "next-auth";
import { auth } from "@/auth.config";
import { apiBaseUrl } from "@/lib";
import { fetchMaintenanceStatus, maintenanceResponse } from "@/lib/maintenance";
import { isCloud } from "@/lib/shared/env";
const publicRoutes = [
"/sign-in",
@@ -17,8 +20,13 @@ const isPublicRoute = (pathname: string): boolean => {
return publicRoutes.some((route) => pathname.startsWith(route));
};
// NextAuth's auth() wrapper - renamed from middleware to proxy
export default auth((req: NextAuthRequest) => {
// NextAuth's auth() wrapper - renamed from middleware to proxy.
//
// Maintenance Mode (MM) is a Cloud-only feature (see `lib/maintenance.ts`).
// Its gate runs from the exported `proxy()` wrapper below, guarded by
// `isCloud()`, so self-hosted deployments never issue the status fetch and
// this handler behaves exactly as it did before MM existed.
const authProxy = auth((req: NextAuthRequest) => {
const { pathname } = req.nextUrl;
const user = req.auth?.user;
@@ -56,6 +64,34 @@ export default auth((req: NextAuthRequest) => {
return NextResponse.next();
});
export default async function proxy(
req: NextAuthRequest,
ctx: NextFetchEvent,
): Promise<NextResponse> {
// Maintenance Mode is Cloud-only: self-hosted has no MM status endpoint,
// so skip the fetch entirely rather than issue it on every request only to
// fail open. Fail-open contract for the Cloud path itself: any error
// fetching the status is treated as MM off (never lock users out on a
// status blip). When MM is on, every matched request is rewritten to the
// dependency-free /maintenance landing page; when MM is off, /maintenance
// redirects back to /.
if (isCloud()) {
const status = await fetchMaintenanceStatus(apiBaseUrl);
const gate = maintenanceResponse(req, status);
if (gate) {
return gate;
}
}
// Delegate to the NextAuth-wrapped handler for normal auth/permission flow.
// Next passes a NextFetchEvent as the middleware context; next-auth's
// `auth()` wrapper types the param as AppRouteHandlerFnContext, so bridge it.
return (await authProxy(
req,
ctx as unknown as Parameters<typeof authProxy>[1],
)) as NextResponse;
}
export const config = {
matcher: [
/*
@@ -65,6 +101,13 @@ export const config = {
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - *.png, *.jpg, *.jpeg, *.svg, *.ico (image files)
*
* /maintenance IS matched (not excluded): when the Cloud MM gate is
* active it must run there too so its terminal branch (MM on + already
* on /maintenance) can return NextResponse.next() itself instead of
* falling through to authProxy, which would redirect an unauthenticated
* visitor to /sign-in. Self-hosted never reaches that branch (gated by
* isCloud() above), but the route stays unexcluded either way.
*/
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:png|jpg|jpeg|svg|ico|css|js)$).*)",
],