feat(17-01): build mimecast-blast-radius.ts fan-out orchestration
- Add getBlastRadius(): never-throwing orchestration that fans out to searchDeliveredMessages + getHeldMessages + getThreatEvents (D-01, unconditional fan-out) and merges into normalized matched/delivered/ held/rejected/clicked counts + perRecipient status array - Config gate (BLAST-02): returns status:'unavailable' reason:'not_configured' synchronously when Mimecast is unconfigured, never constructs the client - Redis-backed 5-min cache (D-04) via redis-client.ts, short-circuits before any MimecastClient call on hit - clicked derived best-effort from getThreatEvents() analysis[] (D-02); documents the /api/ttp/url/get-logs limitation in code - Documents D-05 known limitation: single global getMimecastClient() only, not per-company mimecast_tenants - Unrecognized delivered-message status strings treated conservatively as non-rejected (A3 unconfirmed enum), raw values logged at debug level - Add lib/services/mimecast-blast-radius.test.ts covering config gate, cache-hit short-circuit, fan-out merge, never-throw-on-error, and unknown-recipient classification - Log pre-existing unrelated itglue-search.test.ts failures to deferred-items.md (out of scope for this plan)
This commit is contained in:
parent
8b032c3890
commit
efbc437e2e
3 changed files with 443 additions and 0 deletions
227
lib/services/mimecast-blast-radius.test.ts
Normal file
227
lib/services/mimecast-blast-radius.test.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
/**
|
||||
* mimecast-blast-radius.ts — getBlastRadius() orchestration tests.
|
||||
*
|
||||
* Mocks './mimecast-client' and './redis-client' entirely (following
|
||||
* phishing-eml-service.test.ts's vi.mock() factory-mocking discipline) — no
|
||||
* real Mimecast network calls or Redis connection are used. Synthetic inline
|
||||
* fixtures matching MimecastDeliveredMessage/MimecastHeldMessage/
|
||||
* MimecastThreatEvent shapes are used directly; no fixture file needed.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const isMimecastConfiguredMock = vi.fn();
|
||||
const getMessageInfoMock = vi.fn();
|
||||
const searchDeliveredMessagesMock = vi.fn();
|
||||
const getHeldMessagesMock = vi.fn();
|
||||
const getThreatEventsMock = vi.fn();
|
||||
const getMimecastClientMock = vi.fn(() => ({
|
||||
getMessageInfo: getMessageInfoMock,
|
||||
searchDeliveredMessages: searchDeliveredMessagesMock,
|
||||
getHeldMessages: getHeldMessagesMock,
|
||||
getThreatEvents: getThreatEventsMock,
|
||||
}));
|
||||
|
||||
vi.mock('./mimecast-client', () => ({
|
||||
isMimecastConfigured: () => isMimecastConfiguredMock(),
|
||||
getMimecastClient: () => getMimecastClientMock(),
|
||||
}));
|
||||
|
||||
const getCachedDataMock = vi.fn();
|
||||
const setCachedDataMock = vi.fn();
|
||||
vi.mock('./redis-client', () => ({
|
||||
getCachedData: (...args: unknown[]) => getCachedDataMock(...args),
|
||||
setCachedData: (...args: unknown[]) => setCachedDataMock(...args),
|
||||
}));
|
||||
|
||||
// Import AFTER the mocks are declared so vi.mock hoisting takes effect.
|
||||
import { getBlastRadius, type BlastRadiusInput } from './mimecast-blast-radius';
|
||||
|
||||
const BASE_INPUT: BlastRadiusInput = {
|
||||
sender: 'attacker@evil.test',
|
||||
recipient: 'reporter@wulfconsulting.test',
|
||||
subject: 'Urgent: verify your account',
|
||||
dateWindow: {
|
||||
start: new Date('2026-07-14T00:00:00.000Z'),
|
||||
end: new Date('2026-07-15T00:00:00.000Z'),
|
||||
},
|
||||
};
|
||||
|
||||
describe('getBlastRadius', () => {
|
||||
beforeEach(() => {
|
||||
isMimecastConfiguredMock.mockReset().mockReturnValue(true);
|
||||
getMessageInfoMock.mockReset().mockResolvedValue(null);
|
||||
searchDeliveredMessagesMock.mockReset().mockResolvedValue({ messages: [] });
|
||||
getHeldMessagesMock.mockReset().mockResolvedValue({ messages: [], totalCount: 0 });
|
||||
getThreatEventsMock.mockReset().mockResolvedValue({ items: [], nextCursor: null });
|
||||
getCachedDataMock.mockReset().mockResolvedValue(null);
|
||||
setCachedDataMock.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('returns unavailable/not_configured synchronously and never calls getMimecastClient when unconfigured', async () => {
|
||||
isMimecastConfiguredMock.mockReturnValue(false);
|
||||
|
||||
const result = await getBlastRadius(BASE_INPUT);
|
||||
|
||||
expect(result).toEqual({ status: 'unavailable', reason: 'not_configured' });
|
||||
expect(getMimecastClientMock).not.toHaveBeenCalled();
|
||||
expect(searchDeliveredMessagesMock).not.toHaveBeenCalled();
|
||||
expect(getHeldMessagesMock).not.toHaveBeenCalled();
|
||||
expect(getThreatEventsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the cached result on a cache hit without calling any MimecastClient method', async () => {
|
||||
const cachedResult = {
|
||||
status: 'ok' as const,
|
||||
matched: 1,
|
||||
delivered: 1,
|
||||
held: 0,
|
||||
rejected: 0,
|
||||
clicked: 0,
|
||||
perRecipient: [{ recipient: BASE_INPUT.recipient, status: 'delivered' as const }],
|
||||
source: 'fan-out' as const,
|
||||
};
|
||||
getCachedDataMock.mockResolvedValue(cachedResult);
|
||||
|
||||
const result = await getBlastRadius(BASE_INPUT);
|
||||
|
||||
expect(result).toEqual(cachedResult);
|
||||
expect(searchDeliveredMessagesMock).not.toHaveBeenCalled();
|
||||
expect(getHeldMessagesMock).not.toHaveBeenCalled();
|
||||
expect(getThreatEventsMock).not.toHaveBeenCalled();
|
||||
expect(setCachedDataMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('merges delivered/held/threat-event fixtures into a normalized ok result and caches it once', async () => {
|
||||
searchDeliveredMessagesMock.mockResolvedValue({
|
||||
messages: [
|
||||
{
|
||||
id: 'd1',
|
||||
status: 'Delivered',
|
||||
subject: BASE_INPUT.subject,
|
||||
from: BASE_INPUT.sender,
|
||||
fromEnv: BASE_INPUT.sender,
|
||||
to: BASE_INPUT.recipient,
|
||||
toDisplay: 'Reporter',
|
||||
received: '2026-07-14T12:00:00+0000',
|
||||
senderIP: '1.2.3.4',
|
||||
spamScore: 0,
|
||||
detectionLevel: 'none',
|
||||
attachments: false,
|
||||
route: 'inbound',
|
||||
info: '',
|
||||
},
|
||||
{
|
||||
id: 'd2',
|
||||
status: 'Rejected',
|
||||
subject: BASE_INPUT.subject,
|
||||
from: BASE_INPUT.sender,
|
||||
fromEnv: BASE_INPUT.sender,
|
||||
to: 'other-recipient@wulfconsulting.test',
|
||||
toDisplay: 'Other',
|
||||
received: '2026-07-14T12:05:00+0000',
|
||||
senderIP: '1.2.3.4',
|
||||
spamScore: 0,
|
||||
detectionLevel: 'none',
|
||||
attachments: false,
|
||||
route: 'inbound',
|
||||
info: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
getHeldMessagesMock.mockResolvedValue({
|
||||
messages: [
|
||||
{
|
||||
id: 'h1',
|
||||
subject: BASE_INPUT.subject,
|
||||
from: BASE_INPUT.sender,
|
||||
fromDisplay: 'Attacker',
|
||||
to: 'held-recipient@wulfconsulting.test',
|
||||
toDisplay: 'Held',
|
||||
dateReceived: '2026-07-14T13:00:00+0000',
|
||||
reason: 'spam',
|
||||
reasonCode: 'SPAM',
|
||||
policyInfo: '',
|
||||
route: 'inbound',
|
||||
hasAttachments: false,
|
||||
size: 100,
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
});
|
||||
getThreatEventsMock.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 't1',
|
||||
eventType: 'click',
|
||||
analysis: ['click'],
|
||||
source: [],
|
||||
direction: [],
|
||||
status: [],
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
const result = await getBlastRadius(BASE_INPUT);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'ok',
|
||||
matched: 2, // 1 non-rejected delivered + 1 held
|
||||
delivered: 1,
|
||||
held: 1,
|
||||
rejected: 1,
|
||||
clicked: 1,
|
||||
perRecipient: expect.arrayContaining([
|
||||
{ recipient: BASE_INPUT.recipient, status: 'delivered' },
|
||||
{ recipient: 'other-recipient@wulfconsulting.test', status: 'rejected' },
|
||||
{ recipient: 'held-recipient@wulfconsulting.test', status: 'held' },
|
||||
]),
|
||||
source: 'fan-out',
|
||||
});
|
||||
expect(setCachedDataMock).toHaveBeenCalledTimes(1);
|
||||
expect(setCachedDataMock).toHaveBeenCalledWith(expect.any(String), result, 300);
|
||||
});
|
||||
|
||||
it('clicked is 0 when no click-type analysis value is present', async () => {
|
||||
getThreatEventsMock.mockResolvedValue({
|
||||
items: [{ id: 't1', eventType: 'malware', analysis: ['malware'], source: [], direction: [], status: [] }],
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
const result = await getBlastRadius(BASE_INPUT);
|
||||
|
||||
expect(result.status).toBe('ok');
|
||||
if (result.status === 'ok') {
|
||||
expect(result.clicked).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('degrades to unavailable/lookup_failed (never throws) when a fan-out call rejects', async () => {
|
||||
getHeldMessagesMock.mockRejectedValue(new Error('Mimecast API timeout'));
|
||||
|
||||
const result = await getBlastRadius(BASE_INPUT);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'unavailable',
|
||||
reason: 'lookup_failed',
|
||||
error: 'Mimecast API timeout',
|
||||
});
|
||||
expect(setCachedDataMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a recipient with no delivered/held rows at all is reported as unknown', async () => {
|
||||
const result = await getBlastRadius(BASE_INPUT);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'ok',
|
||||
matched: 0,
|
||||
delivered: 0,
|
||||
held: 0,
|
||||
rejected: 0,
|
||||
clicked: 0,
|
||||
perRecipient: [{ recipient: BASE_INPUT.recipient, status: 'unknown' }],
|
||||
source: 'fan-out',
|
||||
});
|
||||
});
|
||||
});
|
||||
195
lib/services/mimecast-blast-radius.ts
Normal file
195
lib/services/mimecast-blast-radius.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
/**
|
||||
* Mimecast blast-radius lookup abstraction (Phase 17).
|
||||
*
|
||||
* Given a reported message's identity (Message-ID, sender, recipient,
|
||||
* subject, date window), returns normalized delivery data — matched/
|
||||
* delivered/held/rejected/clicked counts and per-recipient status — when
|
||||
* Mimecast is configured, or a clean `status: 'unavailable'` signal (never a
|
||||
* throw) when it isn't or when the underlying lookup fails unexpectedly.
|
||||
*
|
||||
* Three load-bearing facts:
|
||||
*
|
||||
* (a) EPHEMERAL (D-03): this lookup is a pure function call — nothing is
|
||||
* persisted here. If Phase 19's classifier wants to keep a result, it
|
||||
* writes to its own `classifications.reasons` JSONB column; that is
|
||||
* Phase 19's concern, not this module's.
|
||||
*
|
||||
* (b) `clicked` IS BEST-EFFORT (D-02): it is derived from getThreatEvents()'s
|
||||
* analysis[] subtype containing a click-like value. Mimecast's real
|
||||
* click-tracking data (TTP URL Protect logs) lives in a separate,
|
||||
* currently-unwrapped `/api/ttp/url/get-logs` endpoint (see
|
||||
* 17-RESEARCH.md Pitfall 2) — this module does not call it. A value of
|
||||
* `clicked: 0` means "no click-type threat event found in the events
|
||||
* this module can see," NOT "confirmed zero clicks."
|
||||
*
|
||||
* (c) KNOWN LIMITATION — MULTI-TENANT GAP (D-05): this module uses only the
|
||||
* single global env-var-configured getMimecastClient(), NOT the
|
||||
* per-company `mimecast_tenants` table / getMimecastClientForTenant().
|
||||
* Reports belonging to companies with their own registered Mimecast
|
||||
* tenant (not covered by the global MIMECAST_CLIENT_ID) will return
|
||||
* `status: 'unavailable'` even though Mimecast is technically configured
|
||||
* for that company. Per-tenant resolution is a deliberate, documented v1
|
||||
* gap — not a silent oversight — and would be a small, mechanical
|
||||
* follow-up later (swap getMimecastClient() for a tenant lookup +
|
||||
* getMimecastClientForTenant(), same fan-out/merge logic below).
|
||||
*/
|
||||
|
||||
import {
|
||||
isMimecastConfigured,
|
||||
getMimecastClient,
|
||||
type MimecastDeliveredMessage,
|
||||
type MimecastHeldMessage,
|
||||
} from './mimecast-client';
|
||||
import { getCachedData, setCachedData } from './redis-client';
|
||||
|
||||
export interface BlastRadiusInput {
|
||||
/** Optional Mimecast Message-ID — used only for supplementary body/header
|
||||
* evidence via getMessageInfo(); it never gates or replaces the fan-out. */
|
||||
messageId?: string;
|
||||
/** Reported message's sender address. Required — see T-17-01: never allow
|
||||
* a date-range-only fan-out query. */
|
||||
sender: string;
|
||||
/** Reporter/reported-to recipient address. Required — see T-17-01. */
|
||||
recipient: string;
|
||||
/** Reported message's subject. Required — see T-17-01. */
|
||||
subject: string;
|
||||
/** Date window to search within. Required — see T-17-01. */
|
||||
dateWindow: { start: Date; end: Date };
|
||||
}
|
||||
|
||||
export type BlastRadiusResult =
|
||||
| { status: 'unavailable'; reason: 'not_configured' | 'lookup_failed'; error?: string }
|
||||
| {
|
||||
status: 'ok';
|
||||
matched: number;
|
||||
delivered: number;
|
||||
held: number;
|
||||
rejected: number;
|
||||
clicked: number;
|
||||
perRecipient: Array<{ recipient: string; status: 'delivered' | 'held' | 'rejected' | 'unknown' }>;
|
||||
source: 'fan-out';
|
||||
};
|
||||
|
||||
/**
|
||||
* Best-effort delivered/rejected split. The exact string values Mimecast
|
||||
* returns in MimecastDeliveredMessage.status have NOT been confirmed against
|
||||
* a live/sandbox tenant (17-RESEARCH.md A3 / Open Question #2) — only the
|
||||
* TypeScript field name and type (`string`) are confirmed from the client
|
||||
* code. Treat unrecognized status strings conservatively as NOT rejected
|
||||
* (i.e. delivered) rather than hardcoding a guessed enum as if it were
|
||||
* confirmed. Log raw values at debug level so a first real-tenant run can
|
||||
* validate/refine this heuristic.
|
||||
*/
|
||||
function isRejectedStatus(status: string): boolean {
|
||||
return /reject/i.test(status);
|
||||
}
|
||||
|
||||
/** Best-effort click-type detection (D-02) — see module doc-comment (b). */
|
||||
function isClickEvent(analysis: string[] | undefined): boolean {
|
||||
return (analysis ?? []).some((a) => /click/i.test(a));
|
||||
}
|
||||
|
||||
export async function getBlastRadius(input: BlastRadiusInput): Promise<BlastRadiusResult> {
|
||||
if (!isMimecastConfigured()) {
|
||||
return { status: 'unavailable', reason: 'not_configured' };
|
||||
}
|
||||
|
||||
const cacheKey = input.messageId
|
||||
? `mimecast:blast-radius:msgid:${input.messageId}`
|
||||
: `mimecast:blast-radius:composite:${input.sender}:${input.subject}:${input.dateWindow.start.toISOString()}:${input.dateWindow.end.toISOString()}`;
|
||||
|
||||
const cached = await getCachedData<BlastRadiusResult>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const client = getMimecastClient();
|
||||
|
||||
// Supplementary only — body/header evidence, never gates the fan-out
|
||||
// (17-RESEARCH.md Pitfall 1: getMessageInfo() has no status/counts).
|
||||
if (input.messageId) {
|
||||
await client.getMessageInfo(input.messageId);
|
||||
}
|
||||
|
||||
const startStr = input.dateWindow.start.toISOString().replace(/\.\d{3}Z$/, '+0000');
|
||||
const endStr = input.dateWindow.end.toISOString().replace(/\.\d{3}Z$/, '+0000');
|
||||
|
||||
// D-01 (corrected): the fan-out runs unconditionally — it is the only
|
||||
// source of the matched/delivered/held/rejected/clicked counts.
|
||||
const [deliveredResult, heldResult, threatsResult] = await Promise.all([
|
||||
client.searchDeliveredMessages({
|
||||
to: input.recipient,
|
||||
from: input.sender,
|
||||
subject: input.subject,
|
||||
start: startStr,
|
||||
end: endStr,
|
||||
}),
|
||||
client.getHeldMessages({ recipient: input.recipient }),
|
||||
client.getThreatEvents(),
|
||||
]);
|
||||
|
||||
const deliveredRows: MimecastDeliveredMessage[] = deliveredResult.messages ?? [];
|
||||
const heldRows: MimecastHeldMessage[] = heldResult.messages ?? [];
|
||||
|
||||
const rawStatuses = Array.from(new Set(deliveredRows.map((m) => m.status)));
|
||||
if (rawStatuses.length > 0) {
|
||||
console.debug('[MIMECAST-BLAST-RADIUS] raw delivered-message status values seen:', rawStatuses);
|
||||
}
|
||||
|
||||
const rejectedRows = deliveredRows.filter((m) => isRejectedStatus(m.status));
|
||||
const nonRejectedDeliveredRows = deliveredRows.filter((m) => !isRejectedStatus(m.status));
|
||||
|
||||
const delivered = nonRejectedDeliveredRows.length;
|
||||
const rejected = rejectedRows.length;
|
||||
const held = heldRows.length;
|
||||
// matched = delivered + held; a rejected recipient was never actually
|
||||
// delivered, so rejected is NOT included in matched.
|
||||
const matched = delivered + held;
|
||||
|
||||
const clicked = (threatsResult.items ?? []).filter((t) => isClickEvent(t.analysis)).length;
|
||||
|
||||
// Per-recipient merge: group by the single-string `to` field. Delivered
|
||||
// (non-rejected) rows → 'delivered', rejected rows → 'rejected', held
|
||||
// rows → 'held' (held overwrites a same-recipient delivered entry — a
|
||||
// held message for a recipient is the more actionable signal). Ensure
|
||||
// the originally-queried recipient is always represented, even if
|
||||
// neither result set returned a row for them ('unknown').
|
||||
const perRecipientMap = new Map<string, 'delivered' | 'held' | 'rejected' | 'unknown'>();
|
||||
for (const row of nonRejectedDeliveredRows) {
|
||||
if (row.to) perRecipientMap.set(row.to, 'delivered');
|
||||
}
|
||||
for (const row of rejectedRows) {
|
||||
if (row.to) perRecipientMap.set(row.to, 'rejected');
|
||||
}
|
||||
for (const row of heldRows) {
|
||||
if (row.to) perRecipientMap.set(row.to, 'held');
|
||||
}
|
||||
if (!perRecipientMap.has(input.recipient)) {
|
||||
perRecipientMap.set(input.recipient, 'unknown');
|
||||
}
|
||||
|
||||
const perRecipient = Array.from(perRecipientMap.entries()).map(([recipient, status]) => ({
|
||||
recipient,
|
||||
status,
|
||||
}));
|
||||
|
||||
const result: BlastRadiusResult = {
|
||||
status: 'ok',
|
||||
matched,
|
||||
delivered,
|
||||
held,
|
||||
rejected,
|
||||
clicked,
|
||||
perRecipient,
|
||||
source: 'fan-out',
|
||||
};
|
||||
|
||||
await setCachedData(cacheKey, result, 300);
|
||||
return result;
|
||||
} catch (err) {
|
||||
// T-17-02: log err.message ONLY, never full Mimecast response bodies
|
||||
// (which may contain other recipients' subjects/content).
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error('[MIMECAST-BLAST-RADIUS] lookup failed', message);
|
||||
return { status: 'unavailable', reason: 'lookup_failed', error: message };
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue