93 lines
3.6 KiB
TypeScript
93 lines
3.6 KiB
TypeScript
// @vitest-environment node
|
|
// AK-UI-03 · the fail-closed AV gate in front of sendEmail. The scanner is
|
|
// injected (same seam storageService.body-limit.test.ts uses) so the test never
|
|
// needs a reachable clamd.
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
auditReject: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("~/utils/integrations/storageAudit.server", () => ({
|
|
auditRejectedUpload: vi.fn(() => mocks.auditReject),
|
|
}));
|
|
|
|
const { ComposeAttachmentScanError, scanComposeAttachments } = await import("./EmailAttachmentScan.server");
|
|
|
|
const request = new Request("https://app.test/acme/emails/new", { method: "POST" });
|
|
|
|
function target(filename = "angebot.pdf") {
|
|
return { filename, bytes: Uint8Array.from([1, 2, 3]) };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("scanComposeAttachments", () => {
|
|
it("resolves only after every attachment came back clean", async () => {
|
|
const scanner = { scanBuffer: vi.fn(async () => ({ verdict: "clean" as const, engine: "clamav" })) };
|
|
|
|
await expect(
|
|
scanComposeAttachments({ request, tenantId: "tenant-1", files: [target("a.pdf"), target("b.pdf")], scanner })
|
|
).resolves.toBeUndefined();
|
|
|
|
expect(scanner.scanBuffer).toHaveBeenCalledTimes(2);
|
|
expect(mocks.auditReject).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not call the scanner when there is nothing to send", async () => {
|
|
const scanner = { scanBuffer: vi.fn() };
|
|
|
|
await expect(scanComposeAttachments({ request, tenantId: "tenant-1", files: [], scanner })).resolves.toBeUndefined();
|
|
|
|
expect(scanner.scanBuffer).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects an infected attachment with the Klartext code and audits the verdict", async () => {
|
|
const scanner = {
|
|
scanBuffer: vi.fn(async () => ({ verdict: "infected" as const, signature: "Eicar-Test-Signature", engine: "clamav" })),
|
|
};
|
|
|
|
await expect(scanComposeAttachments({ request, tenantId: "tenant-1", files: [target("virus.exe")], scanner })).rejects.toMatchObject({
|
|
name: ComposeAttachmentScanError.name,
|
|
code: "attachment_infected",
|
|
filename: "virus.exe",
|
|
});
|
|
|
|
expect(mocks.auditReject).toHaveBeenCalledWith(expect.objectContaining({ status: "rejected", reason: "malware_detected" }));
|
|
// The signature is a scanner internal — it must never reach the audit payload.
|
|
expect(JSON.stringify(mocks.auditReject.mock.calls)).not.toContain("Eicar-Test-Signature");
|
|
});
|
|
|
|
it("fails closed when the scanner is unreachable — the mail is not sent", async () => {
|
|
const scanner = {
|
|
scanBuffer: vi.fn(async () => {
|
|
throw new Error("clamd unreachable at 10.42.0.4:3310");
|
|
}),
|
|
};
|
|
|
|
await expect(scanComposeAttachments({ request, tenantId: "tenant-1", files: [target()], scanner })).rejects.toMatchObject({
|
|
name: ComposeAttachmentScanError.name,
|
|
code: "attachment_scan_unavailable",
|
|
});
|
|
|
|
expect(mocks.auditReject).toHaveBeenCalledWith(expect.objectContaining({ status: "rejected", reason: "scanner_error" }));
|
|
});
|
|
|
|
it("stops at the first bad attachment instead of scanning the rest", async () => {
|
|
const scanner = {
|
|
scanBuffer: vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ verdict: "infected" as const, signature: "x", engine: "clamav" })
|
|
.mockResolvedValueOnce({ verdict: "clean" as const, engine: "clamav" }),
|
|
};
|
|
|
|
await expect(
|
|
scanComposeAttachments({ request, tenantId: "tenant-1", files: [target("bad.pdf"), target("good.pdf")], scanner })
|
|
).rejects.toBeInstanceOf(ComposeAttachmentScanError);
|
|
|
|
expect(scanner.scanBuffer).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|