Merge branch 'worktree-agent-aa777fce7f2c01045'
This commit is contained in:
commit
50c55eec98
6 changed files with 311 additions and 27 deletions
|
|
@ -0,0 +1,119 @@
|
|||
---
|
||||
phase: quick-260716-n46
|
||||
plan: 01
|
||||
subsystem: api
|
||||
tags: [mimecast, blast-radius, phishing, multi-tenant, ticket-699308]
|
||||
|
||||
requires:
|
||||
- phase: 17-mimecast-blast-radius-lookup
|
||||
provides: getBlastRadius() fan-out + getMimecastClientForTenant() factory
|
||||
- phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve
|
||||
provides: app/api/phishing/campaigns/[id]/route.ts call site
|
||||
provides:
|
||||
- getBlastRadius(input, options?) with optional per-tenant client injection and cache-scope namespacing
|
||||
- Swallowed searchDeliveredMessages() error now surfaces as unavailable/lookup_failed instead of a false-clean ok
|
||||
- Campaign detail route clamps dateWindow.end to Date.now() and resolves reports.company_id -> mimecast_tenants -> tenant-scoped client
|
||||
affects: [phishing-campaign-review, mimecast-integration]
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Optional client-injection param on a service function so a caller can override a module's default singleton client while preserving the no-injection code path unchanged"
|
||||
- "Cache-key namespacing by an opaque cacheScope string to prevent cross-tenant collisions on shared cache infra"
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- lib/services/mimecast-blast-radius.ts
|
||||
- lib/services/mimecast-blast-radius.test.ts
|
||||
- lib/services/mimecast-client.test.ts
|
||||
- app/api/phishing/campaigns/[id]/route.ts
|
||||
|
||||
key-decisions:
|
||||
- "An injected tenant client bypasses the global isMimecastConfigured() gate entirely -- it carries its own credentials and is self-sufficient, matching the plan's explicit behavior spec"
|
||||
- "Swallowed deliveredResult.error is converted to a thrown Error so the existing outer try/catch (which already correctly logs err.message only, never response bodies) handles it -- no new logging path introduced"
|
||||
- "Tenant resolution query is scoped to enabled = true and ORDER BY id LIMIT 1, so a disabled or duplicate mimecast_tenants row for a company falls back to the global client rather than picking an unintended row"
|
||||
|
||||
patterns-established:
|
||||
- "Pattern: optional second options arg ({ client?, cacheScope? }) added additively to an existing exported function signature to avoid breaking BlastRadiusInput/BlastRadiusResult contract"
|
||||
|
||||
requirements-completed: [BUG-1-future-end-date, BUG-2-multi-tenant-gap]
|
||||
|
||||
duration: 5min
|
||||
completed: 2026-07-16
|
||||
---
|
||||
|
||||
# Quick Task 260716-n46: Fix Mimecast Blast-Radius Date-Window + Multi-Tenant Bugs Summary
|
||||
|
||||
**getBlastRadius now accepts an injected per-tenant MimecastClient and surfaces previously-swallowed search errors instead of a false "clean" zero-count; the campaign detail route clamps its date window to now and resolves each report's own company-specific Mimecast tenant before calling it.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 5 min
|
||||
- **Started:** 2026-07-16T16:42:23-04:00
|
||||
- **Completed:** 2026-07-16T16:47:19-04:00
|
||||
- **Tasks:** 3
|
||||
- **Files modified:** 4
|
||||
|
||||
## Accomplishments
|
||||
- Bug 1 (false clean on fresh campaigns): `getBlastRadius` now rethrows a swallowed `searchDeliveredMessages()` error (e.g. Mimecast's `err_track_and_trace_invalid_end_date`) as `{ status: 'unavailable', reason: 'lookup_failed', error }` instead of a confident zero-count `ok` — and the route-level fix (`Math.min(createdAt + 24h, Date.now())`) prevents the future end-date from ever being sent in the first place.
|
||||
- Bug 2 (multi-tenant gap, D-05): the campaign detail route now looks up `reports.company_id -> mimecast_tenants` (enabled = true) and, when a row exists, builds a tenant-scoped client via the already-implemented `getMimecastClientForTenant()` and threads it into `getBlastRadius` via a new optional `options` param — falling back to the global env-configured client when no company-specific tenant is registered.
|
||||
- Cache keys are now namespaced by `cacheScope` (company_id, or `'global'`) so two tenants querying the same synthetic message-id/composite key can never read each other's cached result (T-N46-02 mitigation).
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically (TDD tasks 1-2 follow red/green discipline):
|
||||
|
||||
1. **Task 1: Add optional per-tenant client injection + swallowed-error surfacing to getBlastRadius**
|
||||
- `4d54aba` test(260716-n46): add failing tests for tenant client injection + swallowed-error surfacing (RED)
|
||||
- `7c724cc` feat(260716-n46): support per-tenant client injection + surface swallowed delivered-search errors (GREEN)
|
||||
2. **Task 2: Add getMimecastClientForTenant coverage**
|
||||
- `12250c1` test(260716-n46): add coverage for getMimecastClientForTenant (coverage-only — function was already implemented per the plan's interfaces block, so tests passed immediately; no GREEN implementation step needed)
|
||||
3. **Task 3: Clamp date window (Bug 1) and resolve per-company tenant (Bug 2) in the campaign detail route**
|
||||
- `9951e53` fix(260716-n46): clamp blast-radius date window and resolve per-company Mimecast tenant
|
||||
|
||||
**Plan metadata:** (this SUMMARY.md commit, made by the executor per worktree-mode protocol)
|
||||
|
||||
## Files Created/Modified
|
||||
- `lib/services/mimecast-blast-radius.ts` — `getBlastRadius(input, options?: { client?; cacheScope? })`; resolves client as `options?.client ?? (isMimecastConfigured() ? getMimecastClient() : null)`; cache key prefixed by scope; throws on `deliveredResult.error` so the outer catch converts it to `unavailable/lookup_failed`; module doc-comment (c) updated to describe per-tenant resolution as supported (no longer "known limitation").
|
||||
- `lib/services/mimecast-blast-radius.test.ts` — 3 new tests: injected tenant client bypasses `getMimecastClient()`; injected client runs fan-out even when `isMimecastConfigured()` is false; swallowed `error` field degrades to `unavailable/lookup_failed` with `setCachedData` not called. Also added `getMimecastClientMock.mockClear()` to `beforeEach` (pre-existing gap in mock hygiene that the new "not.toHaveBeenCalled()" assertions exposed).
|
||||
- `lib/services/mimecast-client.test.ts` — new `describe('getMimecastClientForTenant', ...)` block: returns a client exposing the fan-out methods, builds a new instance per call, doesn't affect the cached global singleton, defaults `base_url` when omitted. Fake credentials only (`tid`/`tsecret`).
|
||||
- `app/api/phishing/campaigns/[id]/route.ts` — added `company_id` to the reports SELECT/`ReportRow`/mapped-`reports` shape; added `MimecastTenantRow` interface; inside the `primaryReport` branch, queries `mimecast_tenants` by `company_id` (`enabled = true`, `ORDER BY id LIMIT 1`) and builds a tenant client via `getMimecastClientForTenant()` when a row is found, passed to `getBlastRadius` as `{ client, cacheScope: companyId }`; `dateWindow.end` now `new Date(Math.min(createdAt.getTime() + 24h, Date.now()))`.
|
||||
|
||||
## Decisions Made
|
||||
- Followed the plan's explicit instruction that an injected tenant client is self-sufficient and bypasses the global `isMimecastConfigured()` gate (tenant carries its own credentials).
|
||||
- Did not add a redundant reports query for company_id — reused the existing `reportsRes` query per the plan's scope guard.
|
||||
- Did not touch other Mimecast call sites (e.g. `mimecast-sync-service.ts`) — scope guard honored.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
**1. [Test hygiene, in-scope] Added `getMimecastClientMock.mockClear()` to the existing `beforeEach` in `mimecast-blast-radius.test.ts`**
|
||||
- **Found during:** Task 1 (writing the "injected client bypasses getMimecastClient" test)
|
||||
- **Issue:** The pre-existing `beforeEach` never reset `getMimecastClientMock`'s call count, so a `not.toHaveBeenCalled()` assertion on it failed due to accumulated calls from earlier tests in the same file, not from the code under test.
|
||||
- **Fix:** Added `getMimecastClientMock.mockClear()` alongside the other five mock resets already in that `beforeEach`.
|
||||
- **Files modified:** `lib/services/mimecast-blast-radius.test.ts`
|
||||
- **Verification:** All 9 tests in the file pass; existing tests' behavior/assertions unchanged.
|
||||
- **Committed in:** `4d54aba` (Task 1 RED commit)
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 1 (test-infrastructure hygiene fix required to make the plan's own specified assertion pass correctly; no production-code scope creep).
|
||||
**Impact on plan:** None on shipped behavior — test-only change needed for the plan's own required test assertions to be meaningful.
|
||||
|
||||
## Issues Encountered
|
||||
- An intermediate `git stash -u` was run in error while diagnosing an unrelated pre-existing test failure (see below) — this is a prohibited operation in worktree mode. It was caught immediately via the system's post-command file-state reminder. Recovery used only read-only `git show stash@{0}:<path>` (never `git stash pop/apply/drop`) to retrieve the stashed file content, which was then written back with the Write tool and verified byte-identical via `diff` before proceeding. The stash entry (`stash@{0}`) was left untouched/undropped in the stash list to avoid any further stash-subcommand risk; it is a stale duplicate of already-committed work and can be safely ignored or cleaned up by the repository owner outside this workflow.
|
||||
- `npx vitest run` (full suite) shows 2 pre-existing failures in `lib/services/analyzer/itglue-search.test.ts` unrelated to this plan's files — confirmed present both before and after this plan's edits, and unrelated to any file this plan touches. Logged to `deferred-items.md` in this directory per the executor's scope-boundary rule; left unfixed.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None — no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Both bugs are fixed within the existing `BlastRadiusInput`/`BlastRadiusResult` contract; no consumers of `getBlastRadius` besides the campaign detail route need updates.
|
||||
- The stray `stash@{0}` entry left in this worktree's shared stash list (see Issues Encountered) is a duplicate of already-committed work in this branch and is safe to drop later; not cleaned up here to avoid any `git stash` subcommand risk during this session.
|
||||
- `deferred-items.md` in this directory documents the pre-existing, out-of-scope `itglue-search.test.ts` failure for future triage.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All 4 code files and 2 planning files verified present on disk; all 5 commit hashes (4d54aba, 7c724cc, 12250c1, 9951e53, 1e384d5) verified present in `git log --oneline --all`.
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
# Deferred Items — 260716-n46
|
||||
|
||||
## Pre-existing, out-of-scope test failure
|
||||
|
||||
`lib/services/analyzer/itglue-search.test.ts > itglueSearch > tolerates per-call failures (configurations errors, flex still returns)` fails on a clean checkout of this branch, before any of this plan's changes. Confirmed present in the full `npx vitest run` both before and after Task 1-3 edits; none of this plan's files (`lib/services/mimecast-blast-radius.ts`, `lib/services/mimecast-blast-radius.test.ts`, `lib/services/mimecast-client.test.ts`, `app/api/phishing/campaigns/[id]/route.ts`) touch `lib/services/analyzer/itglue-search.ts` or its test. Left unfixed per the executor scope boundary (only auto-fix issues directly caused by the current task's changes).
|
||||
|
|
@ -10,6 +10,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||
import { requirePermission } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import { getBlastRadius, type BlastRadiusResult } from '@/lib/services/mimecast-blast-radius';
|
||||
import { getMimecastClientForTenant } from '@/lib/services/mimecast-client';
|
||||
import { mergeTimeline } from '@/lib/services/phishing-timeline';
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
|
@ -30,6 +31,7 @@ interface ReportRow {
|
|||
id: string;
|
||||
ticket_id: string;
|
||||
ticket_number: string | null;
|
||||
company_id: string | null;
|
||||
company_name: string | null;
|
||||
title: string | null;
|
||||
created_at: string;
|
||||
|
|
@ -83,6 +85,12 @@ interface AuditEventRow {
|
|||
created_at: string;
|
||||
}
|
||||
|
||||
interface MimecastTenantRow {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
base_url: string | null;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
|
|
@ -112,8 +120,8 @@ export async function GET(
|
|||
// Bulk-fetch linked reports (+ join contacts for requester email — campaigns
|
||||
// has no recipients column, must be derived via this join).
|
||||
const reportsRes = await postgresClient.query<ReportRow>(
|
||||
`SELECT r.id::text, r.ticket_id::text, r.ticket_number, r.company_name,
|
||||
r.title, r.created_at::text,
|
||||
`SELECT r.id::text, r.ticket_id::text, r.ticket_number, r.company_id::text,
|
||||
r.company_name, r.title, r.created_at::text,
|
||||
c.email_address AS requester_email
|
||||
FROM reports r
|
||||
LEFT JOIN contacts c ON c.id = r.requester_contact_id
|
||||
|
|
@ -173,6 +181,7 @@ export async function GET(
|
|||
id: r.id,
|
||||
ticketId: r.ticket_id,
|
||||
ticketNumber: r.ticket_number,
|
||||
companyId: r.company_id,
|
||||
companyName: r.company_name,
|
||||
title: r.title,
|
||||
createdAt: r.created_at,
|
||||
|
|
@ -255,15 +264,50 @@ export async function GET(
|
|||
| { from?: { email?: string | null } | null }
|
||||
| null;
|
||||
const createdAt = new Date(primaryReport.createdAt);
|
||||
blastRadius = await getBlastRadius({
|
||||
sender: senderIndicator?.value ?? messageHeaders?.from?.email ?? '',
|
||||
recipient: primaryReport.requesterEmail ?? '',
|
||||
subject: primaryMessage?.subject ?? primaryReport.title ?? '',
|
||||
dateWindow: {
|
||||
start: new Date(createdAt.getTime() - 24 * 60 * 60 * 1000),
|
||||
end: new Date(createdAt.getTime() + 24 * 60 * 60 * 1000),
|
||||
|
||||
// Bug 2 (D-05): resolve the reporting company's own registered Mimecast
|
||||
// tenant, if one exists, and query it directly instead of the global
|
||||
// env-configured (Wulf) tenant. Falls back to the global client when
|
||||
// the company has no enabled mimecast_tenants row.
|
||||
let tenantOptions: { client: ReturnType<typeof getMimecastClientForTenant>; cacheScope: string } | undefined;
|
||||
if (primaryReport.companyId) {
|
||||
const tenantRes = await postgresClient.query<MimecastTenantRow>(
|
||||
`SELECT client_id, client_secret, base_url
|
||||
FROM mimecast_tenants
|
||||
WHERE company_id = $1 AND enabled = true
|
||||
ORDER BY id LIMIT 1`,
|
||||
[primaryReport.companyId]
|
||||
);
|
||||
const tenantRow = tenantRes.rows[0];
|
||||
if (tenantRow) {
|
||||
tenantOptions = {
|
||||
client: getMimecastClientForTenant({
|
||||
client_id: tenantRow.client_id,
|
||||
client_secret: tenantRow.client_secret,
|
||||
base_url: tenantRow.base_url ?? undefined,
|
||||
}),
|
||||
cacheScope: primaryReport.companyId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
blastRadius = await getBlastRadius(
|
||||
{
|
||||
sender: senderIndicator?.value ?? messageHeaders?.from?.email ?? '',
|
||||
recipient: primaryReport.requesterEmail ?? '',
|
||||
subject: primaryMessage?.subject ?? primaryReport.title ?? '',
|
||||
dateWindow: {
|
||||
start: new Date(createdAt.getTime() - 24 * 60 * 60 * 1000),
|
||||
// Bug 1: clamp the end of the window to now — a freshly-detected
|
||||
// campaign's primary report is <24h old, which would otherwise
|
||||
// produce a future `end` that Mimecast's real API rejects
|
||||
// (err_track_and_trace_invalid_end_date), swallowed internally by
|
||||
// searchDeliveredMessages() as a false zero-count "clean" result.
|
||||
end: new Date(Math.min(createdAt.getTime() + 24 * 60 * 60 * 1000, Date.now())),
|
||||
},
|
||||
},
|
||||
});
|
||||
tenantOptions
|
||||
);
|
||||
} else {
|
||||
blastRadius = { status: 'unavailable', reason: 'not_configured' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
@ -224,4 +225,62 @@ describe('getBlastRadius', () => {
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ?? [];
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,12 @@
|
|||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { isMimecastConfigured, getMimecastClient, _resetMimecastClient } from './mimecast-client';
|
||||
import {
|
||||
isMimecastConfigured,
|
||||
getMimecastClient,
|
||||
getMimecastClientForTenant,
|
||||
_resetMimecastClient,
|
||||
} from './mimecast-client';
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.MIMECAST_CLIENT_ID;
|
||||
|
|
@ -61,3 +66,40 @@ describe('getMimecastClient', () => {
|
|||
expect(second).not.toBe(first);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMimecastClientForTenant', () => {
|
||||
// Fake credentials only — never real mimecast_tenants values.
|
||||
const FAKE_TENANT = { client_id: 'tid', client_secret: 'tsecret', base_url: 'https://tenant.example' };
|
||||
|
||||
it('returns a MimecastClient instance exposing the fan-out methods', () => {
|
||||
const client = getMimecastClientForTenant(FAKE_TENANT);
|
||||
expect(client).toBeTruthy();
|
||||
expect(typeof client.searchDeliveredMessages).toBe('function');
|
||||
expect(typeof client.getHeldMessages).toBe('function');
|
||||
expect(typeof client.getThreatEvents).toBe('function');
|
||||
});
|
||||
|
||||
it('returns a NEW instance on each call — never the cached global', () => {
|
||||
const first = getMimecastClientForTenant(FAKE_TENANT);
|
||||
const second = getMimecastClientForTenant(FAKE_TENANT);
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it('does not populate or replace the cached global getMimecastClient() instance', () => {
|
||||
process.env.MIMECAST_CLIENT_ID = 'id1';
|
||||
process.env.MIMECAST_CLIENT_SECRET = 'secret1';
|
||||
|
||||
getMimecastClientForTenant(FAKE_TENANT);
|
||||
const global1 = getMimecastClient();
|
||||
const global2 = getMimecastClient();
|
||||
|
||||
// The global singleton is unaffected by tenant-client construction —
|
||||
// still cached and independent of the tenant instance.
|
||||
expect(global2).toBe(global1);
|
||||
expect(global1).not.toBe(getMimecastClientForTenant(FAKE_TENANT));
|
||||
});
|
||||
|
||||
it('does not throw when base_url is omitted (defaults to the Mimecast API host)', () => {
|
||||
expect(() => getMimecastClientForTenant({ client_id: 'tid', client_secret: 'tsecret' })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue