From 9f12cd610a3199a02dcf44b5fe22142784b8db53 Mon Sep 17 00:00:00 2001 From: lorentz Date: Tue, 21 Jul 2026 16:45:44 -0400 Subject: [PATCH] test(260721-n49): cover tenant-scoped vs global-fallback getBlastRadius branches - Add mimecast_tenants routing branch to stageQueries and company_id to report fixtures - New test: enabled mimecast_tenants row -> getBlastRadius called with { client, cacheScope: companyId } and the tenant SQL is issued - New parameterized test: null companyId and companyId-with-no-enabled-row -> getBlastRadius called with no second argument (global fallback) --- lib/services/campaign-classifier.test.ts | 112 +++++++++++++++++++++-- 1 file changed, 102 insertions(+), 10 deletions(-) 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(); + } + ); });