Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/emailInbox/services/EmailAttachmentScan.server.test.ts
T

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);
});
});