chore: merge quick task worktree (worktree-agent-a45f7294477ed0d95)
This commit is contained in:
commit
b7d6be47c6
4 changed files with 208 additions and 4 deletions
|
|
@ -283,4 +283,129 @@ describe('getBlastRadius', () => {
|
||||||
});
|
});
|
||||||
expect(setCachedDataMock).not.toHaveBeenCalled();
|
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' }]);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,25 @@ function isClickEvent(analysis: string[] | undefined): boolean {
|
||||||
return (analysis ?? []).some((a) => /click/i.test(a));
|
return (analysis ?? []).some((a) => /click/i.test(a));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sender-relevance guard: true when the domain of `a` exactly matches the
|
||||||
|
* domain of `b`, or one is a proper subdomain of the other — case-insensitive,
|
||||||
|
* never a bare substring match. Mirrors the comparison logic in
|
||||||
|
* campaign-classifier's `domainMatchesAllowlist` (but does not reuse it — that
|
||||||
|
* helper is allowlist-specific).
|
||||||
|
*/
|
||||||
|
function domainsMatch(a: string, b: string): boolean {
|
||||||
|
const domainOf = (address: string): string => {
|
||||||
|
const at = address.lastIndexOf('@');
|
||||||
|
if (at === -1 || at === address.length - 1) return '';
|
||||||
|
return address.slice(at + 1).toLowerCase();
|
||||||
|
};
|
||||||
|
const x = domainOf(a);
|
||||||
|
const y = domainOf(b);
|
||||||
|
if (!x || !y) return false;
|
||||||
|
return x === y || x.endsWith(`.${y}`) || y.endsWith(`.${x}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getBlastRadius(
|
export async function getBlastRadius(
|
||||||
input: BlastRadiusInput,
|
input: BlastRadiusInput,
|
||||||
options?: { client?: MimecastClient; cacheScope?: string }
|
options?: { client?: MimecastClient; cacheScope?: string }
|
||||||
|
|
@ -128,7 +147,7 @@ export async function getBlastRadius(
|
||||||
start: startStr,
|
start: startStr,
|
||||||
end: endStr,
|
end: endStr,
|
||||||
}),
|
}),
|
||||||
client.getHeldMessages({ recipient: input.recipient }),
|
client.getHeldMessages({ recipient: input.recipient, start: startStr, end: endStr }),
|
||||||
client.getThreatEvents(),
|
client.getThreatEvents(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -144,6 +163,17 @@ export async function getBlastRadius(
|
||||||
|
|
||||||
const deliveredRows: MimecastDeliveredMessage[] = deliveredResult.messages ?? [];
|
const deliveredRows: MimecastDeliveredMessage[] = deliveredResult.messages ?? [];
|
||||||
const heldRows: MimecastHeldMessage[] = heldResult.messages ?? [];
|
const heldRows: MimecastHeldMessage[] = heldResult.messages ?? [];
|
||||||
|
// Even within the date window, unrelated held messages (different
|
||||||
|
// senders that happen to land in the same window) must not count toward
|
||||||
|
// held/matched or override a recipient's delivered status. Filter to
|
||||||
|
// rows whose sender domain plausibly matches the campaign's sender.
|
||||||
|
const relevantHeldRows = heldRows.filter((h) => domainsMatch(h.from, input.sender));
|
||||||
|
if (heldRows.length !== relevantHeldRows.length) {
|
||||||
|
console.debug(
|
||||||
|
'[MIMECAST-BLAST-RADIUS] held rows filtered as unrelated sender:',
|
||||||
|
heldRows.length - relevantHeldRows.length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const rawStatuses = Array.from(new Set(deliveredRows.map((m) => m.status)));
|
const rawStatuses = Array.from(new Set(deliveredRows.map((m) => m.status)));
|
||||||
if (rawStatuses.length > 0) {
|
if (rawStatuses.length > 0) {
|
||||||
|
|
@ -155,7 +185,7 @@ export async function getBlastRadius(
|
||||||
|
|
||||||
const delivered = nonRejectedDeliveredRows.length;
|
const delivered = nonRejectedDeliveredRows.length;
|
||||||
const rejected = rejectedRows.length;
|
const rejected = rejectedRows.length;
|
||||||
const held = heldRows.length;
|
const held = relevantHeldRows.length;
|
||||||
// matched = delivered + held; a rejected recipient was never actually
|
// matched = delivered + held; a rejected recipient was never actually
|
||||||
// delivered, so rejected is NOT included in matched.
|
// delivered, so rejected is NOT included in matched.
|
||||||
const matched = delivered + held;
|
const matched = delivered + held;
|
||||||
|
|
@ -175,7 +205,7 @@ export async function getBlastRadius(
|
||||||
for (const row of rejectedRows) {
|
for (const row of rejectedRows) {
|
||||||
if (row.to) perRecipientMap.set(row.to, 'rejected');
|
if (row.to) perRecipientMap.set(row.to, 'rejected');
|
||||||
}
|
}
|
||||||
for (const row of heldRows) {
|
for (const row of relevantHeldRows) {
|
||||||
if (row.to) perRecipientMap.set(row.to, 'held');
|
if (row.to) perRecipientMap.set(row.to, 'held');
|
||||||
}
|
}
|
||||||
if (!perRecipientMap.has(input.recipient)) {
|
if (!perRecipientMap.has(input.recipient)) {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
* for this phase (Phase 17, Plan 01, Task 1).
|
* for this phase (Phase 17, Plan 01, Task 1).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
isMimecastConfigured,
|
isMimecastConfigured,
|
||||||
getMimecastClient,
|
getMimecastClient,
|
||||||
|
|
@ -103,3 +103,44 @@ describe('getMimecastClientForTenant', () => {
|
||||||
expect(() => getMimecastClientForTenant({ client_id: 'tid', client_secret: 'tsecret' })).not.toThrow();
|
expect(() => getMimecastClientForTenant({ client_id: 'tid', client_secret: 'tsecret' })).not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getHeldMessages date-scoping', () => {
|
||||||
|
// Fake credentials only — never real mimecast_tenants values.
|
||||||
|
const FAKE_TENANT = { client_id: 'tid', client_secret: 'tsecret', base_url: 'https://tenant.example' };
|
||||||
|
|
||||||
|
it('includes start/end in the POST body data[0] when provided', async () => {
|
||||||
|
const client = getMimecastClientForTenant(FAKE_TENANT);
|
||||||
|
const requestSpy = vi
|
||||||
|
.spyOn(client as any, 'request')
|
||||||
|
.mockResolvedValue({ data: [], meta: { pagination: {} } });
|
||||||
|
|
||||||
|
await client.getHeldMessages({
|
||||||
|
recipient: 'r@x.test',
|
||||||
|
start: '2026-07-14T00:00:00+0000',
|
||||||
|
end: '2026-07-15T00:00:00+0000',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(requestSpy).toHaveBeenCalledTimes(1);
|
||||||
|
const body: any = requestSpy.mock.calls[0][2];
|
||||||
|
expect(body.data[0]).toMatchObject({
|
||||||
|
admin: true,
|
||||||
|
start: '2026-07-14T00:00:00+0000',
|
||||||
|
end: '2026-07-15T00:00:00+0000',
|
||||||
|
searchBy: { fieldName: 'recipient', value: 'r@x.test' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits start/end from the POST body data[0] when not provided', async () => {
|
||||||
|
const client = getMimecastClientForTenant(FAKE_TENANT);
|
||||||
|
const requestSpy = vi
|
||||||
|
.spyOn(client as any, 'request')
|
||||||
|
.mockResolvedValue({ data: [], meta: { pagination: {} } });
|
||||||
|
|
||||||
|
await client.getHeldMessages({ recipient: 'r@x.test' });
|
||||||
|
|
||||||
|
expect(requestSpy).toHaveBeenCalledTimes(1);
|
||||||
|
const body: any = requestSpy.mock.calls[0][2];
|
||||||
|
expect(body.data[0]).not.toHaveProperty('start');
|
||||||
|
expect(body.data[0]).not.toHaveProperty('end');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -482,6 +482,8 @@ export class MimecastClient {
|
||||||
async getHeldMessages(options: {
|
async getHeldMessages(options: {
|
||||||
recipient?: string;
|
recipient?: string;
|
||||||
maxMessages?: number;
|
maxMessages?: number;
|
||||||
|
start?: string;
|
||||||
|
end?: string;
|
||||||
} = {}): Promise<{ messages: MimecastHeldMessage[]; totalCount: number }> {
|
} = {}): Promise<{ messages: MimecastHeldMessage[]; totalCount: number }> {
|
||||||
const maxMessages = options.maxMessages ?? 100;
|
const maxMessages = options.maxMessages ?? 100;
|
||||||
const all: MimecastHeldMessage[] = [];
|
const all: MimecastHeldMessage[] = [];
|
||||||
|
|
@ -490,6 +492,12 @@ export class MimecastClient {
|
||||||
|
|
||||||
do {
|
do {
|
||||||
const reqBody: any = { admin: true };
|
const reqBody: any = { admin: true };
|
||||||
|
if (options.start) {
|
||||||
|
reqBody.start = options.start;
|
||||||
|
}
|
||||||
|
if (options.end) {
|
||||||
|
reqBody.end = options.end;
|
||||||
|
}
|
||||||
if (options.recipient) {
|
if (options.recipient) {
|
||||||
reqBody.searchBy = { fieldName: 'recipient', value: options.recipient };
|
reqBody.searchBy = { fieldName: 'recipient', value: options.recipient };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue