diff --git a/ui/app/api/scans/[scanId]/report/route.test.ts b/ui/app/api/scans/[scanId]/report/route.test.ts deleted file mode 100644 index f21c766ae8..0000000000 --- a/ui/app/api/scans/[scanId]/report/route.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -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 deleted file mode 100644 index 68571c9136..0000000000 --- a/ui/app/api/scans/[scanId]/report/route.ts +++ /dev/null @@ -1,98 +0,0 @@ -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 c9f5bd5a31..7d63cdec10 100644 --- a/ui/components/resources/table/resource-detail-content.test.ts +++ b/ui/components/resources/table/resource-detail-content.test.ts @@ -30,9 +30,7 @@ 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 deleted file mode 100644 index 4431a25ba7..0000000000 --- a/ui/lib/helper.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -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 d78724d356..662f1204c4 100644 --- a/ui/lib/helper.ts +++ b/ui/lib/helper.ts @@ -1,6 +1,7 @@ import { getComplianceCsv, getCompliancePdfReport, + getExportsZip, type ScanBinaryResult, } from "@/actions/scans"; import { getTask } from "@/actions/task"; @@ -105,50 +106,44 @@ export const downloadScanZip = async ( scanId: string, toast: ReturnType["toast"], ) => { - const reportUrl = `/api/scans/${encodeURIComponent(scanId)}/report`; + const result = await getExportsZip(scanId); - 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) { + if (result?.pending) { toast({ - variant: "destructive", - title: "Download Failed", - description: "Unable to start the report download. Please try again.", + title: "The report is still being generated", + description: "Please try again in a few minutes.", }); return; } - const a = document.createElement("a"); - a.href = reportUrl; - a.download = `scan-${scanId}-report.zip`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); + 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); + } - toast({ - title: "Download Started", - description: "Your browser is downloading the scan report.", - }); + 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.", + }); + } }; /**