mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-20 10:00:40 +00:00
Compare commits
1
Commits
master
...
feat/mm-ui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea310b1c68 |
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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`
|
||||
@@ -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|");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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)$).*)",
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user