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:
parent
4d54abacae
commit
7c724cc489
2 changed files with 32 additions and 16 deletions
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 ?? [];
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue