Files
prowler/ui/app/api/ingestions/route.test.ts
T

346 lines
11 KiB
TypeScript

import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from "vitest";
import { POST } from "./route";
// Browser MSW intercepts the same-origin request before Next can run this
// Route Handler, so these tests call POST directly.
const { getAuthHeadersMock, isCloudMock } = vi.hoisted(() => ({
getAuthHeadersMock: vi.fn(),
isCloudMock: vi.fn(),
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: getAuthHeadersMock,
}));
vi.mock("@/lib/shared/env", () => ({
isCloud: isCloudMock,
}));
// A browser upload always reaches the route with a length on the wire, and the
// route refuses anything it cannot measure, so a forwarded Request needs one.
const uploadRequest = (body = "report") =>
new Request("http://localhost/api/ingestions", {
method: "POST",
headers: {
"content-length": String(new TextEncoder().encode(body).length),
},
body,
});
describe("POST /api/ingestions", () => {
const server = setupServer();
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
server.resetHandlers();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
afterAll(() => server.close());
it("returns not found in OSS and Local Server without authenticating or forwarding the upload", async () => {
isCloudMock.mockReturnValue(false);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const request = new Request("http://localhost/api/ingestions", {
method: "POST",
headers: { "content-type": "multipart/form-data; boundary=report" },
body: "--report--",
});
const response = await POST(request);
expect(response.status).toBe(404);
expect(request.body?.locked).toBe(false);
expect(getAuthHeadersMock).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
it("forwards the original multipart stream and boundary in Cloud", async () => {
isCloudMock.mockReturnValue(true);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
let contentType: string | null = null;
let contentLength: string | null = null;
let uploadedBody = "";
server.use(
http.post(
"https://api.example.com/api/v1/ingestions",
async ({ request }) => {
contentType = request.headers.get("content-type");
contentLength = request.headers.get("content-length");
uploadedBody = await request.text();
return HttpResponse.json({
data: {
id: "ingestion-123",
type: "ingestions",
attributes: {
status: "pending",
summary: { total: 0, processed: 0, invalid: 0 },
requested_at: "2026-08-26T11:23:20.265770Z",
started_at: null,
completed_at: null,
},
},
});
},
),
);
// Built by hand: a Request created from FormData carries no content-length
// header, which is what this test pins.
const boundary = "----ingestionBoundary";
const multipartBody = [
`--${boundary}`,
'Content-Disposition: form-data; name="file"; filename="findings.ocsf.json"',
"Content-Type: application/json",
"",
"finding report",
`--${boundary}--`,
"",
].join("\r\n");
const request = new Request("http://localhost/api/ingestions", {
method: "POST",
headers: {
"content-type": `multipart/form-data; boundary=${boundary}`,
"content-length": String(
new TextEncoder().encode(multipartBody).length,
),
},
body: multipartBody,
});
const response = await POST(request);
expect(contentType).toBe(`multipart/form-data; boundary=${boundary}`);
expect(contentLength).toBe(
String(new TextEncoder().encode(multipartBody).length),
);
expect(uploadedBody).toBe(multipartBody);
await expect(response.json()).resolves.toEqual({
data: {
id: "ingestion-123",
status: "pending",
totalRecords: 0,
processedRecords: 0,
invalidRecords: 0,
},
});
});
it("refuses an upload it cannot measure instead of forwarding it chunked", async () => {
isCloudMock.mockReturnValue(true);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
// A Request built from FormData carries no content-length, and the ingestion
// API parses no file out of the chunked body that would be forwarded.
const request = new Request("http://localhost/api/ingestions", {
method: "POST",
headers: { "content-type": "multipart/form-data; boundary=report" },
body: "--report--",
});
const response = await POST(request);
expect(response.status).toBe(411);
expect(fetchMock).not.toHaveBeenCalled();
expect(request.body?.locked).toBe(false);
await expect(response.json()).resolves.toEqual({
error: "Unable to start the import. Please try again.",
});
});
it("sanitizes unexpected upstream error pages", async () => {
isCloudMock.mockReturnValue(true);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
server.use(
http.post(
"https://api.example.com/api/v1/ingestions",
() =>
new HttpResponse("<html><body>upstream details</body></html>", {
status: 502,
headers: { "content-type": "text/html" },
}),
),
);
const response = await POST(uploadRequest());
expect(response.status).toBe(502);
await expect(response.json()).resolves.toEqual({
error: "Unable to start the import. Please try again.",
});
});
it("returns a safe bad gateway response when the ingestion API connection fails", async () => {
// Given
isCloudMock.mockReturnValue(true);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
vi.stubGlobal(
"fetch",
vi.fn().mockRejectedValue(new Error("connect ECONNREFUSED api.internal")),
);
// When
const response = await POST(uploadRequest());
// Then
expect(response.status).toBe(502);
await expect(response.json()).resolves.toEqual({
error: "Unable to start the import. Please try again.",
});
});
it.each([
[400, "invalid", "The report is not a valid Prowler OCSF finding report."],
[
402,
"subscription_required",
"A Prowler Cloud subscription is required to import findings.",
],
[
403,
"permission_denied",
"You do not have permission to import findings.",
],
[
413,
"file_too_large",
"The selected file exceeds the allowed upload size.",
],
[
429,
"rate_limited",
"Too many import requests. Please try again shortly.",
],
])(
"maps known upstream rejection %i/%s to safe import guidance",
async (status, code, message) => {
isCloudMock.mockReturnValue(true);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
server.use(
http.post("https://api.example.com/api/v1/ingestions", () =>
HttpResponse.json(
{ errors: [{ code, detail: "internal implementation detail" }] },
{ status },
),
),
);
const response = await POST(uploadRequest());
expect(response.status).toBe(status);
await expect(response.json()).resolves.toEqual({ error: message });
},
);
const STATUS_TIER_REJECTIONS = [
[400, "The report is not a valid Prowler OCSF finding report."],
[402, "A Prowler Cloud subscription is required to import findings."],
[403, "You do not have permission to import findings."],
[413, "The selected file exceeds the allowed upload size."],
[429, "Too many import requests. Please try again shortly."],
] as const;
it.each(STATUS_TIER_REJECTIONS)(
"maps a codeless upstream rejection to the %i status guidance",
async (status, message) => {
isCloudMock.mockReturnValue(true);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
server.use(
http.post("https://api.example.com/api/v1/ingestions", () =>
HttpResponse.json(
{ detail: "internal implementation detail" },
{ status },
),
),
);
const response = await POST(uploadRequest());
expect(response.status).toBe(status);
await expect(response.json()).resolves.toEqual({ error: message });
},
);
it.each(STATUS_TIER_REJECTIONS)(
"falls through an unrecognized error code to the %i status guidance",
async (status, message) => {
isCloudMock.mockReturnValue(true);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
server.use(
http.post("https://api.example.com/api/v1/ingestions", () =>
HttpResponse.json(
{
errors: [
{
code: "invalid_findings",
detail: "internal implementation detail",
},
],
},
{ status },
),
),
);
const response = await POST(uploadRequest());
expect(response.status).toBe(status);
await expect(response.json()).resolves.toEqual({ error: message });
},
);
// An empty id parses into a trackable-looking job whose poll URL,
// `/api/ingestions/`, matches no route: a created import reads as a failure.
it.each([
["no job identifier", { attributes: { status: "pending" } }],
["an empty job identifier", { id: "", attributes: { status: "pending" } }],
])("refuses an accepted response with %s", async (_shape, data) => {
isCloudMock.mockReturnValue(true);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
server.use(
http.post("https://api.example.com/api/v1/ingestions", () =>
HttpResponse.json({ data }),
),
);
const response = await POST(uploadRequest());
expect(response.status).toBe(502);
await expect(response.json()).resolves.toEqual({
error: "Unable to start the import. Please try again.",
});
});
it("rejects accepted responses that cannot start a trackable ingestion", async () => {
isCloudMock.mockReturnValue(true);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
server.use(
http.post("https://api.example.com/api/v1/ingestions", () =>
HttpResponse.json({ data: { id: "ingestion-123", attributes: {} } }),
),
);
const response = await POST(uploadRequest());
expect(response.status).toBe(502);
await expect(response.json()).resolves.toEqual({
error: "Unable to start the import. Please try again.",
});
});
});