test(19-01): add classifyCampaign orchestrator tests (RED)
- Mock ./postgres-client (query-only) and ./mimecast-blast-radius (getBlastRadius), routing staged rows by SQL substring per call - Named tests for CLASSIFY-01 (returns exactly one verdict), D-02 (append-only INSERT, no ON CONFLICT), D-06 simulation allowlist (KnowBe4 From-match + BSN Return-Path-match), positive-path D-03 THREAT, D-03 known-bad-indicator OR-branch (auth pass, shared indicator across 2 messages), D-04 SPAM/UNWANTED split, and CLASSIFY-06 evidence bounding
This commit is contained in:
parent
3ea6c95e38
commit
f6c954aa23
1 changed files with 313 additions and 1 deletions
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue