diff --git a/lib/services/phishing-eml-service.test.ts b/lib/services/phishing-eml-service.test.ts new file mode 100644 index 0000000..0fad974 --- /dev/null +++ b/lib/services/phishing-eml-service.test.ts @@ -0,0 +1,195 @@ +/** + * phishing-eml-service.ts — parseAndStoreMessage orchestration tests. + * + * postgresClient.query, getAutotaskClient (Autotask factory), and the B2 + * client's isB2Configured/presignUpload are all mocked (following + * pax8-sync-service.test.ts / notify.test.ts's mocking discipline) — no real + * Postgres, Autotask, or B2 credentials/network are used. All `.eml` content + * is imported from eml-parser.fixtures.ts (synthetic only, per this + * milestone's Out of Scope constraint — no real customer email). + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock postgresClient BEFORE importing the module under test. +const queryMock = vi.fn(); +vi.mock('./postgres-client', () => ({ + postgresClient: { + query: (...args: unknown[]) => queryMock(...args), + }, +})); + +// Mock the Autotask factory so no real credentials/network are required. +const getAttachmentsMock = vi.fn(); +const getAttachmentContentMock = vi.fn(); +vi.mock('./autotask-factory', () => ({ + getAutotaskClient: () => ({ + getAttachments: (...args: unknown[]) => getAttachmentsMock(...args), + getAttachmentContent: (...args: unknown[]) => getAttachmentContentMock(...args), + }), +})); + +// Mock the B2 client — isB2Configured/presignUpload are stubbed per-test. +const isB2ConfiguredMock = vi.fn(); +const presignUploadMock = vi.fn(); +vi.mock('./b2/client', () => ({ + isB2Configured: () => isB2ConfiguredMock(), + presignUpload: (...args: unknown[]) => presignUploadMock(...args), + EML_OBJECT_KEY_REGEX: /^phishing\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+\.eml$/, +})); + +// Import AFTER the mocks are declared so vi.mock hoisting takes effect. +import { parseAndStoreMessage } from './phishing-eml-service'; +import { + RFC_EML_TIER_ATTACHMENTS, + NO_EML_ATTACHMENTS, + RICH_MULTIPART_EML, +} from './eml-parser.fixtures'; + +const REPORT_ID = 'report-abc-123'; +const TICKET_ID = 999; +const SELECTED_ATTACHMENT_ID = RFC_EML_TIER_ATTACHMENTS[0].id; // rfc.eml, tier 1 +// The exact URL embedded in RICH_MULTIPART_EML's text+html body (16-01 fixture). +const MESSAGE_BODY_URL = 'http://evil-example.test/verify'; + +function selectedAttachmentWithContent() { + return { + ...RFC_EML_TIER_ATTACHMENTS[0], + data: RICH_MULTIPART_EML.toString('base64'), + }; +} + +/** Extracts [sql, params] tuples for calls whose SQL contains `needle`. */ +function callsContaining(needle: string): Array<[string, unknown[]]> { + return queryMock.mock.calls + .filter(([sql]) => String(sql).includes(needle)) + .map(([sql, params]) => [String(sql), (params as unknown[]) ?? []]); +} + +describe('parseAndStoreMessage', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + queryMock.mockReset(); + getAttachmentsMock.mockReset(); + getAttachmentContentMock.mockReset(); + isB2ConfiguredMock.mockReset(); + presignUploadMock.mockReset(); + + // Default: any INSERT ... RETURNING resolves with a single row. + queryMock.mockResolvedValue({ rows: [{ id: 'message-uuid-1' }], rowCount: 1 }); + + global.fetch = vi.fn().mockResolvedValue(new Response('ok', { status: 200 })); + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('lists attachments, selects the original message, parses it, and writes one messages row plus indicators (happy path)', async () => { + getAttachmentsMock.mockResolvedValue(RFC_EML_TIER_ATTACHMENTS); + getAttachmentContentMock.mockResolvedValue(selectedAttachmentWithContent()); + isB2ConfiguredMock.mockReturnValue(false); + + const result = await parseAndStoreMessage({ reportId: REPORT_ID, ticketId: TICKET_ID }); + + expect(result).toEqual({ stored: true, messageId: 'message-uuid-1' }); + expect(getAttachmentsMock).toHaveBeenCalledWith('Tickets', TICKET_ID); + expect(getAttachmentContentMock).toHaveBeenCalledWith('Tickets', TICKET_ID, SELECTED_ATTACHMENT_ID); + + const messageInserts = callsContaining('INSERT INTO messages'); + expect(messageInserts).toHaveLength(1); + + const [, messageParams] = messageInserts[0]; + const headersPayload = JSON.parse(messageParams[2] as string); + expect(headersPayload.authResults).toEqual({ spf: 'pass', dkim: 'fail', dmarc: 'none' }); + + const indicatorInserts = callsContaining('INSERT INTO indicators'); + expect(indicatorInserts.length).toBeGreaterThan(0); + }); + + it('returns { stored: false } and never inserts a messages row when no .eml attachment is found', async () => { + getAttachmentsMock.mockResolvedValue(NO_EML_ATTACHMENTS); + + const result = await parseAndStoreMessage({ reportId: REPORT_ID, ticketId: TICKET_ID }); + + expect(result).toEqual({ stored: false, reason: 'no-eml-attachment' }); + expect(getAttachmentContentMock).not.toHaveBeenCalled(); + expect(callsContaining('INSERT INTO messages')).toHaveLength(0); + }); + + it('persists with raw_ref null and never calls presignUpload/PUT when B2 is unconfigured', async () => { + getAttachmentsMock.mockResolvedValue(RFC_EML_TIER_ATTACHMENTS); + getAttachmentContentMock.mockResolvedValue(selectedAttachmentWithContent()); + isB2ConfiguredMock.mockReturnValue(false); + + await parseAndStoreMessage({ reportId: REPORT_ID, ticketId: TICKET_ID }); + + expect(presignUploadMock).not.toHaveBeenCalled(); + expect(global.fetch).not.toHaveBeenCalled(); + + const [, messageParams] = callsContaining('INSERT INTO messages')[0]; + expect(messageParams[6]).toBeNull(); // raw_ref column + }); + + it('sets raw_ref to phishing/{reportId}/{attachmentId}.eml and PUTs exactly once when B2 is configured', async () => { + getAttachmentsMock.mockResolvedValue(RFC_EML_TIER_ATTACHMENTS); + getAttachmentContentMock.mockResolvedValue(selectedAttachmentWithContent()); + isB2ConfiguredMock.mockReturnValue(true); + presignUploadMock.mockReturnValue('https://s3.example.test/presigned-put-url'); + + await parseAndStoreMessage({ reportId: REPORT_ID, ticketId: TICKET_ID }); + + expect(presignUploadMock).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith( + 'https://s3.example.test/presigned-put-url', + expect.objectContaining({ method: 'PUT' }) + ); + + const [, messageParams] = callsContaining('INSERT INTO messages')[0]; + expect(messageParams[6]).toBe(`phishing/${REPORT_ID}/${SELECTED_ATTACHMENT_ID}.eml`); + }); + + it('never calls fetch with any URL extracted from the message body (no-network invariant)', async () => { + getAttachmentsMock.mockResolvedValue(RFC_EML_TIER_ATTACHMENTS); + getAttachmentContentMock.mockResolvedValue(selectedAttachmentWithContent()); + isB2ConfiguredMock.mockReturnValue(true); + presignUploadMock.mockReturnValue('https://s3.example.test/presigned-put-url'); + + await parseAndStoreMessage({ reportId: REPORT_ID, ticketId: TICKET_ID }); + + const fetchMock = global.fetch as unknown as ReturnType; + for (const call of fetchMock.mock.calls) { + expect(call[0]).not.toBe(MESSAGE_BODY_URL); + } + // The only fetch call made is the mocked B2 presigned PUT target. + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith('https://s3.example.test/presigned-put-url', expect.anything()); + }); + + it('writes an attachment_hash indicator whose metadata carries filename/contentType/size/related', async () => { + getAttachmentsMock.mockResolvedValue(RFC_EML_TIER_ATTACHMENTS); + getAttachmentContentMock.mockResolvedValue(selectedAttachmentWithContent()); + isB2ConfiguredMock.mockReturnValue(false); + + await parseAndStoreMessage({ reportId: REPORT_ID, ticketId: TICKET_ID }); + + const attachmentHashInserts = callsContaining('INSERT INTO indicators').filter( + ([, params]) => params[1] === 'attachment_hash' + ); + expect(attachmentHashInserts).toHaveLength(1); + + const [, params] = attachmentHashInserts[0]; + const metadata = JSON.parse(params[3] as string); + expect(metadata).toEqual( + expect.objectContaining({ + filename: 'invoice.pdf', + contentType: 'application/pdf', + size: expect.any(Number), + related: false, + }) + ); + }); +});