From ea677b7aba9149cdbf6a433f7e925151402a16cc Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 15 Jul 2026 19:19:44 -0400 Subject: [PATCH] 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 --- .../campaign-grouping-service.test.ts | 50 ++++++++++++++++++ lib/services/campaign-grouping-service.ts | 52 +++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 lib/services/campaign-grouping-service.test.ts create mode 100644 lib/services/campaign-grouping-service.ts diff --git a/lib/services/campaign-grouping-service.test.ts b/lib/services/campaign-grouping-service.test.ts new file mode 100644 index 0000000..bc92fbc --- /dev/null +++ b/lib/services/campaign-grouping-service.test.ts @@ -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'); + }); +}); diff --git a/lib/services/campaign-grouping-service.ts b/lib/services/campaign-grouping-service.ts new file mode 100644 index 0000000..399d769 --- /dev/null +++ b/lib/services/campaign-grouping-service.ts @@ -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; + } +}