wulf-pulse/lib/services/phishing-eml-service.test.ts
lorentz cf04f07c58 feat(quick-260717-a19): add idempotency guard + retry-parse on ticket.update
- 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
2026-07-17 07:20:48 -04:00

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);
});
});