test(16-03): phishing-eml-service.test.ts orchestration coverage (mocked I/O)

- mocks postgresClient, getAutotaskClient, and B2 client (isB2Configured/presignUpload); no real Postgres/Autotask/B2 credentials or network
- happy path: one messages insert with D-06 auth verdicts in headers, indicators inserted
- no-eml no-op: NO_EML_ATTACHMENTS -> stored:false, no messages insert
- B2 unconfigured: raw_ref null, no presignUpload/PUT
- B2 configured: raw_ref = phishing/{reportId}/{attachmentId}.eml, exactly one PUT
- no-network invariant: fetch never called with a message-body URL from the fixture
- attachment_hash indicator metadata carries filename/contentType/size/related
- all fixtures reused from eml-parser.fixtures.ts (synthetic only)
This commit is contained in:
lorentz 2026-07-15 10:39:59 -04:00
parent 5088d8d511
commit ce16e6747c

View file

@ -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<typeof vi.fn>;
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,
})
);
});
});