diff --git a/lib/services/campaign-classifier.test.ts b/lib/services/campaign-classifier.test.ts index 2c1a7e3..596290d 100644 --- a/lib/services/campaign-classifier.test.ts +++ b/lib/services/campaign-classifier.test.ts @@ -1,4 +1,22 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock postgresClient BEFORE importing the module under test — mirrors +// campaign-grouping-service.test.ts's vi.mock() factory-mocking discipline. +const queryMock = vi.fn(); +vi.mock('./postgres-client', () => ({ + postgresClient: { + query: (...args: unknown[]) => queryMock(...args), + }, +})); + +// Mock getBlastRadius entirely — mirrors mimecast-blast-radius.test.ts's +// sibling-service mocking pattern. No real Mimecast/Postgres calls happen. +const getBlastRadiusMock = vi.fn(); +vi.mock('./mimecast-blast-radius', () => ({ + getBlastRadius: (...args: unknown[]) => getBlastRadiusMock(...args), +})); + +// eslint-disable-next-line import/first -- imported after vi.mock hoisting import { KNOWN_SIMULATION_SENDERS, domainMatchesAllowlist, @@ -8,13 +26,17 @@ import { computeConfidence, mapVerdictToActions, computeRequiresApproval, + classifyCampaign, } from './campaign-classifier'; +// eslint-disable-next-line import/first -- imported after vi.mock hoisting import { knowbe4SimMessage, bsnSimMessage, threatMessage, cleanSpamMessage, + suspiciousUnwantedMessage, } from './campaign-classifier.fixtures'; +import type { NormalizedMessage } from './eml-parser'; describe('KNOWN_SIMULATION_SENDERS', () => { it('includes both the KnowBe4 and Breach Secure Now sender domains', () => { @@ -187,3 +209,293 @@ describe('computeRequiresApproval', () => { expect(computeRequiresApproval(['warn_user'])).toBe(false); }); }); + +// ============================================================================= +// classifyCampaign — mocked-DB orchestration tests (CLASSIFY-01/02/03/04/06, +// D-02/D-03/D-04/D-06) +// +// `queryMock` routes staged rows based on a distinguishing SQL substring per +// call (`FROM reports`, `FROM messages`, `FROM indicators`, +// `INSERT INTO classifications`) — NOT by call order — mirroring +// campaign-grouping-service.test.ts's makeClient() discipline. +// ============================================================================= + +interface ReportFixtureRow { + id: string; + title: string | null; + created_at: string; + requester_email: string | null; +} + +interface StagedRows { + reports?: ReportFixtureRow[]; + messages?: Array<{ id: string; report_id: string; headers: NormalizedMessage }>; + indicators?: Array<{ id: string; message_id: string; indicator_type: string; value: string }>; +} + +function stageQueries(rows: StagedRows) { + queryMock.mockImplementation(async (sql: string) => { + if (sql.includes('INSERT INTO classifications')) { + return { rows: [{ id: 'classification-1', created_at: '2026-07-16T00:00:00.000Z' }], rowCount: 1 }; + } + if (sql.includes('FROM reports')) { + return { rows: rows.reports ?? [], rowCount: rows.reports?.length ?? 0 }; + } + if (sql.includes('FROM messages')) { + return { rows: rows.messages ?? [], rowCount: rows.messages?.length ?? 0 }; + } + if (sql.includes('FROM indicators')) { + return { rows: rows.indicators ?? [], rowCount: rows.indicators?.length ?? 0 }; + } + throw new Error(`Unstaged query in test mock: ${sql}`); + }); +} + +function toMessageRow(id: string, reportId: string, fixture: NormalizedMessage) { + return { id, report_id: reportId, headers: fixture }; +} + +const REPORTER_EMAIL = 'reporter@wulfconsulting.test'; + +describe('classifyCampaign', () => { + beforeEach(() => { + queryMock.mockReset(); + getBlastRadiusMock.mockReset(); + }); + + it('returns exactly one verdict with the full payload shape (returns exactly one verdict)', async () => { + stageQueries({ + reports: [ + { id: 'report-1', title: cleanSpamMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL }, + ], + messages: [toMessageRow('message-1', 'report-1', cleanSpamMessage)], + indicators: [], + }); + getBlastRadiusMock.mockResolvedValue({ + status: 'ok', + matched: 1, + delivered: 0, + held: 1, + rejected: 0, + clicked: 0, + perRecipient: [], + source: 'fan-out', + }); + + const result = await classifyCampaign('campaign-1'); + + expect(['SPAM', 'UNWANTED', 'THREAT']).toContain(result.verdict); + expect(typeof result.id).toBe('string'); + expect(result.campaignId).toBe('campaign-1'); + expect(typeof result.confidence).toBe('number'); + expect(typeof result.summary).toBe('string'); + expect(Array.isArray(result.reasons)).toBe(true); + expect(Array.isArray(result.recommendedActions)).toBe(true); + expect(typeof result.requiresApproval).toBe('boolean'); + expect(typeof result.createdAt).toBe('string'); + }); + + it('inserts exactly one append-only classifications row with no ON CONFLICT', async () => { + stageQueries({ + reports: [ + { id: 'report-1', title: cleanSpamMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL }, + ], + messages: [toMessageRow('message-1', 'report-1', cleanSpamMessage)], + indicators: [], + }); + getBlastRadiusMock.mockResolvedValue({ + status: 'ok', + matched: 1, + delivered: 0, + held: 1, + rejected: 0, + clicked: 0, + perRecipient: [], + source: 'fan-out', + }); + + await classifyCampaign('campaign-1'); + + const insertCalls = queryMock.mock.calls.filter( + ([sql]) => typeof sql === 'string' && sql.includes('INSERT INTO classifications') + ); + expect(insertCalls).toHaveLength(1); + expect(insertCalls[0][0]).not.toMatch(/ON CONFLICT/i); + }); + + it.each([ + ['knowbe4 (From match)', knowbe4SimMessage], + ['breach-secure-now (Return-Path match)', bsnSimMessage], + ])( + 'never classifies a known simulation sender as THREAT despite a hard auth fail and delivered>0 (simulation allowlist: %s)', + async (_label, fixture) => { + stageQueries({ + reports: [ + { id: 'report-1', title: fixture.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL }, + ], + messages: [toMessageRow('message-1', 'report-1', fixture)], + indicators: [], + }); + getBlastRadiusMock.mockResolvedValue({ + status: 'ok', + matched: 1, + delivered: 1, + held: 0, + rejected: 0, + clicked: 0, + perRecipient: [{ recipient: REPORTER_EMAIL, status: 'delivered' }], + source: 'fan-out', + }); + + const result = await classifyCampaign('campaign-1'); + + expect(result.verdict).not.toBe('THREAT'); + } + ); + + it('classifies a real non-simulation signal as THREAT with destructive recommended actions (threat tier)', async () => { + stageQueries({ + reports: [ + { id: 'report-1', title: threatMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL }, + ], + messages: [toMessageRow('message-1', 'report-1', threatMessage)], + indicators: [], + }); + getBlastRadiusMock.mockResolvedValue({ + status: 'ok', + matched: 3, + delivered: 3, + held: 0, + rejected: 0, + clicked: 0, + perRecipient: [ + { recipient: REPORTER_EMAIL, status: 'delivered' }, + { recipient: 'victim2@wulfconsulting.test', status: 'delivered' }, + { recipient: 'victim3@wulfconsulting.test', status: 'delivered' }, + ], + source: 'fan-out', + }); + + const result = await classifyCampaign('campaign-1'); + + expect(result.verdict).toBe('THREAT'); + expect(result.recommendedActions).toContain('block_sender'); + expect(result.recommendedActions).toContain('purge_message'); + expect(result.requiresApproval).toBe(true); + }); + + it('classifies THREAT via the known-bad-indicator OR-branch when auth passes across 2 messages (threat tier known-bad indicator)', async () => { + const sharedUrl = 'http://evil-shared.example.test/payload'; + stageQueries({ + reports: [ + { id: 'report-1', title: 'Invoice attached', created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL }, + { id: 'report-2', title: 'Invoice attached', created_at: '2026-07-15T02:00:00.000Z', requester_email: 'reporter2@wulfconsulting.test' }, + ], + messages: [ + toMessageRow('message-1', 'report-1', cleanSpamMessage), + toMessageRow('message-2', 'report-2', cleanSpamMessage), + ], + indicators: [ + { id: 'ind-1', message_id: 'message-1', indicator_type: 'url', value: sharedUrl }, + { id: 'ind-2', message_id: 'message-2', indicator_type: 'url', value: sharedUrl }, + ], + }); + getBlastRadiusMock.mockResolvedValue({ + status: 'ok', + matched: 1, + delivered: 1, + held: 0, + rejected: 0, + clicked: 0, + perRecipient: [{ recipient: REPORTER_EMAIL, status: 'delivered' }], + source: 'fan-out', + }); + + const result = await classifyCampaign('campaign-1'); + + expect(result.verdict).toBe('THREAT'); + }); + + it('classifies a clean campaign with no indicators and no delivery/click signal as SPAM (spam vs unwanted tier)', async () => { + stageQueries({ + reports: [ + { id: 'report-1', title: cleanSpamMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL }, + ], + messages: [toMessageRow('message-1', 'report-1', cleanSpamMessage)], + indicators: [], + }); + getBlastRadiusMock.mockResolvedValue({ + status: 'ok', + matched: 1, + delivered: 0, + held: 1, + rejected: 0, + clicked: 0, + perRecipient: [], + source: 'fan-out', + }); + + const result = await classifyCampaign('campaign-1'); + + expect(result.verdict).toBe('SPAM'); + }); + + it('classifies a suspicious-but-contained campaign (one url indicator, delivery contained to reporter) as UNWANTED (spam vs unwanted tier)', async () => { + stageQueries({ + reports: [ + { id: 'report-1', title: suspiciousUnwantedMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL }, + ], + messages: [toMessageRow('message-1', 'report-1', suspiciousUnwantedMessage)], + indicators: [ + { id: 'ind-1', message_id: 'message-1', indicator_type: 'url', value: 'http://promo.some-vendor.net/deal' }, + ], + }); + getBlastRadiusMock.mockResolvedValue({ + status: 'ok', + matched: 1, + delivered: 1, + held: 0, + rejected: 0, + clicked: 0, + perRecipient: [{ recipient: REPORTER_EMAIL, status: 'delivered' }], + source: 'fan-out', + }); + + const result = await classifyCampaign('campaign-1'); + + expect(result.verdict).toBe('UNWANTED'); + }); + + it('keeps persisted reasons short and free of raw body text even with many indicators (evidence bounding)', async () => { + const manyIndicators = Array.from({ length: 50 }, (_, i) => ({ + id: `ind-${i}`, + message_id: 'message-1', + indicator_type: 'url', + value: `http://spammy-${i}.example.test/x`, + })); + stageQueries({ + reports: [ + { id: 'report-1', title: cleanSpamMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL }, + ], + messages: [toMessageRow('message-1', 'report-1', cleanSpamMessage)], + indicators: manyIndicators, + }); + getBlastRadiusMock.mockResolvedValue({ + status: 'ok', + matched: 1, + delivered: 0, + held: 1, + rejected: 0, + clicked: 0, + perRecipient: [], + source: 'fan-out', + }); + + const result = await classifyCampaign('campaign-1'); + + expect(result.reasons.length).toBeLessThanOrEqual(5); + for (const reason of result.reasons) { + expect(reason.length).toBeLessThan(300); + } + }); +});