diff --git a/ui/app/api/scans/[scanId]/report/route.test.ts b/ui/app/api/scans/[scanId]/report/route.test.ts new file mode 100644 index 0000000000..f21c766ae8 --- /dev/null +++ b/ui/app/api/scans/[scanId]/report/route.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { GET } from "./route"; + +const { getAuthHeadersMock } = vi.hoisted(() => ({ + getAuthHeadersMock: vi.fn(), +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, +})); + +describe("GET /api/scans/[scanId]/report", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it("streams the upstream report body without buffering it", async () => { + const upstreamBody = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])); + controller.close(); + }, + }); + const fetchMock = vi.fn().mockResolvedValue( + new Response(upstreamBody, { + status: 200, + headers: { + "content-type": "application/zip", + "content-length": "3", + }, + }), + ); + 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({ + headers: { Authorization: "Bearer token" }, + cache: "no-store", + }), + ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/zip"); + expect(response.headers.get("content-length")).toBe("3"); + expect(response.headers.get("content-disposition")).toBe( + 'attachment; filename="scan-scan-123-report.zip"', + ); + expect(response.body).toBe(upstreamBody); + }); + + it("checks report readiness without streaming ready report bytes", async () => { + const cancelMock = vi.fn(); + const upstreamBody = new ReadableStream({ + cancel: cancelMock, + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])); + }, + }); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(upstreamBody, { status: 200 })), + ); + 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(cancelMock).toHaveBeenCalledTimes(1); + }); + + it("preserves pending report responses from the API", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + Response.json({ data: { id: "task-1" } }, { status: 202 }), + ), + ); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + + const response = await GET(new Request("http://localhost/api"), { + params: Promise.resolve({ scanId: "scan-123" }), + }); + + expect(response.status).toBe(202); + await expect(response.json()).resolves.toEqual({ data: { id: "task-1" } }); + }); +}); diff --git a/ui/app/api/scans/[scanId]/report/route.ts b/ui/app/api/scans/[scanId]/report/route.ts new file mode 100644 index 0000000000..68571c9136 --- /dev/null +++ b/ui/app/api/scans/[scanId]/report/route.ts @@ -0,0 +1,98 @@ +import { NextResponse } from "next/server"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +interface ScanReportRouteContext { + params: Promise<{ + scanId: string; + }>; +} + +const COPY_RESPONSE_HEADERS = [ + "content-length", + "content-type", + "etag", + "last-modified", +] as const; + +const buildAttachmentFilename = (scanId: string) => + `scan-${scanId.replace(/[^a-zA-Z0-9._-]/g, "-")}-report.zip`; + +const buildDownloadHeaders = (upstreamHeaders: Headers, scanId: string) => { + const headers = new Headers({ + "Cache-Control": "no-store", + "Content-Disposition": `attachment; filename="${buildAttachmentFilename(scanId)}"`, + }); + + COPY_RESPONSE_HEADERS.forEach((headerName) => { + const value = upstreamHeaders.get(headerName); + if (value) headers.set(headerName, value); + }); + + if (!headers.has("content-type")) { + headers.set("content-type", "application/zip"); + } + + return headers; +}; + +export async function GET( + request: Request, + { params }: ScanReportRouteContext, +) { + const { scanId } = await params; + const headers = await getAuthHeaders({ contentType: false }); + const upstreamUrl = `${apiBaseUrl}/scans/${encodeURIComponent(scanId)}/report`; + const isPreflight = + new URL(request.url).searchParams.get("preflight") === "1"; + + const upstreamResponse = await fetch(upstreamUrl, { + headers, + cache: "no-store", + }); + + if (upstreamResponse.status === 202) { + const body = await upstreamResponse.json().catch(() => ({})); + return NextResponse.json(body, { + status: 202, + headers: { "Cache-Control": "no-store" }, + }); + } + + if (!upstreamResponse.ok) { + const body = 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", + }, + }); + } + + if (isPreflight) { + await upstreamResponse.body?.cancel(); + return new Response(null, { + status: 204, + headers: { "Cache-Control": "no-store" }, + }); + } + + if (!upstreamResponse.body) { + return NextResponse.json( + { error: "Report response did not include a readable body." }, + { status: 502, headers: { "Cache-Control": "no-store" } }, + ); + } + + return new Response(upstreamResponse.body, { + status: upstreamResponse.status, + statusText: upstreamResponse.statusText, + headers: buildDownloadHeaders(upstreamResponse.headers, scanId), + }); +} diff --git a/ui/components/resources/table/resource-detail-content.test.ts b/ui/components/resources/table/resource-detail-content.test.ts index 7d63cdec10..c9f5bd5a31 100644 --- a/ui/components/resources/table/resource-detail-content.test.ts +++ b/ui/components/resources/table/resource-detail-content.test.ts @@ -30,7 +30,9 @@ describe("resource detail content", () => { it("renders the external resource link below the resource title row", () => { expect(source).toContain(` { diff --git a/ui/lib/helper.test.ts b/ui/lib/helper.test.ts new file mode 100644 index 0000000000..4431a25ba7 --- /dev/null +++ b/ui/lib/helper.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { downloadScanZip } from "./helper"; + +vi.mock("@/actions/scans", () => ({ + getComplianceCsv: vi.fn(), + getCompliancePdfReport: vi.fn(), +})); + +vi.mock("@/actions/task", () => ({ + getTask: vi.fn(), +})); + +vi.mock("@/auth.config", () => ({ + auth: vi.fn(), +})); + +const createToast = () => vi.fn(); + +const getAnchor = () => { + const anchor = document.createElement("a"); + const clickMock = vi.spyOn(anchor, "click").mockImplementation(() => {}); + vi.spyOn(document, "createElement").mockReturnValue(anchor); + return { anchor, clickMock }; +}; + +describe("downloadScanZip", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + document.body.replaceChildren(); + }); + + it("preflights the report and starts a browser-native download when ready", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(null, { status: 204 })), + ); + const { anchor, clickMock } = getAnchor(); + const toast = createToast(); + + await downloadScanZip("scan-123", toast); + + expect(fetch).toHaveBeenCalledWith( + "/api/scans/scan-123/report?preflight=1", + { + cache: "no-store", + }, + ); + expect(anchor.href).toContain("/api/scans/scan-123/report"); + expect(anchor.download).toBe("scan-scan-123-report.zip"); + expect(clickMock).toHaveBeenCalledTimes(1); + expect(toast).toHaveBeenCalledWith({ + title: "Download Started", + description: "Your browser is downloading the scan report.", + }); + }); + + it("shows the pending report message without starting a download", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response("{}", { status: 202 })), + ); + const { clickMock } = getAnchor(); + const toast = createToast(); + + await downloadScanZip("scan-123", toast); + + expect(clickMock).not.toHaveBeenCalled(); + expect(toast).toHaveBeenCalledWith({ + title: "The report is still being generated", + description: "Please try again in a few minutes.", + }); + }); + + it("shows an error without starting a download when preflight fails", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response("not found", { status: 404 })), + ); + const { clickMock } = getAnchor(); + const toast = createToast(); + + await downloadScanZip("scan-123", toast); + + expect(clickMock).not.toHaveBeenCalled(); + expect(toast).toHaveBeenCalledWith({ + variant: "destructive", + title: "Download Failed", + description: "not found", + }); + }); +}); diff --git a/ui/lib/helper.ts b/ui/lib/helper.ts index 662f1204c4..d78724d356 100644 --- a/ui/lib/helper.ts +++ b/ui/lib/helper.ts @@ -1,7 +1,6 @@ import { getComplianceCsv, getCompliancePdfReport, - getExportsZip, type ScanBinaryResult, } from "@/actions/scans"; import { getTask } from "@/actions/task"; @@ -106,44 +105,50 @@ export const downloadScanZip = async ( scanId: string, toast: ReturnType["toast"], ) => { - const result = await getExportsZip(scanId); + const reportUrl = `/api/scans/${encodeURIComponent(scanId)}/report`; - if (result?.pending) { + try { + const preflightResponse = await fetch(`${reportUrl}?preflight=1`, { + cache: "no-store", + }); + + if (preflightResponse.status === 202) { + toast({ + title: "The report is still being generated", + description: "Please try again in a few minutes.", + }); + return; + } + + if (!preflightResponse.ok) { + const errorMessage = await preflightResponse.text(); + toast({ + variant: "destructive", + title: "Download Failed", + description: errorMessage || "An unknown error occurred.", + }); + return; + } + } catch (_error) { toast({ - title: "The report is still being generated", - description: "Please try again in a few minutes.", + variant: "destructive", + title: "Download Failed", + description: "Unable to start the report download. Please try again.", }); return; } - if (result?.success && result.data) { - const binaryString = window.atob(result.data); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } + const a = document.createElement("a"); + a.href = reportUrl; + a.download = `scan-${scanId}-report.zip`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); - const blob = new Blob([bytes], { type: "application/zip" }); - const url = window.URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = result.filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - window.URL.revokeObjectURL(url); - - toast({ - title: "Download Complete", - description: "Your scan report has been downloaded successfully.", - }); - } else { - toast({ - variant: "destructive", - title: "Download Failed", - description: result?.error || "An unknown error occurred.", - }); - } + toast({ + title: "Download Started", + description: "Your browser is downloading the scan report.", + }); }; /**