test(19-01): add failing tests + synthetic fixtures for classifier pure rule functions
- campaign-classifier.test.ts: describe blocks for domainMatchesAllowlist, isKnownSimulationSender, effectiveAuthResults, hasHardAuthFail, computeConfidence, mapVerdictToActions, computeRequiresApproval - campaign-classifier.fixtures.ts: synthetic KnowBe4/BSN simulation fixtures plus non-simulation threat/clean-spam/suspicious-unwanted fixtures - Covers CLASSIFY-01/02/03/04/06 pure-function behavior (T-19-01, Pitfall 1/3)
This commit is contained in:
parent
f2b3602ca1
commit
f4e6baf505
2 changed files with 307 additions and 0 deletions
118
lib/services/campaign-classifier.fixtures.ts
Normal file
118
lib/services/campaign-classifier.fixtures.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/**
|
||||
* Synthetic fixtures for campaign-classifier.test.ts (Phase 19). Nothing
|
||||
* here is real customer content — all addresses, domains, and subjects are
|
||||
* invented for testing only (per this milestone's explicit
|
||||
* synthetic-fixture-only constraint).
|
||||
*
|
||||
* Both simulation fixtures (`knowbe4SimMessage`, `bsnSimMessage`) reproduce
|
||||
* the forwarding-induced auth-verdict inversion described in
|
||||
* 19-RESEARCH.md Pitfall 1 — the primary `authResults` header shows a hard
|
||||
* fail (post-forward, DKIM invalidated by the forward hop) while
|
||||
* `authResultsOriginal` shows the pre-forward pass. This lets the
|
||||
* "simulation is never THREAT" test prove BOTH the D-06 allowlist
|
||||
* short-circuit AND the D-05/authResultsOriginal precedence in one fixture.
|
||||
*/
|
||||
|
||||
import type { NormalizedMessage } from './eml-parser';
|
||||
|
||||
function makeNormalizedMessage(
|
||||
overrides: Partial<NormalizedMessage> & {
|
||||
from: NormalizedMessage['from'];
|
||||
authResults: NormalizedMessage['authResults'];
|
||||
}
|
||||
): NormalizedMessage {
|
||||
return {
|
||||
replyTo: null,
|
||||
returnPath: null,
|
||||
to: ['reporter@wulfconsulting.test'],
|
||||
cc: [],
|
||||
subject: 'Test subject',
|
||||
date: '2026-07-15T12:00:00.000Z',
|
||||
messageId: null,
|
||||
receivedChain: [],
|
||||
authResultsOriginal: null,
|
||||
urls: [],
|
||||
attachments: [],
|
||||
bodyPreview: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* KnowBe4 phishing-simulation fixture — From domain matches the
|
||||
* `it-support.care` allowlist entry (19-RESEARCH.md D-07 finding #2).
|
||||
*/
|
||||
export const knowbe4SimMessage: NormalizedMessage = makeNormalizedMessage({
|
||||
from: { displayName: 'IT Support', email: 'alert@it-support.care', domain: 'it-support.care' },
|
||||
returnPath: 'bounce@it-support.care',
|
||||
subject: 'Phishing Alert - Email Security Report',
|
||||
authResults: { spf: 'fail', dkim: 'fail', dmarc: 'fail' },
|
||||
authResultsOriginal: { spf: 'pass', dkim: 'pass', dmarc: 'pass' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Breach Secure Now training-notification fixture — From.domain is null
|
||||
* (Pitfall 3: From may lack a visible email address); the Return-Path
|
||||
* domain is the only allowlist signal (19-RESEARCH.md D-07 finding #1).
|
||||
*/
|
||||
export const bsnSimMessage: NormalizedMessage = makeNormalizedMessage({
|
||||
from: { displayName: null, email: null, domain: null },
|
||||
returnPath: 'bounces-abc123@em8721.breachsecurenow.com',
|
||||
subject: 'Security Awareness Training Notification',
|
||||
authResults: { spf: 'fail', dkim: 'fail', dmarc: 'fail' },
|
||||
authResultsOriginal: { spf: 'pass', dkim: 'pass', dmarc: 'pass' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Non-simulation THREAT fixture — a real typosquat flavor per
|
||||
* 19-RESEARCH.md D-07 finding #3 (`mlcrosoft.live`, NOT allowlisted). No
|
||||
* `authResultsOriginal` — effectiveAuthResults falls back to the primary
|
||||
* `authResults`, which itself shows a hard fail (no forwarding inversion
|
||||
* here — the fail is the actual signal).
|
||||
*/
|
||||
export const threatMessage: NormalizedMessage = makeNormalizedMessage({
|
||||
from: {
|
||||
displayName: 'Microsoft Account Team',
|
||||
email: 'security@mlcrosoft.live',
|
||||
domain: 'mlcrosoft.live',
|
||||
},
|
||||
returnPath: 'bounce@mlcrosoft.live',
|
||||
subject: 'Unusual sign-in activity detected',
|
||||
authResults: { spf: 'fail', dkim: 'fail', dmarc: 'fail' },
|
||||
authResultsOriginal: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Clean non-simulation SPAM fixture — generic bulk/newsletter sender, no
|
||||
* spoofing, all auth verdicts pass, no attachment/url indicators.
|
||||
*/
|
||||
export const cleanSpamMessage: NormalizedMessage = makeNormalizedMessage({
|
||||
from: {
|
||||
displayName: 'Example Newsletter',
|
||||
email: 'news@mail.example-newsletter.com',
|
||||
domain: 'mail.example-newsletter.com',
|
||||
},
|
||||
returnPath: 'bounce@mail.example-newsletter.com',
|
||||
subject: 'Your weekly digest',
|
||||
authResults: { spf: 'pass', dkim: 'pass', dmarc: 'pass' },
|
||||
authResultsOriginal: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Suspicious-but-contained UNWANTED fixture — a single suspicious signal
|
||||
* (paired in Task 2 with exactly one url indicator, not shared across
|
||||
* messages) with delivery contained to the reporter only — below the
|
||||
* THREAT bar per D-04.
|
||||
*/
|
||||
export const suspiciousUnwantedMessage: NormalizedMessage = makeNormalizedMessage({
|
||||
from: {
|
||||
displayName: 'Vendor Promo',
|
||||
email: 'promo@promo.some-vendor.net',
|
||||
domain: 'promo.some-vendor.net',
|
||||
},
|
||||
returnPath: 'bounce@promo.some-vendor.net',
|
||||
subject: 'Special offer just for you',
|
||||
authResults: { spf: 'pass', dkim: 'pass', dmarc: 'pass' },
|
||||
authResultsOriginal: null,
|
||||
urls: ['http://promo.some-vendor.net/deal'],
|
||||
});
|
||||
189
lib/services/campaign-classifier.test.ts
Normal file
189
lib/services/campaign-classifier.test.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
KNOWN_SIMULATION_SENDERS,
|
||||
domainMatchesAllowlist,
|
||||
isKnownSimulationSender,
|
||||
effectiveAuthResults,
|
||||
hasHardAuthFail,
|
||||
computeConfidence,
|
||||
mapVerdictToActions,
|
||||
computeRequiresApproval,
|
||||
} from './campaign-classifier';
|
||||
import {
|
||||
knowbe4SimMessage,
|
||||
bsnSimMessage,
|
||||
threatMessage,
|
||||
cleanSpamMessage,
|
||||
} from './campaign-classifier.fixtures';
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue