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:
parent
78c984dceb
commit
ea677b7aba
2 changed files with 102 additions and 0 deletions
50
lib/services/campaign-grouping-service.test.ts
Normal file
50
lib/services/campaign-grouping-service.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
52
lib/services/campaign-grouping-service.ts
Normal file
52
lib/services/campaign-grouping-service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue