wulf-pulse/lib/services/mimecast-blast-radius.test.ts
lorentz efbc437e2e 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)
2026-07-15 14:30:00 -04:00

227 lines
7.4 KiB
TypeScript

/**
* 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',
});
});
});