- blast-radius: getHeldMessages called with same start/end window as searchDeliveredMessages - blast-radius: unrelated-sender held row excluded from held/matched and perRecipient; matching-sender held row still counts and overrides - client: getHeldMessages threads start/end into POST body data[0] when provided, omits them when not
411 lines
14 KiB
TypeScript
411 lines
14 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);
|
|
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 })
|
|
);
|
|
expect(getHeldMessagesMock).toHaveBeenCalledWith({
|
|
recipient: BASE_INPUT.recipient,
|
|
start: expectedStart,
|
|
end: expectedEnd,
|
|
});
|
|
});
|
|
|
|
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' }]);
|
|
}
|
|
});
|
|
});
|