feat(260716-n46): support per-tenant client injection + surface swallowed delivered-search errors

- getBlastRadius(input, options?) accepts an optional injected MimecastClient
  and cacheScope; an injected client bypasses the global isMimecastConfigured()
  gate since it carries its own credentials
- cache key namespaced by cacheScope to prevent cross-tenant collisions
- deliveredResult.error (previously swallowed) now rethrown so the outer
  catch converts it to status: unavailable / reason: lookup_failed --
  defense-in-depth against Bug 1 (future end-date rejected by Mimecast)
- test mock hygiene: getMimecastClientMock now cleared in beforeEach
This commit is contained in:
lorentz 2026-07-16 16:44:23 -04:00
parent 4d54abacae
commit 7c724cc489
2 changed files with 32 additions and 16 deletions

View file

@ -50,6 +50,7 @@ const BASE_INPUT: BlastRadiusInput = {
describe('getBlastRadius', () => {
beforeEach(() => {
isMimecastConfiguredMock.mockReset().mockReturnValue(true);
getMimecastClientMock.mockClear();
getMessageInfoMock.mockReset().mockResolvedValue(null);
searchDeliveredMessagesMock.mockReset().mockResolvedValue({ messages: [] });
getHeldMessagesMock.mockReset().mockResolvedValue({ messages: [], totalCount: 0 });

View file

@ -22,21 +22,20 @@
* `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).
* (c) PER-TENANT RESOLUTION (D-05, formerly a known gap): this module now
* accepts an optional `options.client` a pre-built MimecastClient for a
* specific company's `mimecast_tenants` row (via
* getMimecastClientForTenant()). Resolution of WHICH tenant to use is the
* caller's responsibility (the campaign detail route looks up
* reports.company_id -> mimecast_tenants); this module simply uses
* whatever client it is given, or falls back to the single global
* env-var-configured getMimecastClient() when no client is injected.
*/
import {
isMimecastConfigured,
getMimecastClient,
type MimecastClient,
type MimecastDeliveredMessage,
type MimecastHeldMessage,
} from './mimecast-client';
@ -89,21 +88,27 @@ function isClickEvent(analysis: string[] | undefined): boolean {
return (analysis ?? []).some((a) => /click/i.test(a));
}
export async function getBlastRadius(input: BlastRadiusInput): Promise<BlastRadiusResult> {
if (!isMimecastConfigured()) {
export async function getBlastRadius(
input: BlastRadiusInput,
options?: { client?: MimecastClient; cacheScope?: string }
): Promise<BlastRadiusResult> {
// An injected tenant client is self-sufficient (it carries its own
// credentials) — only fall back to the global env-configured client (and
// its isMimecastConfigured() gate) when no client was injected.
const client = options?.client ?? (isMimecastConfigured() ? getMimecastClient() : null);
if (!client) {
return { status: 'unavailable', reason: 'not_configured' };
}
const scope = options?.cacheScope ?? 'global';
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()}`;
? `mimecast:blast-radius:${scope}:msgid:${input.messageId}`
: `mimecast:blast-radius:${scope}: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) {
@ -127,6 +132,16 @@ export async function getBlastRadius(input: BlastRadiusInput): Promise<BlastRadi
client.getThreatEvents(),
]);
// Bug 1 defense-in-depth: searchDeliveredMessages() swallows its own
// errors internally (returns { messages: [], error } rather than
// throwing) — e.g. a future end-date rejected by Mimecast as
// err_track_and_trace_invalid_end_date. Without this check, that
// swallowed error would silently present as a confident zero-count 'ok'.
// Surface it via the existing outer catch instead.
if (deliveredResult.error) {
throw new Error(deliveredResult.error);
}
const deliveredRows: MimecastDeliveredMessage[] = deliveredResult.messages ?? [];
const heldRows: MimecastHeldMessage[] = heldResult.messages ?? [];