- parseAndStoreMessage (Defect 3): short-circuit with
{ stored: false, reason: 'already-parsed' } when a messages row already
exists for the report, before any Autotask attachment fetch
- webhook-service (Defect 2): new retryPhishingParseOnUpdate wired into
ticket.update fire-and-forget path; retries the missing-EML parse for a
flagged, unparsed, auto_parse-gated report — no new cron/polling, reuses
existing update traffic, safe to fire repeatedly thanks to the new
idempotency guard
- Adjust eml-service test mock default so the new leading existence-check
query doesn't short-circuit existing happy-path tests; add new test for
the already-parsed short-circuit
219 lines
9.2 KiB
TypeScript
219 lines
9.2 KiB
TypeScript
/**
|
|
* 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<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,
|
|
})
|
|
);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|