diff --git a/ui/app/maintenance/maintenance-view.test.tsx b/ui/app/maintenance/maintenance-view.test.tsx new file mode 100644 index 0000000000..e7f2beb35d --- /dev/null +++ b/ui/app/maintenance/maintenance-view.test.tsx @@ -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(); + + // 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(); + + // Then + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("reloads the page on an interval so the edge gate can redirect away", () => { + // Given + vi.useFakeTimers(); + + // When + render(); + 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( + , + ); + unmount(); + vi.advanceTimersByTime(15000); + + // Then + expect(reloadMock).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/app/maintenance/maintenance-view.tsx b/ui/app/maintenance/maintenance-view.tsx new file mode 100644 index 0000000000..e410070b5c --- /dev/null +++ b/ui/app/maintenance/maintenance-view.tsx @@ -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 ( +
+
+ {/* Background Pattern — mirrors the sign-in screen (AuthLayout) */} +
+ + {/* Prowler Logo */} +
+ +
+ + {/* Maintenance Card — same shell as the auth form container */} +
+ {/* Header with Title and Theme Toggle */} +
+

Under maintenance

+ +
+ +

{message}

+ +

+ This page refreshes automatically once maintenance is complete. +

+
+
+
+ ); +} diff --git a/ui/app/maintenance/page.test.tsx b/ui/app/maintenance/page.test.tsx new file mode 100644 index 0000000000..c9a28361fc --- /dev/null +++ b/ui/app/maintenance/page.test.tsx @@ -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) => { + vi.mocked(headers).mockResolvedValue( + new Headers(entries) as unknown as Awaited>, + ); +}; + +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(); + }); +}); diff --git a/ui/app/maintenance/page.tsx b/ui/app/maintenance/page.tsx new file mode 100644 index 0000000000..ad20011466 --- /dev/null +++ b/ui/app/maintenance/page.tsx @@ -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 ; +} diff --git a/ui/changelog.d/maintenance-mode-page.added.md b/ui/changelog.d/maintenance-mode-page.added.md new file mode 100644 index 0000000000..50fecff069 --- /dev/null +++ b/ui/changelog.d/maintenance-mode-page.added.md @@ -0,0 +1 @@ +Maintenance mode landing page, gated to Prowler Cloud via `NEXT_PUBLIC_IS_CLOUD_ENV` diff --git a/ui/lib/maintenance.test.ts b/ui/lib/maintenance.test.ts new file mode 100644 index 0000000000..27e6b705b6 --- /dev/null +++ b/ui/lib/maintenance.test.ts @@ -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|"); + }); +}); diff --git a/ui/lib/maintenance.ts b/ui/lib/maintenance.ts new file mode 100644 index 0000000000..f26c466bb0 --- /dev/null +++ b/ui/lib/maintenance.ts @@ -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 => { + 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; + + 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; +}; diff --git a/ui/lib/server-actions-helper.test.ts b/ui/lib/server-actions-helper.test.ts index bfb9fb5ced..4438bcd824 100644 --- a/ui/lib/server-actions-helper.test.ts +++ b/ui/lib/server-actions-helper.test.ts @@ -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(); + }); + }); }); diff --git a/ui/lib/server-actions-helper.ts b/ui/lib/server-actions-helper.ts index ae677f2ed2..44e7b03f9a 100644 --- a/ui/lib/server-actions-helper.ts +++ b/ui/lib/server-actions-helper.ts @@ -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(); diff --git a/ui/proxy.test.ts b/ui/proxy.test.ts new file mode 100644 index 0000000000..5c629df979 --- /dev/null +++ b/ui/proxy.test.ts @@ -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(); + }); +}); diff --git a/ui/proxy.ts b/ui/proxy.ts index d3bb8eb20e..efd6bda5c4 100644 --- a/ui/proxy.ts +++ b/ui/proxy.ts @@ -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 { + // 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[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)$).*)", ],