diff --git a/lib/services/campaign-classifier.test.ts b/lib/services/campaign-classifier.test.ts index 7295bb7..d54d4e4 100644 --- a/lib/services/campaign-classifier.test.ts +++ b/lib/services/campaign-classifier.test.ts @@ -247,12 +247,14 @@ interface ReportFixtureRow { title: string | null; created_at: string; requester_email: string | null; + company_id: string | null; } interface StagedRows { reports?: ReportFixtureRow[]; messages?: Array<{ id: string; report_id: string; headers: NormalizedMessage }>; indicators?: Array<{ id: string; message_id: string; indicator_type: string; value: string }>; + mimecastTenants?: Array<{ client_id: string; client_secret: string; base_url: string | null }>; } function stageQueries(rows: StagedRows) { @@ -260,6 +262,9 @@ function stageQueries(rows: StagedRows) { if (sql.includes('INSERT INTO classifications')) { return { rows: [{ id: 'classification-1', created_at: '2026-07-16T00:00:00.000Z' }], rowCount: 1 }; } + if (sql.includes('FROM mimecast_tenants')) { + return { rows: rows.mimecastTenants ?? [], rowCount: rows.mimecastTenants?.length ?? 0 }; + } if (sql.includes('FROM reports')) { return { rows: rows.reports ?? [], rowCount: rows.reports?.length ?? 0 }; } @@ -288,7 +293,7 @@ describe('classifyCampaign', () => { it('returns exactly one verdict with the full payload shape (returns exactly one verdict)', async () => { stageQueries({ reports: [ - { id: 'report-1', title: cleanSpamMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL }, + { id: 'report-1', title: cleanSpamMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL , company_id: null}, ], messages: [toMessageRow('message-1', 'report-1', cleanSpamMessage)], indicators: [], @@ -320,7 +325,7 @@ describe('classifyCampaign', () => { it('inserts exactly one append-only classifications row with no ON CONFLICT', async () => { stageQueries({ reports: [ - { id: 'report-1', title: cleanSpamMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL }, + { id: 'report-1', title: cleanSpamMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL , company_id: null}, ], messages: [toMessageRow('message-1', 'report-1', cleanSpamMessage)], indicators: [], @@ -353,7 +358,7 @@ describe('classifyCampaign', () => { async (_label, fixture) => { stageQueries({ reports: [ - { id: 'report-1', title: fixture.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL }, + { id: 'report-1', title: fixture.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL , company_id: null}, ], messages: [toMessageRow('message-1', 'report-1', fixture)], indicators: [], @@ -383,7 +388,7 @@ describe('classifyCampaign', () => { async (_label, fixture) => { stageQueries({ reports: [ - { id: 'report-1', title: fixture.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL }, + { id: 'report-1', title: fixture.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL , company_id: null}, ], messages: [toMessageRow('message-1', 'report-1', fixture)], indicators: [], @@ -410,7 +415,7 @@ describe('classifyCampaign', () => { it('classifies a real non-simulation signal as THREAT with destructive recommended actions (threat tier)', async () => { stageQueries({ reports: [ - { id: 'report-1', title: threatMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL }, + { id: 'report-1', title: threatMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL , company_id: null}, ], messages: [toMessageRow('message-1', 'report-1', threatMessage)], indicators: [], @@ -442,8 +447,8 @@ describe('classifyCampaign', () => { const sharedUrl = 'http://evil-shared.example.test/payload'; stageQueries({ reports: [ - { id: 'report-1', title: 'Invoice attached', created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL }, - { id: 'report-2', title: 'Invoice attached', created_at: '2026-07-15T02:00:00.000Z', requester_email: 'reporter2@wulfconsulting.test' }, + { id: 'report-1', title: 'Invoice attached', created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL , company_id: null}, + { id: 'report-2', title: 'Invoice attached', created_at: '2026-07-15T02:00:00.000Z', requester_email: 'reporter2@wulfconsulting.test' , company_id: null}, ], messages: [ toMessageRow('message-1', 'report-1', cleanSpamMessage), @@ -473,7 +478,7 @@ describe('classifyCampaign', () => { it('classifies a clean campaign with no indicators and no delivery/click signal as SPAM (spam vs unwanted tier)', async () => { stageQueries({ reports: [ - { id: 'report-1', title: cleanSpamMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL }, + { id: 'report-1', title: cleanSpamMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL , company_id: null}, ], messages: [toMessageRow('message-1', 'report-1', cleanSpamMessage)], indicators: [], @@ -497,7 +502,7 @@ describe('classifyCampaign', () => { it('classifies a suspicious-but-contained campaign (one url indicator, delivery contained to reporter) as UNWANTED (spam vs unwanted tier)', async () => { stageQueries({ reports: [ - { id: 'report-1', title: suspiciousUnwantedMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL }, + { id: 'report-1', title: suspiciousUnwantedMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL , company_id: null}, ], messages: [toMessageRow('message-1', 'report-1', suspiciousUnwantedMessage)], indicators: [ @@ -529,7 +534,7 @@ describe('classifyCampaign', () => { })); stageQueries({ reports: [ - { id: 'report-1', title: cleanSpamMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL }, + { id: 'report-1', title: cleanSpamMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL , company_id: null}, ], messages: [toMessageRow('message-1', 'report-1', cleanSpamMessage)], indicators: manyIndicators, @@ -552,4 +557,91 @@ describe('classifyCampaign', () => { expect(reason.length).toBeLessThan(300); } }); + + // =========================================================================== + // Bug 2 (D-05) parity: per-company Mimecast tenant resolution vs. global + // env fallback — mirrors app/api/phishing/campaigns/[id]/route.ts's + // already-tested tenant-resolution block (260721-n49). + // =========================================================================== + + it('resolves the reporting company\'s own Mimecast tenant and scopes getBlastRadius when an enabled mimecast_tenants row exists (tenant resolution)', async () => { + stageQueries({ + reports: [ + { + id: 'report-1', + title: cleanSpamMessage.subject, + created_at: '2026-07-15T00:00:00.000Z', + requester_email: REPORTER_EMAIL, + company_id: '29683407', + }, + ], + messages: [toMessageRow('message-1', 'report-1', cleanSpamMessage)], + indicators: [], + mimecastTenants: [ + { client_id: 'tenant-client-id', client_secret: 'tenant-client-secret', base_url: 'https://eu-api.mimecast.com' }, + ], + }); + getBlastRadiusMock.mockResolvedValue({ + status: 'ok', + matched: 1, + delivered: 0, + held: 1, + rejected: 0, + clicked: 0, + perRecipient: [], + source: 'fan-out', + }); + + await classifyCampaign('campaign-1'); + + expect(getBlastRadiusMock).toHaveBeenCalledTimes(1); + const [, tenantOptions] = getBlastRadiusMock.mock.calls[0]; + expect(tenantOptions).toBeDefined(); + expect(tenantOptions.cacheScope).toBe('29683407'); + expect(tenantOptions.client).toBeTruthy(); + + const tenantQueryCalls = queryMock.mock.calls.filter( + ([sql]) => typeof sql === 'string' && sql.includes('FROM mimecast_tenants') + ); + expect(tenantQueryCalls).toHaveLength(1); + expect(tenantQueryCalls[0][0]).toMatch(/company_id = \$1 AND enabled = true/); + }); + + it.each([ + ['no companyId on the primary report', null, [] as Array<{ client_id: string; client_secret: string; base_url: string | null }>], + ['companyId set but no enabled tenant row', '29683407', []], + ])( + 'calls getBlastRadius with no tenant scoping when %s (global fallback preserved)', + async (_label, companyId, mimecastTenants) => { + stageQueries({ + reports: [ + { + id: 'report-1', + title: cleanSpamMessage.subject, + created_at: '2026-07-15T00:00:00.000Z', + requester_email: REPORTER_EMAIL, + company_id: companyId, + }, + ], + messages: [toMessageRow('message-1', 'report-1', cleanSpamMessage)], + indicators: [], + mimecastTenants, + }); + getBlastRadiusMock.mockResolvedValue({ + status: 'ok', + matched: 1, + delivered: 0, + held: 1, + rejected: 0, + clicked: 0, + perRecipient: [], + source: 'fan-out', + }); + + await classifyCampaign('campaign-1'); + + expect(getBlastRadiusMock).toHaveBeenCalledTimes(1); + expect(getBlastRadiusMock.mock.calls[0][1]).toBeUndefined(); + } + ); }); diff --git a/lib/services/campaign-classifier.ts b/lib/services/campaign-classifier.ts index 45c0b7b..3e3e5bb 100644 --- a/lib/services/campaign-classifier.ts +++ b/lib/services/campaign-classifier.ts @@ -17,6 +17,7 @@ import type { AuthResults } from './eml-parser'; import { postgresClient } from './postgres-client'; import { getBlastRadius, type BlastRadiusResult } from './mimecast-blast-radius'; +import { getMimecastClientForTenant } from './mimecast-client'; // ============================================================================= // D-06/D-07: KnowBe4 / Breach Secure Now simulation sender-domain allowlist @@ -213,6 +214,14 @@ interface ReportDbRow { title: string | null; created_at: string; requester_email: string | null; + company_id: string | null; +} + +/** Mirrors app/api/phishing/campaigns/[id]/route.ts's MimecastTenantRow shape verbatim. */ +interface MimecastTenantRow { + client_id: string; + client_secret: string; + base_url: string | null; } interface MessageDbRow { @@ -256,6 +265,7 @@ export interface CampaignReportSummary { title: string | null; createdAt: string; requesterEmail: string | null; + companyId: string | null; } export interface CampaignEvidence { @@ -283,7 +293,7 @@ export async function gatherCampaignEvidence(campaignId: string): Promise( `SELECT r.id::text AS id, r.title, r.created_at::text AS created_at, - c.email_address AS requester_email + c.email_address AS requester_email, r.company_id::text AS company_id FROM reports r LEFT JOIN contacts c ON c.id = r.requester_contact_id WHERE r.campaign_id = $1 @@ -295,6 +305,7 @@ export async function gatherCampaignEvidence(campaignId: string): Promise r.id); @@ -347,15 +358,47 @@ export async function gatherCampaignEvidence(campaignId: string): Promise i.messageId === primaryMessage?.id && i.indicatorType === 'sender' ); const createdAt = new Date(primaryReport.createdAt); - blastRadius = await getBlastRadius({ - sender: senderIndicator?.value ?? primaryMessage?.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. Mirrors + // app/api/phishing/campaigns/[id]/route.ts's identical tenant-resolution + // block verbatim — same query, same client build, same options shape. + let tenantOptions: { client: ReturnType; cacheScope: string } | undefined; + if (primaryReport.companyId) { + const tenantRes = await postgresClient.query( + `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 ?? primaryMessage?.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), + }, }, - }); + tenantOptions + ); } else { // No report ever linked to this campaign — nothing to look up (research A6). blastRadius = { status: 'unavailable', reason: 'not_configured' };