test(18-01): add campaign-grouping tier-key helpers + test scaffold

- normalizeSubject strips repeated Re:/Fwd:/Fw: prefixes case-insensitively, lowercases, trims (D-03)
- extractUrlDomain returns hostname or null (never throws) for malformed URLs (Pitfall 4)
- test file mocks ./postgres-client before import, mirroring phishing-eml-service.test.ts's discipline
This commit is contained in:
lorentz 2026-07-15 19:19:44 -04:00
parent 78c984dceb
commit ea677b7aba
2 changed files with 102 additions and 0 deletions

View file

@ -0,0 +1,50 @@
import { describe, it, expect, vi } from 'vitest';
// Mock postgresClient BEFORE importing the module under test.
const queryMock = vi.fn();
const transactionMock = vi.fn();
vi.mock('./postgres-client', () => ({
postgresClient: {
query: (...args: unknown[]) => queryMock(...args),
transaction: (...args: unknown[]) => transactionMock(...args),
},
}));
// eslint-disable-next-line import/first -- imported after vi.mock hoisting
import { normalizeSubject, extractUrlDomain } from './campaign-grouping-service';
describe('normalizeSubject', () => {
it('strips a single Re: prefix, lowercases, trims', () => {
expect(normalizeSubject('Re: Your Invoice ')).toBe('your invoice');
});
it('strips repeated Re:/Fwd:/Fw: prefixes case-insensitively', () => {
expect(normalizeSubject('FW: Re: fwd: Urgent Payment')).toBe('urgent payment');
});
it('handles null', () => {
expect(normalizeSubject(null)).toBe('');
});
it('handles empty string', () => {
expect(normalizeSubject('')).toBe('');
});
it('lowercases and trims a subject with no prefix', () => {
expect(normalizeSubject(' Urgent Payment ')).toBe('urgent payment');
});
});
describe('extractUrlDomain', () => {
it('extracts hostname from a full URL', () => {
expect(extractUrlDomain('https://evil.example.com/path?x=1')).toBe('evil.example.com');
});
it('returns null for a malformed URL instead of throwing', () => {
expect(extractUrlDomain('not-a-url')).toBeNull();
});
it('extracts hostname regardless of scheme', () => {
expect(extractUrlDomain('http://another.example.net')).toBe('another.example.net');
});
});

View file

@ -0,0 +1,52 @@
/**
* Campaign Grouping Service
*
* Shared grouping core called by all 3 trigger sites: the webhook path
* (`webhook-service.ts`'s `triggerPhishingDetection()`), the cron sweep
* (`phishing-sweep-service.ts`), and the on-demand
* `POST /api/phishing/tickets/{id}/analyze` route. One deterministic,
* testable function computes a tiered campaign key (CAMP-01) and
* finds-or-creates a `campaigns` row (CAMP-02) no duplicated matching
* logic between callers, mirroring `phishing-detector.ts`'s shared-core
* architecture.
*
* D-07 limitation (load-bearing, stated explicitly): `parseAndStoreMessage`
* (the only writer of `messages`/`indicators` rows Phase 16) is not wired
* into the automatic webhook/cron path this phase. That means the automatic
* path only ever has `reports`/`contacts` data available, so Tier 1
* (Message-ID) and Tier 2 (attachment-hash/URL-domain) can only ever match
* for a report that has already been through an explicit `/analyze` call at
* least once. Until then, automatic grouping effectively only reaches
* Tier 3 (sender + normalized subject + client + 24h window).
*/
import { postgresClient } from './postgres-client';
// =============================================================================
// Pure logic — tier-key helpers (mirrors matchesPhishingPatterns / computePhishingContentHash)
// =============================================================================
/**
* D-03: strip leading Re:/Fwd:/Fw: (repeated, case-insensitive), lowercase, trim.
*/
export function normalizeSubject(subject: string | null): string {
let s = (subject ?? '').trim();
const prefixRe = /^(re|fwd|fw):\s*/i;
while (prefixRe.test(s)) {
s = s.replace(prefixRe, '').trim();
}
return s.toLowerCase();
}
/**
* Pitfall 4: indicators.value for indicator_type='url' is a bare URL string,
* not a domain extract at read time, guarded (malformed/relative URLs are
* possible in real-world phishing emails).
*/
export function extractUrlDomain(url: string): string | null {
try {
return new URL(url).hostname || null;
} catch {
return null;
}
}