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:
parent
5088d8d511
commit
ce16e6747c
1 changed files with 195 additions and 0 deletions
195
lib/services/phishing-eml-service.test.ts
Normal file
195
lib/services/phishing-eml-service.test.ts
Normal 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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue