/** * 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. The new // leading `SELECT id FROM messages WHERE report_id` idempotency check // (Defect 3, 260717-a19) must resolve empty by default so existing // happy-path tests aren't short-circuited as 'already-parsed'. queryMock.mockImplementation((sql: string) => { if (String(sql).includes('FROM messages WHERE report_id')) { return Promise.resolve({ rows: [], rowCount: 0 }); } return Promise.resolve({ 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, }) ); }); it('returns { stored: false, reason: "already-parsed" } and does no work when a messages row already exists for the report (Defect 3, 260717-a19)', async () => { queryMock.mockImplementation((sql: string) => { if (String(sql).includes('FROM messages WHERE report_id')) { return Promise.resolve({ rows: [{ id: 'existing-message-uuid' }], rowCount: 1 }); } return Promise.resolve({ rows: [{ id: 'message-uuid-1' }], rowCount: 1 }); }); const result = await parseAndStoreMessage({ reportId: REPORT_ID, ticketId: TICKET_ID }); expect(result).toEqual({ stored: false, reason: 'already-parsed' }); expect(getAttachmentsMock).not.toHaveBeenCalled(); expect(getAttachmentContentMock).not.toHaveBeenCalled(); expect(callsContaining('INSERT INTO messages')).toHaveLength(0); }); });