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:
lorentz 2026-07-16 08:14:51 -04:00
parent f2b3602ca1
commit f4e6baf505
2 changed files with 307 additions and 0 deletions

View 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'],
});

View 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);
});
});