mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-05 03:12:14 +00:00
fix(ui): avoid report preflight timeouts (#11350)
Co-authored-by: Adrián Jesús Peña Rodríguez <adrianjpr@gmail.com>
This commit is contained in:
co-authored by
Adrián Jesús Peña Rodríguez
parent
723d161c63
commit
a70bc3c1c7
@@ -45,6 +45,7 @@ describe("GET /api/scans/[scanId]/report", () => {
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer token" },
|
||||
cache: "no-store",
|
||||
redirect: "manual",
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
@@ -82,6 +83,56 @@ describe("GET /api/scans/[scanId]/report", () => {
|
||||
expect(cancelMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("redirects the browser to the presigned URL for S3-backed reports", async () => {
|
||||
const presignedUrl = "https://bucket.s3.example.com/report.zip?sig=abc";
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: presignedUrl },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
|
||||
const response = await GET(new Request("http://localhost/api"), {
|
||||
params: Promise.resolve({ scanId: "scan-123" }),
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.example.com/api/v1/scans/scan-123/report",
|
||||
expect.objectContaining({ redirect: "manual" }),
|
||||
);
|
||||
expect(response.status).toBe(307);
|
||||
expect(response.headers.get("location")).toBe(presignedUrl);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(response.body).toBeNull();
|
||||
});
|
||||
|
||||
it("reports readiness without exposing the presigned URL on preflight", async () => {
|
||||
const presignedUrl = "https://bucket.s3.example.com/report.zip?sig=abc";
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: presignedUrl },
|
||||
}),
|
||||
),
|
||||
);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
|
||||
const response = await GET(
|
||||
new Request("http://localhost/api?preflight=1"),
|
||||
{
|
||||
params: Promise.resolve({ scanId: "scan-123" }),
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(response.headers.get("location")).toBeNull();
|
||||
expect(response.body).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves pending report responses from the API", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
@@ -100,4 +151,52 @@ describe("GET /api/scans/[scanId]/report", () => {
|
||||
expect(response.status).toBe(202);
|
||||
await expect(response.json()).resolves.toEqual({ data: { id: "task-1" } });
|
||||
});
|
||||
|
||||
it("continues to the browser-native download when preflight times out", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockRejectedValue(new DOMException("Timed out", "TimeoutError")),
|
||||
);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
|
||||
const response = await GET(
|
||||
new Request("http://localhost/api?preflight=1"),
|
||||
{
|
||||
params: Promise.resolve({ scanId: "scan-123" }),
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(response.body).toBeNull();
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
});
|
||||
|
||||
it("does not forward upstream HTML error pages for preflight failures", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
"<html><body><h1>504 Gateway Time-out</h1></body></html>",
|
||||
{
|
||||
status: 504,
|
||||
headers: { "content-type": "text/html" },
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
|
||||
const response = await GET(
|
||||
new Request("http://localhost/api?preflight=1"),
|
||||
{
|
||||
params: Promise.resolve({ scanId: "scan-123" }),
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(504);
|
||||
expect(response.headers.get("content-type")).toContain("text/plain");
|
||||
await expect(response.text()).resolves.toBe(
|
||||
"Unable to prepare the scan report. Please try again in a few minutes.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,10 @@ const COPY_RESPONSE_HEADERS = [
|
||||
"last-modified",
|
||||
] as const;
|
||||
|
||||
const PREFLIGHT_TIMEOUT_MS = 10_000;
|
||||
const REPORT_PREPARATION_ERROR =
|
||||
"Unable to prepare the scan report. Please try again in a few minutes.";
|
||||
|
||||
const buildAttachmentFilename = (scanId: string) =>
|
||||
`scan-${scanId.replace(/[^a-zA-Z0-9._-]/g, "-")}-report.zip`;
|
||||
|
||||
@@ -39,6 +43,21 @@ const buildDownloadHeaders = (upstreamHeaders: Headers, scanId: string) => {
|
||||
return headers;
|
||||
};
|
||||
|
||||
const isAbortError = (error: unknown) =>
|
||||
error instanceof DOMException &&
|
||||
(error.name === "AbortError" || error.name === "TimeoutError");
|
||||
|
||||
const isHtmlResponse = (headers: Headers) =>
|
||||
headers.get("content-type")?.toLowerCase().includes("text/html") ?? false;
|
||||
|
||||
const isRedirect = (status: number) => status >= 300 && status < 400;
|
||||
|
||||
const preflightReadyResponse = () =>
|
||||
new Response(null, {
|
||||
status: 204,
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: ScanReportRouteContext,
|
||||
@@ -49,10 +68,27 @@ export async function GET(
|
||||
const isPreflight =
|
||||
new URL(request.url).searchParams.get("preflight") === "1";
|
||||
|
||||
const upstreamResponse = await fetch(upstreamUrl, {
|
||||
headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
let upstreamResponse: Response;
|
||||
|
||||
try {
|
||||
upstreamResponse = await fetch(upstreamUrl, {
|
||||
headers,
|
||||
cache: "no-store",
|
||||
// The API redirects S3-backed reports to a presigned URL; keep that
|
||||
// redirect instead of following it so the bytes never stream through
|
||||
// this server.
|
||||
redirect: "manual",
|
||||
signal: isPreflight
|
||||
? AbortSignal.timeout(PREFLIGHT_TIMEOUT_MS)
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isPreflight && isAbortError(error)) {
|
||||
return preflightReadyResponse();
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (upstreamResponse.status === 202) {
|
||||
const body = await upstreamResponse.json().catch(() => ({}));
|
||||
@@ -62,25 +98,51 @@ export async function GET(
|
||||
});
|
||||
}
|
||||
|
||||
// S3-backed reports: hand the API's presigned redirect to the browser so it
|
||||
// downloads straight from S3 without proxying the bytes through this server.
|
||||
if (isRedirect(upstreamResponse.status)) {
|
||||
if (isPreflight) {
|
||||
return preflightReadyResponse();
|
||||
}
|
||||
|
||||
const location = upstreamResponse.headers.get("location");
|
||||
if (!location) {
|
||||
return NextResponse.json(
|
||||
{ error: "Report redirect did not include a location." },
|
||||
{ status: 502, headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(null, {
|
||||
status: 307,
|
||||
headers: { Location: location, "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
|
||||
if (!upstreamResponse.ok) {
|
||||
const body = await upstreamResponse.text().catch(() => "");
|
||||
const body =
|
||||
isPreflight && isHtmlResponse(upstreamResponse.headers)
|
||||
? REPORT_PREPARATION_ERROR
|
||||
: await upstreamResponse.text().catch(() => "");
|
||||
|
||||
return new Response(body, {
|
||||
status: upstreamResponse.status,
|
||||
statusText: upstreamResponse.statusText,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type":
|
||||
upstreamResponse.headers.get("content-type") || "text/plain",
|
||||
isPreflight && isHtmlResponse(upstreamResponse.headers)
|
||||
? "text/plain"
|
||||
: upstreamResponse.headers.get("content-type") || "text/plain",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Self-hosted without S3: the API returns the bytes directly, so there is no
|
||||
// presigned URL to redirect to and we stream the response through instead.
|
||||
if (isPreflight) {
|
||||
await upstreamResponse.body?.cancel();
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
return preflightReadyResponse();
|
||||
}
|
||||
|
||||
if (!upstreamResponse.body) {
|
||||
|
||||
@@ -90,4 +90,31 @@ describe("downloadScanZip", () => {
|
||||
description: "not found",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a generic error when preflight fails with an HTML gateway page", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
"<html><body><h1>504 Gateway Time-out</h1></body></html>",
|
||||
{
|
||||
status: 504,
|
||||
headers: { "content-type": "text/html" },
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
const { clickMock } = getAnchor();
|
||||
const toast = createToast();
|
||||
|
||||
await downloadScanZip("scan-123", toast);
|
||||
|
||||
expect(clickMock).not.toHaveBeenCalled();
|
||||
expect(toast).toHaveBeenCalledWith({
|
||||
variant: "destructive",
|
||||
title: "Download Failed",
|
||||
description:
|
||||
"Unable to prepare the scan report. Please try again in a few minutes.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+14
-2
@@ -101,6 +101,19 @@ export const getAuthUrl = (provider: AuthSocialProvider) => {
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const REPORT_PREPARATION_ERROR =
|
||||
"Unable to prepare the scan report. Please try again in a few minutes.";
|
||||
|
||||
const getPreflightErrorMessage = async (response: Response) => {
|
||||
const contentType = response.headers.get("content-type")?.toLowerCase() || "";
|
||||
|
||||
if (contentType.includes("text/html")) {
|
||||
return REPORT_PREPARATION_ERROR;
|
||||
}
|
||||
|
||||
return (await response.text()) || "An unknown error occurred.";
|
||||
};
|
||||
|
||||
export const downloadScanZip = async (
|
||||
scanId: string,
|
||||
toast: ReturnType<typeof useToast>["toast"],
|
||||
@@ -121,11 +134,10 @@ export const downloadScanZip = async (
|
||||
}
|
||||
|
||||
if (!preflightResponse.ok) {
|
||||
const errorMessage = await preflightResponse.text();
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Download Failed",
|
||||
description: errorMessage || "An unknown error occurred.",
|
||||
description: await getPreflightErrorMessage(preflightResponse),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user