/** * 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); getMimecastClientMock.mockClear(); 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', }); }); it('uses an injected tenant client via options.client and never calls getMimecastClient', async () => { const tenantGetMessageInfoMock = vi.fn().mockResolvedValue(null); const tenantSearchDeliveredMessagesMock = vi.fn().mockResolvedValue({ messages: [] }); const tenantGetHeldMessagesMock = vi.fn().mockResolvedValue({ messages: [], totalCount: 0 }); const tenantGetThreatEventsMock = vi.fn().mockResolvedValue({ items: [], nextCursor: null }); const fakeTenantClient = { getMessageInfo: tenantGetMessageInfoMock, searchDeliveredMessages: tenantSearchDeliveredMessagesMock, getHeldMessages: tenantGetHeldMessagesMock, getThreatEvents: tenantGetThreatEventsMock, }; const result = await getBlastRadius(BASE_INPUT, { client: fakeTenantClient as any, cacheScope: 'company-123' }); expect(result.status).toBe('ok'); expect(getMimecastClientMock).not.toHaveBeenCalled(); expect(tenantSearchDeliveredMessagesMock).toHaveBeenCalledTimes(1); expect(tenantGetHeldMessagesMock).toHaveBeenCalledTimes(1); expect(tenantGetThreatEventsMock).toHaveBeenCalledTimes(1); // Global client's mocked methods must not have been touched. expect(searchDeliveredMessagesMock).not.toHaveBeenCalled(); }); it('runs the fan-out with an injected tenant client even when isMimecastConfigured() is false', async () => { isMimecastConfiguredMock.mockReturnValue(false); const tenantSearchDeliveredMessagesMock = vi.fn().mockResolvedValue({ messages: [] }); const tenantGetHeldMessagesMock = vi.fn().mockResolvedValue({ messages: [], totalCount: 0 }); const tenantGetThreatEventsMock = vi.fn().mockResolvedValue({ items: [], nextCursor: null }); const fakeTenantClient = { getMessageInfo: vi.fn().mockResolvedValue(null), searchDeliveredMessages: tenantSearchDeliveredMessagesMock, getHeldMessages: tenantGetHeldMessagesMock, getThreatEvents: tenantGetThreatEventsMock, }; const result = await getBlastRadius(BASE_INPUT, { client: fakeTenantClient as any }); expect(result.status).toBe('ok'); expect(getMimecastClientMock).not.toHaveBeenCalled(); expect(tenantSearchDeliveredMessagesMock).toHaveBeenCalledTimes(1); }); it('degrades to unavailable/lookup_failed when searchDeliveredMessages swallows an error internally (Bug 1 defense-in-depth)', async () => { searchDeliveredMessagesMock.mockResolvedValue({ messages: [], error: 'err_track_and_trace_invalid_end_date', }); const result = await getBlastRadius(BASE_INPUT); expect(result).toEqual({ status: 'unavailable', reason: 'lookup_failed', error: 'err_track_and_trace_invalid_end_date', }); expect(setCachedDataMock).not.toHaveBeenCalled(); }); it('calls getHeldMessages with the SAME start/end window passed to searchDeliveredMessages', async () => { await getBlastRadius(BASE_INPUT); const expectedStart = BASE_INPUT.dateWindow.start.toISOString().replace(/\.\d{3}Z$/, '+0000'); const expectedEnd = BASE_INPUT.dateWindow.end.toISOString().replace(/\.\d{3}Z$/, '+0000'); expect(searchDeliveredMessagesMock).toHaveBeenCalledWith( expect.objectContaining({ start: expectedStart, end: expectedEnd }) ); const deliveredArg = searchDeliveredMessagesMock.mock.calls[0][0]; expect(deliveredArg).not.toHaveProperty('to'); expect(getHeldMessagesMock).toHaveBeenCalledWith({ start: expectedStart, end: expectedEnd, }); }); it('returns every distinct recipient from a multi-recipient delivered result (true blast radius)', 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: 'Delivered', subject: BASE_INPUT.subject, from: BASE_INPUT.sender, fromEnv: BASE_INPUT.sender, to: 'coworker-a@wulfconsulting.test', toDisplay: 'Coworker A', received: '2026-07-14T12:05:00+0000', senderIP: '1.2.3.4', spamScore: 0, detectionLevel: 'none', attachments: false, route: 'inbound', info: '', }, { id: 'd3', status: 'Delivered', subject: BASE_INPUT.subject, from: BASE_INPUT.sender, fromEnv: BASE_INPUT.sender, to: 'coworker-b@wulfconsulting.test', toDisplay: 'Coworker B', received: '2026-07-14T12:10:00+0000', senderIP: '1.2.3.4', spamScore: 0, detectionLevel: 'none', attachments: false, route: 'inbound', info: '', }, ], }); const result = await getBlastRadius(BASE_INPUT); expect(result.status).toBe('ok'); if (result.status === 'ok') { expect(result.delivered).toBe(3); expect(result.matched).toBe(3); expect(result.perRecipient).toEqual( expect.arrayContaining([ { recipient: BASE_INPUT.recipient, status: 'delivered' }, { recipient: 'coworker-a@wulfconsulting.test', status: 'delivered' }, { recipient: 'coworker-b@wulfconsulting.test', status: 'delivered' }, ]) ); } }); it('excludes an in-window held row from an UNRELATED sender domain from held/matched and perRecipient', 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: '', }, ], }); getHeldMessagesMock.mockResolvedValue({ messages: [ { id: 'h1', subject: 'Weekly promo', from: 'promo@paulfredrick.test', // unrelated domain vs. evil.test fromDisplay: 'Paul Fredrick', to: BASE_INPUT.recipient, toDisplay: 'Reporter', dateReceived: '2026-07-14T13:00:00+0000', reason: 'spam', reasonCode: 'SPAM', policyInfo: '', route: 'inbound', hasAttachments: false, size: 100, }, ], totalCount: 1, }); const result = await getBlastRadius(BASE_INPUT); expect(result).toEqual({ status: 'ok', matched: 1, delivered: 1, held: 0, rejected: 0, clicked: 0, perRecipient: [{ recipient: BASE_INPUT.recipient, status: 'delivered' }], source: 'fan-out', }); }); it('still counts and overrides when a held row sender domain MATCHES input.sender', 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: '', }, ], }); getHeldMessagesMock.mockResolvedValue({ messages: [ { id: 'h1', subject: BASE_INPUT.subject, from: 'queue@evil.test', // same domain as BASE_INPUT.sender (evil.test) fromDisplay: 'Attacker Queue', to: BASE_INPUT.recipient, toDisplay: 'Reporter', dateReceived: '2026-07-14T13:00:00+0000', reason: 'spam', reasonCode: 'SPAM', policyInfo: '', route: 'inbound', hasAttachments: false, size: 100, }, ], totalCount: 1, }); const result = await getBlastRadius(BASE_INPUT); expect(result.status).toBe('ok'); if (result.status === 'ok') { expect(result.held).toBe(1); expect(result.delivered).toBe(1); expect(result.matched).toBe(2); // 1 non-rejected delivered + 1 held (counts are independent of the perRecipient override) expect(result.perRecipient).toEqual([{ recipient: BASE_INPUT.recipient, status: 'held' }]); } }); });