- Add mimecast_tenants routing branch to stageQueries and company_id to
report fixtures
- New test: enabled mimecast_tenants row -> getBlastRadius called with
{ client, cacheScope: companyId } and the tenant SQL is issued
- New parameterized test: null companyId and companyId-with-no-enabled-row
-> getBlastRadius called with no second argument (global fallback)
647 lines
23 KiB
TypeScript
647 lines
23 KiB
TypeScript
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);
|
|
});
|
|
|
|
it('matches the 3 KnowBe4 domains confirmed via Seubert ticket 699456 (260717-a19)', () => {
|
|
expect(domainMatchesAllowlist('customer-portal.info')).toBe(true);
|
|
expect(domainMatchesAllowlist('cloud-service-care.com')).toBe(true);
|
|
expect(domainMatchesAllowlist('bankonlinesupport.com')).toBe(true);
|
|
});
|
|
|
|
it('matches a subdomain of the newly-added customer-portal.info', () => {
|
|
expect(domainMatchesAllowlist('mail.customer-portal.info')).toBe(true);
|
|
});
|
|
|
|
it('does NOT match a suffix-spoofed variant of the newly-added domain', () => {
|
|
expect(domainMatchesAllowlist('customer-portal.info.attacker.net')).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');
|
|
});
|
|
|
|
it('maps USER_AWARENESS to exactly acknowledge_user', () => {
|
|
expect(mapVerdictToActions('USER_AWARENESS', { clicked: 0 })).toEqual(['acknowledge_user']);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it('is false for acknowledge_user (USER_AWARENESS is never destructive)', () => {
|
|
expect(computeRequiresApproval(['acknowledge_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;
|
|
company_id: 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 }>;
|
|
mimecastTenants?: Array<{ client_id: string; client_secret: string; base_url: string | null }>;
|
|
}
|
|
|
|
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 mimecast_tenants')) {
|
|
return { rows: rows.mimecastTenants ?? [], rowCount: rows.mimecastTenants?.length ?? 0 };
|
|
}
|
|
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 , company_id: null},
|
|
],
|
|
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 , company_id: null},
|
|
],
|
|
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 , company_id: null},
|
|
],
|
|
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.each([
|
|
['knowbe4 (From match)', knowbe4SimMessage],
|
|
['breach-secure-now (Return-Path match)', bsnSimMessage],
|
|
])(
|
|
'classifies a known simulation sender as USER_AWARENESS with acknowledge_user recommended and requiresApproval false (%s)',
|
|
async (_label, fixture) => {
|
|
stageQueries({
|
|
reports: [
|
|
{ id: 'report-1', title: fixture.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL , company_id: null},
|
|
],
|
|
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).toBe('USER_AWARENESS');
|
|
expect(result.recommendedActions).toEqual(['acknowledge_user']);
|
|
expect(result.requiresApproval).toBe(false);
|
|
}
|
|
);
|
|
|
|
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 , company_id: null},
|
|
],
|
|
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 , company_id: null},
|
|
{ id: 'report-2', title: 'Invoice attached', created_at: '2026-07-15T02:00:00.000Z', requester_email: 'reporter2@wulfconsulting.test' , company_id: null},
|
|
],
|
|
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 , company_id: null},
|
|
],
|
|
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 , company_id: null},
|
|
],
|
|
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 , company_id: null},
|
|
],
|
|
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);
|
|
}
|
|
});
|
|
|
|
// ===========================================================================
|
|
// Bug 2 (D-05) parity: per-company Mimecast tenant resolution vs. global
|
|
// env fallback — mirrors app/api/phishing/campaigns/[id]/route.ts's
|
|
// already-tested tenant-resolution block (260721-n49).
|
|
// ===========================================================================
|
|
|
|
it('resolves the reporting company\'s own Mimecast tenant and scopes getBlastRadius when an enabled mimecast_tenants row exists (tenant resolution)', async () => {
|
|
stageQueries({
|
|
reports: [
|
|
{
|
|
id: 'report-1',
|
|
title: cleanSpamMessage.subject,
|
|
created_at: '2026-07-15T00:00:00.000Z',
|
|
requester_email: REPORTER_EMAIL,
|
|
company_id: '29683407',
|
|
},
|
|
],
|
|
messages: [toMessageRow('message-1', 'report-1', cleanSpamMessage)],
|
|
indicators: [],
|
|
mimecastTenants: [
|
|
{ client_id: 'tenant-client-id', client_secret: 'tenant-client-secret', base_url: 'https://eu-api.mimecast.com' },
|
|
],
|
|
});
|
|
getBlastRadiusMock.mockResolvedValue({
|
|
status: 'ok',
|
|
matched: 1,
|
|
delivered: 0,
|
|
held: 1,
|
|
rejected: 0,
|
|
clicked: 0,
|
|
perRecipient: [],
|
|
source: 'fan-out',
|
|
});
|
|
|
|
await classifyCampaign('campaign-1');
|
|
|
|
expect(getBlastRadiusMock).toHaveBeenCalledTimes(1);
|
|
const [, tenantOptions] = getBlastRadiusMock.mock.calls[0];
|
|
expect(tenantOptions).toBeDefined();
|
|
expect(tenantOptions.cacheScope).toBe('29683407');
|
|
expect(tenantOptions.client).toBeTruthy();
|
|
|
|
const tenantQueryCalls = queryMock.mock.calls.filter(
|
|
([sql]) => typeof sql === 'string' && sql.includes('FROM mimecast_tenants')
|
|
);
|
|
expect(tenantQueryCalls).toHaveLength(1);
|
|
expect(tenantQueryCalls[0][0]).toMatch(/company_id = \$1 AND enabled = true/);
|
|
});
|
|
|
|
it.each([
|
|
['no companyId on the primary report', null, [] as Array<{ client_id: string; client_secret: string; base_url: string | null }>],
|
|
['companyId set but no enabled tenant row', '29683407', []],
|
|
])(
|
|
'calls getBlastRadius with no tenant scoping when %s (global fallback preserved)',
|
|
async (_label, companyId, mimecastTenants) => {
|
|
stageQueries({
|
|
reports: [
|
|
{
|
|
id: 'report-1',
|
|
title: cleanSpamMessage.subject,
|
|
created_at: '2026-07-15T00:00:00.000Z',
|
|
requester_email: REPORTER_EMAIL,
|
|
company_id: companyId,
|
|
},
|
|
],
|
|
messages: [toMessageRow('message-1', 'report-1', cleanSpamMessage)],
|
|
indicators: [],
|
|
mimecastTenants,
|
|
});
|
|
getBlastRadiusMock.mockResolvedValue({
|
|
status: 'ok',
|
|
matched: 1,
|
|
delivered: 0,
|
|
held: 1,
|
|
rejected: 0,
|
|
clicked: 0,
|
|
perRecipient: [],
|
|
source: 'fan-out',
|
|
});
|
|
|
|
await classifyCampaign('campaign-1');
|
|
|
|
expect(getBlastRadiusMock).toHaveBeenCalledTimes(1);
|
|
expect(getBlastRadiusMock.mock.calls[0][1]).toBeUndefined();
|
|
}
|
|
);
|
|
});
|