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, isKnownSimulationSender, effectiveAuthResults, hasHardAuthFail, 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', () => { const allDomains = KNOWN_SIMULATION_SENDERS.flatMap((entry) => entry.domains); expect(allDomains).toContain('it-support.care'); expect(allDomains).toContain('breachsecurenow.com'); }); }); describe('domainMatchesAllowlist', () => { it('matches an exact allowlisted domain', () => { expect(domainMatchesAllowlist('it-support.care')).toBe(true); }); it('matches a proper subdomain of an allowlisted domain', () => { expect(domainMatchesAllowlist('sub.it-support.care')).toBe(true); expect(domainMatchesAllowlist('em8721.breachsecurenow.com')).toBe(true); }); it('does NOT match a bare substring / suffix-spoofed domain (T-19-01)', () => { expect(domainMatchesAllowlist('it-support.care.attacker.net')).toBe(false); expect(domainMatchesAllowlist('evil-it-support.care')).toBe(false); }); }); describe('isKnownSimulationSender', () => { it('matches on From domain (knowbe4SimMessage)', () => { expect(isKnownSimulationSender(knowbe4SimMessage)).toBe(true); }); it('matches on Return-Path domain when From.domain is null (Pitfall 3, bsnSimMessage)', () => { expect(bsnSimMessage.from.domain).toBeNull(); expect(isKnownSimulationSender(bsnSimMessage)).toBe(true); }); it('does not match a non-allowlisted sender', () => { expect(isKnownSimulationSender(threatMessage)).toBe(false); expect(isKnownSimulationSender(cleanSpamMessage)).toBe(false); }); }); describe('effectiveAuthResults', () => { it('returns authResultsOriginal when present (Pitfall 1)', () => { expect(effectiveAuthResults(knowbe4SimMessage)).toEqual(knowbe4SimMessage.authResultsOriginal); }); it('falls back to authResults when authResultsOriginal is null', () => { expect(threatMessage.authResultsOriginal).toBeNull(); expect(effectiveAuthResults(threatMessage)).toEqual(threatMessage.authResults); }); }); describe('hasHardAuthFail', () => { it('is true when spf is fail', () => { expect(hasHardAuthFail({ spf: 'fail' })).toBe(true); }); it('is true when dkim is fail', () => { expect(hasHardAuthFail({ dkim: 'fail' })).toBe(true); }); it('is true when dmarc is fail', () => { expect(hasHardAuthFail({ dmarc: 'fail' })).toBe(true); }); it('is false for none/neutral/undefined verdicts', () => { expect(hasHardAuthFail({ spf: 'none', dkim: 'neutral' })).toBe(false); expect(hasHardAuthFail({})).toBe(false); }); it('is false when all verdicts pass', () => { expect(hasHardAuthFail({ spf: 'pass', dkim: 'pass', dmarc: 'pass' })).toBe(false); }); }); describe('computeConfidence', () => { it('is 1.0 with no deductions and no reasons when all evidence is present (confidence deduction baseline)', () => { const result = computeConfidence({ hasAnyMessage: true, blastRadiusStatus: 'ok', hasAttachmentOrUrlIndicators: true, }); expect(result.confidence).toBe(1.0); expect(result.reasons).toHaveLength(0); }); it('deducts 0.4 and names the reason when no message was parsed (confidence deduction)', () => { const result = computeConfidence({ hasAnyMessage: false, blastRadiusStatus: 'ok', hasAttachmentOrUrlIndicators: true, }); expect(result.confidence).toBe(0.6); expect(result.reasons).toHaveLength(1); expect(result.reasons[0]).toMatch(/message/i); }); it('deducts 0.3 and names the reason when blast-radius is unavailable (confidence deduction)', () => { const result = computeConfidence({ hasAnyMessage: true, blastRadiusStatus: 'unavailable', hasAttachmentOrUrlIndicators: true, }); expect(result.confidence).toBe(0.7); expect(result.reasons[0]).toMatch(/mimecast|blast/i); }); it('deducts 0.2 and names the reason when no attachment/url indicators are found (confidence deduction)', () => { const result = computeConfidence({ hasAnyMessage: true, blastRadiusStatus: 'ok', hasAttachmentOrUrlIndicators: false, }); expect(result.confidence).toBe(0.8); expect(result.reasons[0]).toMatch(/indicator/i); }); it('floors at 0.10 with all three named reasons when every evidence source is missing (confidence deduction)', () => { const result = computeConfidence({ hasAnyMessage: false, blastRadiusStatus: 'unavailable', hasAttachmentOrUrlIndicators: false, }); expect(result.confidence).toBe(0.1); expect(result.reasons).toHaveLength(3); }); }); describe('mapVerdictToActions', () => { it('maps SPAM to no_action', () => { expect(mapVerdictToActions('SPAM', { clicked: 0 })).toEqual(['no_action']); }); it('maps UNWANTED to warn_user', () => { expect(mapVerdictToActions('UNWANTED', { clicked: 0 })).toEqual(['warn_user']); }); it('maps THREAT with no clicks to block_sender + purge_message', () => { expect(mapVerdictToActions('THREAT', { clicked: 0 })).toEqual(['block_sender', 'purge_message']); }); it('maps THREAT with clicks to also include reset_password/isolate_endpoint/disable_forwarding_rule', () => { const actions = mapVerdictToActions('THREAT', { clicked: 1 }); expect(actions).toContain('block_sender'); expect(actions).toContain('purge_message'); expect(actions).toContain('reset_password'); expect(actions).toContain('isolate_endpoint'); expect(actions).toContain('disable_forwarding_rule'); }); }); describe('computeRequiresApproval', () => { it('is false for disable_forwarding_rule alone (requires_approval invariant)', () => { expect(computeRequiresApproval(['disable_forwarding_rule'])).toBe(false); }); it('is true when disable_forwarding_rule is combined with a destructive action (requires_approval invariant)', () => { expect(computeRequiresApproval(['disable_forwarding_rule', 'block_sender'])).toBe(true); }); it.each(['block_sender', 'purge_message', 'reset_password', 'isolate_endpoint'])( 'is true for %s alone (requires_approval invariant)', (action) => { expect(computeRequiresApproval([action])).toBe(true); } ); it('is false for no_action and warn_user (requires_approval invariant)', () => { expect(computeRequiresApproval(['no_action'])).toBe(false); 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); } }); });