diff --git a/apps/api/eslint.config.js b/apps/api/eslint.config.js index e607551e72..8482281ea0 100644 --- a/apps/api/eslint.config.js +++ b/apps/api/eslint.config.js @@ -15,4 +15,21 @@ export default [ linterOptions: { reportUnusedDisableDirectives: 'off' }, rules: {}, }, + { + files: ['src/**/*.ts'], + ignores: ['src/**/*.test.ts', 'src/lib/validation.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: '@hono/zod-validator', + message: 'Import zValidator from src/lib/validation instead.', + }, + ], + }, + ], + }, + }, ]; diff --git a/apps/api/src/__tests__/integration/securityComplianceReport.integration.test.ts b/apps/api/src/__tests__/integration/securityComplianceReport.integration.test.ts new file mode 100644 index 0000000000..60f3a3a7fd --- /dev/null +++ b/apps/api/src/__tests__/integration/securityComplianceReport.integration.test.ts @@ -0,0 +1,133 @@ +import './setup'; + +import { describe, expect, it } from 'vitest'; + +import { db, withDbAccessContext, withSystemDbAccessContext } from '../../db'; +import { devices, deviceVulnerabilities, vulnerabilities } from '../../db/schema'; +import { generateSecurityCompliancePostureReport } from '../../services/securityComplianceReport'; +import { loadOpenVulnerabilityCounts } from '../../services/securityComplianceReportVulnerabilities'; +import { setupTestEnvironment } from './db-utils'; + +const runDb = it.runIf(!!process.env.DATABASE_URL); + +describe('security compliance report vulnerability isolation', () => { + runDb('counts its own open catalog findings and excludes another organization', async () => { + const envA = await setupTestEnvironment({ scope: 'organization' }); + const envB = await setupTestEnvironment({ scope: 'organization' }); + + const seeded = await withSystemDbAccessContext(async () => { + const [deviceA, deviceB] = await db + .insert(devices) + .values([ + { + orgId: envA.organization.id, + siteId: envA.site.id, + agentId: `posture-a-${Date.now()}`, + hostname: 'posture-a', + osType: 'windows', + osVersion: '11', + architecture: 'x86_64', + agentVersion: 'test', + status: 'offline', + }, + { + orgId: envB.organization.id, + siteId: envB.site.id, + agentId: `posture-b-${Date.now()}`, + hostname: 'posture-b', + osType: 'windows', + osVersion: '11', + architecture: 'x86_64', + agentVersion: 'test', + status: 'offline', + }, + ]) + .returning({ id: devices.id, orgId: devices.orgId }); + + const [catalogA, catalogB, patchedCatalog, mitigatedCatalog, acceptedCatalog] = await db + .insert(vulnerabilities) + .values([ + { cveId: 'CVE-2026-71001', source: 'nvd', description: 'org A open finding', severity: 'HIGH', rawPayload: {} }, + { cveId: 'CVE-2026-71002', source: 'msrc', description: 'org B open finding', severity: 'Critical', rawPayload: {} }, + { cveId: 'CVE-2026-71003', source: 'nvd', description: 'org A patched finding', severity: 'CRITICAL', rawPayload: {} }, + { cveId: 'CVE-2026-71004', source: 'nvd', description: 'org A mitigated finding', severity: 'CRITICAL', rawPayload: {} }, + { cveId: 'CVE-2026-71005', source: 'nvd', description: 'org A accepted finding', severity: 'CRITICAL', rawPayload: {} }, + ]) + .returning({ id: vulnerabilities.id }); + + await db.insert(deviceVulnerabilities).values([ + { + orgId: envA.organization.id, + deviceId: deviceA!.id, + vulnerabilityId: catalogA!.id, + status: 'open', + detectedAt: new Date(), + }, + { + orgId: envB.organization.id, + deviceId: deviceB!.id, + vulnerabilityId: catalogB!.id, + status: 'open', + detectedAt: new Date(), + }, + { + orgId: envA.organization.id, + deviceId: deviceA!.id, + vulnerabilityId: patchedCatalog!.id, + status: 'patched', + detectedAt: new Date(), + }, + { + orgId: envA.organization.id, + deviceId: deviceA!.id, + vulnerabilityId: mitigatedCatalog!.id, + status: 'mitigated', + detectedAt: new Date(), + }, + { + orgId: envA.organization.id, + deviceId: deviceA!.id, + vulnerabilityId: acceptedCatalog!.id, + status: 'accepted', + detectedAt: new Date(), + }, + ]); + + return { deviceA: deviceA!.id, deviceB: deviceB!.id }; + }); + + const { result, vulnerabilityCounts } = await withDbAccessContext( + { + scope: 'organization', + orgId: envA.organization.id, + accessibleOrgIds: [envA.organization.id], + userId: envA.user.id, + }, + async () => { + const result = await generateSecurityCompliancePostureReport(envA.organization.id, { + includeCis: false, + }); + const vulnerabilityCounts = await loadOpenVulnerabilityCounts([ + seeded.deviceA, + seeded.deviceB, + ]); + return { result, vulnerabilityCounts }; + }, + ); + + expect(result.rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + hostname: 'posture-a', + openVulnHigh: 1, + openVulnCritical: 0, + }), + ]), + ); + expect(result.rows).not.toEqual( + expect.arrayContaining([expect.objectContaining({ hostname: 'posture-b' })]), + ); + expect(vulnerabilityCounts.get(seeded.deviceA)).toEqual({ high: 1, critical: 0 }); + expect(vulnerabilityCounts.has(seeded.deviceB)).toBe(false); + }); +}); diff --git a/apps/api/src/db/autoMigrate.test.ts b/apps/api/src/db/autoMigrate.test.ts index 07165f1ef9..630a15d59f 100644 --- a/apps/api/src/db/autoMigrate.test.ts +++ b/apps/api/src/db/autoMigrate.test.ts @@ -9,7 +9,7 @@ import { partitionLedgerRows, } from './autoMigrate'; import { createHash } from 'node:crypto'; -import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -114,91 +114,6 @@ describe('autoMigrate', () => { }); }); - // Regression test for issue #506. A `localeCompare` sort places - // `2026-04-19-installer-bootstrap-tokens-constraints.sql` before - // `...-tokens.sql` (because '-' < '.'), so the constraints migration ran - // before the table that owns those constraints existed. This scans every - // migration in the same order autoMigrate uses and asserts that each - // referenced table was created in this file or an earlier one. - describe('migration ordering', () => { - const MIGRATION_FILE_PATTERN = /^\d{4}-.*\.sql$/; - const migrationsDir = path.resolve(__dirname, '../../migrations'); - - const SYSTEM_TABLES = new Set([ - 'pg_policies', - 'pg_indexes', - 'pg_class', - 'pg_namespace', - 'pg_trigger', - 'pg_proc', - 'pg_constraint', - 'pg_attribute', - 'pg_type', - 'pg_tables', - 'information_schema', - ]); - - function collectMatches(sql: string, pattern: RegExp): string[] { - const out: string[] = []; - for (const match of sql.matchAll(pattern)) { - if (match[1]) out.push(match[1].toLowerCase()); - } - return out; - } - - function extractCreatedTables(sql: string): string[] { - return collectMatches( - sql, - /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:"?public"?\.)?"?([a-zA-Z_][a-zA-Z0-9_]*)"?/gi, - ); - } - - function extractReferencedTables(sql: string): string[] { - const stripped = sql - .replace(/--[^\n]*/g, '') - .replace(/\/\*[\s\S]*?\*\//g, ''); - // Only patterns that hard-fail when the table is missing. Tolerant - // forms like `DROP TABLE IF EXISTS` or `ALTER TABLE IF EXISTS` are - // intentionally excluded — they're a no-op against an absent table. - const patterns = [ - /\bREFERENCES\s+(?:"?public"?\.)?"?([a-zA-Z_][a-zA-Z0-9_]*)"?/gi, - /\bALTER\s+TABLE\s+(?!IF\s+EXISTS\b)(?:ONLY\s+)?(?:"?public"?\.)?"?([a-zA-Z_][a-zA-Z0-9_]*)"?/gi, - /\bCREATE\s+POLICY\s+[^;]*?\bON\s+(?:"?public"?\.)?"?([a-zA-Z_][a-zA-Z0-9_]*)"?/gi, - /\bCREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?[^;]*?\bON\s+(?:"?public"?\.)?"?([a-zA-Z_][a-zA-Z0-9_]*)"?/gi, - /\bCREATE\s+TRIGGER\s+[^;]*?\bON\s+(?:"?public"?\.)?"?([a-zA-Z_][a-zA-Z0-9_]*)"?/gi, - ]; - const refs: string[] = []; - for (const pattern of patterns) refs.push(...collectMatches(stripped, pattern)); - return refs; - } - - it('every referenced table is created in the same file or an earlier one', () => { - const files = readdirSync(migrationsDir) - .filter((name) => MIGRATION_FILE_PATTERN.test(name)) - .sort((a, b) => a.localeCompare(b)); - - expect(files.length).toBeGreaterThan(0); - - const created = new Set(); - const violations: string[] = []; - - for (const file of files) { - const sql = readFileSync(path.join(migrationsDir, file), 'utf8'); - // Add tables created in this file BEFORE checking references so a - // file that creates a table and immediately alters or self-references - // it passes. - for (const t of extractCreatedTables(sql)) created.add(t); - for (const ref of extractReferencedTables(sql)) { - if (SYSTEM_TABLES.has(ref)) continue; - if (created.has(ref)) continue; - violations.push(`${file} references "${ref}" before it is created`); - } - } - - expect(violations).toEqual([]); - }); - }); - describe('hasNoTransactionDirective', () => { it('returns true when "-- @no-transaction" is the first line', () => { expect(hasNoTransactionDirective('-- @no-transaction\nCREATE INDEX foo ON bar (x);')).toBe( diff --git a/apps/api/src/db/migrationOrdering.test.ts b/apps/api/src/db/migrationOrdering.test.ts new file mode 100644 index 0000000000..1485ebae5c --- /dev/null +++ b/apps/api/src/db/migrationOrdering.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from 'vitest'; +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; + +// Regression test for issue #506. A `localeCompare` sort places +// `2026-04-19-installer-bootstrap-tokens-constraints.sql` before +// `...-tokens.sql` (because '-' < '.'), so the constraints migration ran +// before the table that owns those constraints existed. This scans every +// migration in the same order autoMigrate uses and asserts that each +// referenced table was created in this file or an earlier one. +describe('migration ordering', () => { + const MIGRATION_FILE_PATTERN = /^\d{4}-.*\.sql$/; + const migrationsDir = path.resolve(__dirname, '../../migrations'); + + const SYSTEM_TABLES = new Set([ + 'pg_policies', + 'pg_indexes', + 'pg_class', + 'pg_namespace', + 'pg_trigger', + 'pg_proc', + 'pg_constraint', + 'pg_attribute', + 'pg_type', + 'pg_tables', + 'information_schema', + ]); + + function collectMatches(sql: string, pattern: RegExp): string[] { + const out: string[] = []; + for (const match of sql.matchAll(pattern)) { + if (match[1]) out.push(match[1].toLowerCase()); + } + return out; + } + + function extractCreatedTables(sql: string): string[] { + return collectMatches( + sql, + /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:"?public"?\.)?"?([a-zA-Z_][a-zA-Z0-9_]*)"?/gi, + ); + } + + function extractReferencedTables(sql: string): string[] { + const stripped = sql + .replace(/--[^\n]*/g, '') + .replace(/\/\*[\s\S]*?\*\//g, ''); + // Only patterns that hard-fail when the table is missing. Tolerant + // forms like `DROP TABLE IF EXISTS` or `ALTER TABLE IF EXISTS` are + // intentionally excluded — they're a no-op against an absent table. + const patterns = [ + /\bREFERENCES\s+(?:"?public"?\.)?"?([a-zA-Z_][a-zA-Z0-9_]*)"?/gi, + /\bALTER\s+TABLE\s+(?!IF\s+EXISTS\b)(?:ONLY\s+)?(?:"?public"?\.)?"?([a-zA-Z_][a-zA-Z0-9_]*)"?/gi, + /\bCREATE\s+POLICY\s+[^;]*?\bON\s+(?:"?public"?\.)?"?([a-zA-Z_][a-zA-Z0-9_]*)"?/gi, + /\bCREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?[^;]*?\bON\s+(?:"?public"?\.)?"?([a-zA-Z_][a-zA-Z0-9_]*)"?/gi, + /\bCREATE\s+TRIGGER\s+[^;]*?\bON\s+(?:"?public"?\.)?"?([a-zA-Z_][a-zA-Z0-9_]*)"?/gi, + ]; + const refs: string[] = []; + for (const pattern of patterns) refs.push(...collectMatches(stripped, pattern)); + return refs; + } + + it('every referenced table is created in the same file or an earlier one', async () => { + const files = (await readdir(migrationsDir)) + .filter((name) => MIGRATION_FILE_PATTERN.test(name)) + .sort((a, b) => a.localeCompare(b)); + + expect(files.length).toBeGreaterThan(0); + + const migrations = await Promise.all(files.map(async (file) => ({ + file, + sql: await readFile(path.join(migrationsDir, file), 'utf8'), + }))); + + const created = new Set(); + const violations: string[] = []; + + for (const { file, sql } of migrations) { + // Add tables created in this file BEFORE checking references so a + // file that creates a table and immediately alters or self-references + // it passes. + for (const t of extractCreatedTables(sql)) created.add(t); + for (const ref of extractReferencedTables(sql)) { + if (SYSTEM_TABLES.has(ref)) continue; + if (created.has(ref)) continue; + violations.push(`${file} references "${ref}" before it is created`); + } + } + + expect(violations).toEqual([]); + }); +}); diff --git a/apps/api/src/jobs/reportScheduleWorker.test.ts b/apps/api/src/jobs/reportScheduleWorker.test.ts index b7cd47be33..695208d1e2 100644 --- a/apps/api/src/jobs/reportScheduleWorker.test.ts +++ b/apps/api/src/jobs/reportScheduleWorker.test.ts @@ -87,11 +87,17 @@ vi.mock('./workerObservability', () => ({ attachWorkerObservability: vi.fn(), })); +const captureExceptionMock = vi.fn(); +vi.mock('../services/sentry', () => ({ + captureException: (...args: unknown[]) => captureExceptionMock(...(args as [])), +})); + import { lastOccurrenceKey, isDue, wallClockIn, findDueReports, + processCheckSchedules, processRunScheduledReport, } from './reportScheduleWorker'; @@ -511,3 +517,85 @@ describe('processRunScheduledReport', () => { expect(mail.attachments).toHaveLength(0); }); }); + +// ─── Failure handling ──────────────────────────────────────────────────────── + +describe('scheduled report failure handling', () => { + const report = { + id: REPORT_ID, + orgId: ORG_ID, + name: 'Nightly inventory', + type: 'device_inventory', + format: 'csv', + schedule: 'daily', + config: { schedule: { time: '09:00' }, emailRecipients: ['ops@example.com'] }, + lastGeneratedAt: null, + }; + + const arrangeFailingRun = () => { + selectMock.mockReturnValueOnce(selectChain([report])); + insertMock.mockReturnValueOnce(insertChain([{ id: RUN_ID }])); + updateMock.mockReturnValueOnce(updateChain()).mockReturnValueOnce(updateChain()); + generateReportMock.mockRejectedValueOnce(new Error('boom')); + }; + + it('tells recipients when the last attempt fails, without leaking the raw error', async () => { + arrangeFailingRun(); + + await expect( + processRunScheduledReport( + { type: 'run-scheduled-report', reportId: REPORT_ID, occurrenceKey: 202607010900 }, + { finalAttempt: true }, + ), + ).rejects.toThrow('boom'); + + expect(sendEmailMock).toHaveBeenCalledTimes(1); + const mail = sendEmailMock.mock.calls[0]![0] as { to: string[]; subject: string; html: string }; + expect(mail.to).toEqual(['ops@example.com']); + expect(mail.subject).toContain('Nightly inventory'); + // The run row keeps the raw message for operators; the customer-facing + // email must not carry it (it can hold Zod/PG internals). + expect(mail.html).not.toContain('boom'); + }); + + it('stays quiet on a non-final attempt so a retry can still succeed', async () => { + arrangeFailingRun(); + + await expect( + processRunScheduledReport( + { type: 'run-scheduled-report', reportId: REPORT_ID, occurrenceKey: 202607010900 }, + { finalAttempt: false }, + ), + ).rejects.toThrow('boom'); + + expect(sendEmailMock).not.toHaveBeenCalled(); + }); + + it('keeps running the rest of the due batch when one inline report throws', async () => { + const second = { ...report, id: '44444444-4444-4444-4444-444444444444', name: 'Second' }; + // findDueReports: both reports due (never generated). + selectMock.mockReturnValueOnce( + selectChain([ + { id: report.id, schedule: 'daily', lastGeneratedAt: null, config: report.config, orgSettings: null, partnerTimezone: null, partnerSettings: null }, + { id: second.id, schedule: 'daily', lastGeneratedAt: null, config: second.config, orgSettings: null, partnerTimezone: null, partnerSettings: null }, + ]), + ); + // report 1: load -> throws during generation + selectMock.mockReturnValueOnce(selectChain([report])); + insertMock.mockReturnValueOnce(insertChain([{ id: RUN_ID }])); + updateMock.mockReturnValueOnce(updateChain()).mockReturnValueOnce(updateChain()); + generateReportMock.mockRejectedValueOnce(new Error('boom')); + // report 2: load -> succeeds + selectMock.mockReturnValueOnce(selectChain([second])); + selectMock.mockReturnValueOnce(selectChain([])); // timezone lookup + insertMock.mockReturnValueOnce(insertChain([{ id: RUN_ID }])); + updateMock.mockReturnValueOnce(updateChain()).mockReturnValueOnce(updateChain()); + generateReportMock.mockResolvedValueOnce({ rows: [], rowCount: 0 }); + + await expect(processCheckSchedules()).resolves.toBeUndefined(); + + // The throwing report must not starve its neighbour. + expect(generateReportMock).toHaveBeenCalledTimes(2); + expect(captureExceptionMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/api/src/jobs/reportScheduleWorker.ts b/apps/api/src/jobs/reportScheduleWorker.ts index 9e7326e9e4..5e81f0d683 100644 --- a/apps/api/src/jobs/reportScheduleWorker.ts +++ b/apps/api/src/jobs/reportScheduleWorker.ts @@ -41,6 +41,7 @@ import { import { buildReportPdf, type ReportBranding } from '@breeze/shared/reportPdf'; import type { PostureSummary, ExecutiveSummary } from '@breeze/shared'; import { loadReportBrandingForOrg } from '../services/reportBranding'; +import { captureException } from '../services/sentry'; import { attachWorkerObservability } from './workerObservability'; // Re-exported so the occurrence-math tests colocated with this worker keep @@ -57,6 +58,8 @@ const runWithSystemDbAccess = async (fn: () => Promise): Promise => { const REPORT_SCHEDULE_QUEUE = 'report-schedules'; const CHECK_INTERVAL_MS = 5 * 60 * 1000; +/** Attempts per `run-scheduled-report` job before the occurrence is given up on. */ +const RUN_JOB_ATTEMPTS = 3; // Attachments above this size are dropped in favour of the in-app link. const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024; @@ -189,6 +192,46 @@ function trendLineOf(result: ReportResult): string | null { return null; } +/** + * Tells recipients their scheduled report did not arrive. Without this a failed + * occurrence is silent end-to-end: the job is not retried again after its final + * attempt, `lastGeneratedAt` has already moved past the occurrence, and the only + * record is a `failed` report_runs row nobody is watching. + * + * Deliberately omits the underlying error: it reaches customer inboxes, and the + * raw message can carry Zod issue arrays or PG schema details. Operators get the + * real message on the run row and in Sentry. + */ +async function emailReportFailure(opts: { + reportName: string; + recipients: string[]; +}): Promise { + const email = getEmailService(); + if (!email) { + console.warn('[ReportScheduleWorker] Email service not configured; cannot notify failure for', opts.reportName); + return; + } + const base = (process.env.DASHBOARD_URL || process.env.PUBLIC_APP_URL || 'http://localhost:4321').replace(/\/$/, ''); + const html = renderLayout({ + title: 'Scheduled report failed', + preheader: `${opts.reportName} could not be generated`, + heading: 'Scheduled report failed', + body: [ + renderParagraph( + `We couldn't generate ${escapeHtml(opts.reportName)} for its scheduled run. No report was produced.`, + ), + renderParagraph('Your team can run it manually, or wait for the next scheduled occurrence.'), + renderButton('View reports', `${base}/reports`), + ].join(''), + }); + + await email.sendEmail({ + to: opts.recipients, + subject: `Scheduled report failed: ${opts.reportName}`, + html, + }); +} + async function emailReportRun(opts: { reportName: string; reportType: string; @@ -284,7 +327,10 @@ async function emailReportRun(opts: { }); } -export async function processRunScheduledReport(data: RunScheduledReportJobData): Promise { +export async function processRunScheduledReport( + data: RunScheduledReportJobData, + opts: { finalAttempt?: boolean } = {}, +): Promise { const [report] = await db .select() .from(reports) @@ -372,18 +418,42 @@ export async function processRunScheduledReport(data: RunScheduledReportJobData) errorMessage: err instanceof Error ? err.message : 'Failed to generate report', }) .where(eq(reportRuns.id, run.id)); + + // Only once the job is out of retries: an earlier attempt may still succeed, + // and this occurrence will not be re-enqueued after the last one fails. + if (opts.finalAttempt) { + const recipients = recipientsOf(config); + if (recipients.length > 0) { + try { + await emailReportFailure({ reportName: report.name, recipients }); + } catch (notifyErr) { + console.error(`[ReportScheduleWorker] Failure notice undeliverable for report ${report.id}:`, notifyErr); + } + } + } throw err; } } -async function processCheckSchedules(): Promise { +export async function processCheckSchedules(): Promise { const due = await findDueReports(new Date()); if (due.length === 0) return; console.log(`[ReportScheduleWorker] ${due.length} scheduled report(s) due`); for (const item of due) { if (!isRedisAvailable()) { - await processRunScheduledReport({ type: 'run-scheduled-report', reportId: item.id, occurrenceKey: item.occurrenceKey }); + // Inline mode has no queue to absorb a throw, so one failing report would + // abort the loop and silently starve every remaining org's reports. + // There is no retry here either, hence finalAttempt. + try { + await processRunScheduledReport( + { type: 'run-scheduled-report', reportId: item.id, occurrenceKey: item.occurrenceKey }, + { finalAttempt: true }, + ); + } catch (err) { + console.error(`[ReportScheduleWorker] Inline run failed for report ${item.id}:`, err); + captureException(err); + } continue; } // Occurrence-keyed jobId dedupes double-enqueue across overlapping checks. @@ -392,6 +462,12 @@ async function processCheckSchedules(): Promise { { type: 'run-scheduled-report', reportId: item.id, occurrenceKey: item.occurrenceKey }, { jobId: `report-sched-run-${item.id}-${item.occurrenceKey}`, + // A transient blip must not cost the whole occurrence: lastGeneratedAt is + // stamped before generation (deliberately — it stops a failed report from + // being re-found due every check interval), so once these attempts are + // spent the occurrence is gone until the next one. + attempts: RUN_JOB_ATTEMPTS, + backoff: { type: 'exponential', delay: 30_000 }, removeOnComplete: { count: 500 }, removeOnFail: { count: 500 }, }, @@ -436,8 +512,14 @@ export async function initializeReportScheduleWorker(): Promise { switch (job.data.type) { case 'check-schedules': return processCheckSchedules(); - case 'run-scheduled-report': - return processRunScheduledReport(job.data); + case 'run-scheduled-report': { + // attemptsMade counts attempts already finished, so on the last one + // it is attempts-1 and this run is the occurrence's final chance. + const allowed = job.opts.attempts ?? 1; + return processRunScheduledReport(job.data, { + finalAttempt: job.attemptsMade + 1 >= allowed, + }); + } default: throw new Error(`Unknown report schedule job type: ${(job.data as { type: string }).type}`); } diff --git a/apps/api/src/lib/validation.imports.test.ts b/apps/api/src/lib/validation.imports.test.ts deleted file mode 100644 index b22e1276c1..0000000000 --- a/apps/api/src/lib/validation.imports.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { readFileSync, readdirSync, statSync } from 'node:fs'; -import { join, relative } from 'node:path'; - -/** - * Guard for issue #2201: application code must import `zValidator` from - * `src/lib/validation` (which installs the readable string-first 400 hook), - * never from `@hono/zod-validator` directly — the package default returns a - * raw serialized ZodError that is unreadable for every non-web client. - * - * Scope: all of `src/` (not just routes/) so a future validator-using - * middleware or shared helper can't reintroduce raw ZodError 400s. Only the - * wrapper itself and test files are exempt. `vi.mock('@hono/zod-validator', - * ...)` calls are fine regardless (they intercept the wrapper's own base - * import), and the regex also catches `export ... from` re-exports so the - * import can't be laundered through a helper. - */ - -const SRC_DIR = join(__dirname, '..'); -const WRAPPER = join(__dirname, 'validation.ts'); - -function walk(dir: string): string[] { - const out: string[] = []; - for (const entry of readdirSync(dir)) { - const full = join(dir, entry); - if (statSync(full).isDirectory()) out.push(...walk(full)); - else if (entry.endsWith('.ts')) out.push(full); - } - return out; -} - -describe('zValidator import guard (#2201)', () => { - it('no app file imports @hono/zod-validator directly', () => { - const offenders = walk(SRC_DIR) - .filter((file) => file !== WRAPPER && !file.endsWith('.test.ts')) - .filter((file) => - /(?:import|export)\s[^;]*from\s+['"]@hono\/zod-validator['"]/.test( - readFileSync(file, 'utf8') - ) - ) - .map((file) => relative(SRC_DIR, file)); - expect( - offenders, - `Import zValidator from src/lib/validation instead of @hono/zod-validator:\n${offenders.join('\n')}` - ).toEqual([]); - }); -}); diff --git a/apps/api/src/routes/backup/constants.ts b/apps/api/src/routes/backup/constants.ts new file mode 100644 index 0000000000..16e292eda2 --- /dev/null +++ b/apps/api/src/routes/backup/constants.ts @@ -0,0 +1 @@ +export const BACKUP_LOW_READINESS_THRESHOLD = 70; diff --git a/apps/api/src/routes/backup/verificationService.ts b/apps/api/src/routes/backup/verificationService.ts index 061279d79f..dddb82705a 100644 --- a/apps/api/src/routes/backup/verificationService.ts +++ b/apps/api/src/routes/backup/verificationService.ts @@ -9,6 +9,7 @@ import { recordBackupDispatchFailure } from '../../services/backupMetrics'; import { resolveBackupProviderConfig, resolveBackupDestinationError, type BackupProviderConfig } from '../../services/backupProviderConfig'; import { queueCommandForExecution } from '../../services/commandQueue'; import { publishEvent } from '../../services/eventBus'; +import { BACKUP_LOW_READINESS_THRESHOLD } from './constants'; import { addBackupVerification, backupConfigs as backupConfigsMemory, @@ -39,7 +40,7 @@ const runWithSystemDbAccess = async (fn: () => Promise): Promise => { const DAY_MS = 24 * 60 * 60 * 1000; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -export const BACKUP_LOW_READINESS_THRESHOLD = 70; +export { BACKUP_LOW_READINESS_THRESHOLD } from './constants'; export const BACKUP_READINESS_RECOVERY_THRESHOLD = 75; export const BACKUP_HIGH_READINESS_THRESHOLD = 85; export const BACKUP_RECENT_COVERAGE_DAYS = 30; diff --git a/apps/api/src/routes/connectedApps.disabled.test.ts b/apps/api/src/routes/connectedApps.disabled.test.ts new file mode 100644 index 0000000000..3b3a00de24 --- /dev/null +++ b/apps/api/src/routes/connectedApps.disabled.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Hono } from 'hono'; + +vi.mock('../config/env', () => ({ MCP_OAUTH_ENABLED: false })); +vi.mock('../middleware/auth', () => ({ authMiddleware: vi.fn() })); +vi.mock('../db', () => ({ db: {} })); +vi.mock('../db/schema', () => ({ + oauthClients: {}, + oauthClientPartnerGrants: {}, +})); +vi.mock('../oauth/revocationService', () => ({ revokeClientFamilies: vi.fn() })); +vi.mock('../oauth/log', () => ({ + ERROR_IDS: {}, + logOauthError: vi.fn(), +})); + +import { connectedAppsRoutes } from './connectedApps'; + +describe('connectedAppsRoutes when MCP OAuth is disabled', () => { + it('does not mount routes', async () => { + const app = new Hono().route('/api/v1/settings/connected-apps', connectedAppsRoutes); + const res = await app.request('/api/v1/settings/connected-apps'); + + expect(res.status).toBe(404); + }); +}); diff --git a/apps/api/src/routes/connectedApps.test.ts b/apps/api/src/routes/connectedApps.test.ts index 93cbf53a33..28c8457362 100644 --- a/apps/api/src/routes/connectedApps.test.ts +++ b/apps/api/src/routes/connectedApps.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Hono } from 'hono'; import { HTTPException } from 'hono/http-exception'; @@ -44,10 +44,28 @@ vi.mock('../db', () => ({ withSystemDbAccessContext: mocks.withSystemDbAccessContext, })); +vi.mock('../db/schema', () => ({ + oauthClients: { + id: { name: 'id' }, + metadata: { name: 'metadata' }, + createdAt: { name: 'created_at' }, + lastUsedAt: { name: 'last_used_at' }, + disabledAt: { name: 'disabled_at' }, + }, + oauthClientPartnerGrants: { + clientId: { name: 'client_id' }, + partnerId: { name: 'partner_id' }, + }, +})); + vi.mock('../oauth/revocationService', () => ({ revokeClientFamilies: mocks.revokeClientFamilies, })); +vi.mock('../config/env', () => ({ MCP_OAUTH_ENABLED: true })); + +import { connectedAppsRoutes } from './connectedApps'; + function resetAuth(partnerId: string | null = 'current-partner') { mocks.auth = { user: { id: 'u1', email: 'user@example.com', name: 'User One' }, @@ -87,10 +105,7 @@ function queueDelete() { return { where }; } -async function loadApp(enabled = true) { - process.env.MCP_OAUTH_ENABLED = enabled ? 'true' : 'false'; - vi.resetModules(); - const { connectedAppsRoutes } = await import('./connectedApps'); +function loadApp() { const app = new Hono().route('/api/v1/settings/connected-apps', connectedAppsRoutes); app.onError((err, c) => { if (err instanceof HTTPException) { @@ -104,16 +119,17 @@ async function loadApp(enabled = true) { describe('connectedAppsRoutes', () => { beforeEach(() => { vi.clearAllMocks(); + mocks.select.mockReset(); + mocks.update.mockReset(); + mocks.delete.mockReset(); + mocks.revokeClientFamilies.mockReset(); + mocks.revokeClientFamilies.mockResolvedValue({ grants: 0, refreshTokens: 0 }); resetAuth(); }); - afterEach(() => { - delete process.env.MCP_OAUTH_ENABLED; - }); - it('returns 403 when auth has no partner scope', async () => { resetAuth(null); - const res = await (await loadApp()).request('/api/v1/settings/connected-apps'); + const res = await loadApp().request('/api/v1/settings/connected-apps'); expect(res.status).toBe(403); expect(await res.json()).toEqual({ message: 'partner scope required' }); expect(mocks.select).not.toHaveBeenCalled(); @@ -136,7 +152,7 @@ describe('connectedAppsRoutes', () => { disabledAt: null, }, ]); - const res = await (await loadApp()).request('/api/v1/settings/connected-apps'); + const res = await loadApp().request('/api/v1/settings/connected-apps'); expect(res.status).toBe(200); expect(await res.json()).toEqual({ clients: [ @@ -158,7 +174,7 @@ describe('connectedAppsRoutes', () => { it('returns an empty client list', async () => { queueSelect([]); - const res = await (await loadApp()).request('/api/v1/settings/connected-apps'); + const res = await loadApp().request('/api/v1/settings/connected-apps'); expect(res.status).toBe(200); expect(await res.json()).toEqual({ clients: [] }); }); @@ -181,7 +197,7 @@ describe('connectedAppsRoutes', () => { }, ]); - const res = await (await loadApp()).request('/api/v1/settings/connected-apps'); + const res = await loadApp().request('/api/v1/settings/connected-apps'); expect(res.status).toBe(200); expect(await res.json()).toEqual({ @@ -198,7 +214,7 @@ describe('connectedAppsRoutes', () => { it('filters GET clients by partner id (via the join table)', async () => { queueSelect([]); - await (await loadApp()).request('/api/v1/settings/connected-apps'); + await loadApp().request('/api/v1/settings/connected-apps'); // After the H2 proper fix, the partner filter is on // `oauth_client_partner_grants.partner_id`, not `oauth_clients.partner_id`. expect(mocks.eq).toHaveBeenCalledWith(expect.objectContaining({ name: 'partner_id' }), 'current-partner'); @@ -206,7 +222,7 @@ describe('connectedAppsRoutes', () => { it('DELETE returns 403 when auth has no partner scope and skips DB calls', async () => { resetAuth(null); - const res = await (await loadApp()).request('/api/v1/settings/connected-apps/client-1', { + const res = await loadApp().request('/api/v1/settings/connected-apps/client-1', { method: 'DELETE', }); expect(res.status).toBe(403); @@ -221,7 +237,7 @@ describe('connectedAppsRoutes', () => { // either the client doesn't exist or another partner installed it but // this one didn't. Either way, return 404 without touching anything. queueSelect([], 'limit'); - const res = await (await loadApp()).request('/api/v1/settings/connected-apps/client-missing', { + const res = await loadApp().request('/api/v1/settings/connected-apps/client-missing', { method: 'DELETE', }); expect(res.status).toBe(404); @@ -231,7 +247,7 @@ describe('connectedAppsRoutes', () => { it('returns 404 when deleting a client where another partner has the join row', async () => { queueSelect([], 'limit'); - const res = await (await loadApp()).request('/api/v1/settings/connected-apps/other-client', { + const res = await loadApp().request('/api/v1/settings/connected-apps/other-client', { method: 'DELETE', }); expect(res.status).toBe(404); @@ -245,7 +261,7 @@ describe('connectedAppsRoutes', () => { // client are left untouched (MCP-OAUTH-07). queueSelect([{ clientId: 'client-1', partnerId: 'current-partner' }], 'limit'); - const res = await (await loadApp()).request('/api/v1/settings/connected-apps/client-1', { + const res = await loadApp().request('/api/v1/settings/connected-apps/client-1', { method: 'DELETE', }); @@ -260,7 +276,7 @@ describe('connectedAppsRoutes', () => { it('does not call the revocation service when the join row is missing (404)', async () => { queueSelect([], 'limit'); - const res = await (await loadApp()).request('/api/v1/settings/connected-apps/client-1', { + const res = await loadApp().request('/api/v1/settings/connected-apps/client-1', { method: 'DELETE', }); expect(res.status).toBe(404); @@ -275,16 +291,11 @@ describe('connectedAppsRoutes', () => { mocks.revokeClientFamilies.mockRejectedValueOnce(new Error('redis down')); queueSelect([{ clientId: 'client-1', partnerId: 'current-partner' }], 'limit'); - const res = await (await loadApp()).request('/api/v1/settings/connected-apps/client-1', { + const res = await loadApp().request('/api/v1/settings/connected-apps/client-1', { method: 'DELETE', }); expect(res.status).toBe(503); errorSpy.mockRestore(); }); - - it('does not mount routes when MCP_OAUTH_ENABLED is false', async () => { - const res = await (await loadApp(false)).request('/api/v1/settings/connected-apps'); - expect(res.status).toBe(404); - }); }); diff --git a/apps/api/src/routes/mcpServer.bearer.test.ts b/apps/api/src/routes/mcpServer.bearer.test.ts index f96d327674..75947607a5 100644 --- a/apps/api/src/routes/mcpServer.bearer.test.ts +++ b/apps/api/src/routes/mcpServer.bearer.test.ts @@ -7,6 +7,18 @@ const mocks = vi.hoisted(() => ({ executeTool: vi.fn(), getToolDefinitions: vi.fn(() => []), getToolTier: vi.fn((_: string): number | undefined => undefined), + writeAuditEvent: vi.fn(), + rateLimiter: vi.fn(), +})); + +const envState = vi.hoisted(() => ({ + oauthEnabled: false, + oauthIssuer: 'https://us.example.com', +})); + +vi.mock('../config/env', () => ({ + get MCP_OAUTH_ENABLED() { return envState.oauthEnabled; }, + get OAUTH_ISSUER() { return envState.oauthIssuer; }, })); const setApiKeyContext = (c: any) => { @@ -62,8 +74,12 @@ vi.mock('../db', () => { { resource: 'automations', action: 'read' }, ]; const makeWhere = (rows: unknown[]) => { - const thenable = Promise.resolve(rows) as Promise & { limit: (n: number) => Promise }; + const thenable = Promise.resolve(rows) as Promise & { + limit: (n: number) => Promise; + orderBy: () => { limit: (n: number) => Promise }; + }; thenable.limit = async () => rows; + thenable.orderBy = () => ({ limit: async () => rows }); return thenable; }; // buildPerms resolves role→permissions via `.innerJoin(...).where(...)`, so the @@ -118,10 +134,13 @@ vi.mock('../services/aiGuardrails', () => ({ checkToolPermission: async () => null, checkToolRateLimit: async () => null, })); -vi.mock('../services/auditEvents', () => ({ writeAuditEvent: vi.fn(), requestLikeFromSnapshot: vi.fn() })); +vi.mock('../services/auditEvents', () => ({ + writeAuditEvent: mocks.writeAuditEvent, + requestLikeFromSnapshot: vi.fn(), +})); vi.mock('../services/redis', () => ({ getRedis: () => null })); vi.mock('../services/rate-limit', () => ({ - rateLimiter: vi.fn(async () => ({ allowed: true, resetAt: new Date(Date.now() + 60000) })), + rateLimiter: (...args: any[]) => mocks.rateLimiter(...args), })); vi.mock('../modules/mcpInvites', () => ({ initMcpBootstrap: () => ({ unauthTools: [], authTools: [] }) })); @@ -137,16 +156,17 @@ vi.mock('./mcpExecutionOrg', () => ({ McpExecutionOrgError: class McpExecutionOrgError extends Error {}, })); +import { mcpServerRoutes } from './mcpServer'; + const ENV = ['MCP_OAUTH_ENABLED', 'IS_HOSTED', 'OAUTH_ISSUER'] as const; const clearEnv = () => { for (const key of ENV) delete process.env[key]; }; -async function appWithMcpRoutes() { - const mod = await import('./mcpServer'); - return { app: new Hono().route('/mcp', mod.mcpServerRoutes), mod }; +function appWithMcpRoutes() { + return new Hono().route('/mcp', mcpServerRoutes); } async function postToolsList(headers: Record = {}) { - const { app } = await appWithMcpRoutes(); + const app = appWithMcpRoutes(); return app.request('/mcp/message', { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, @@ -155,7 +175,7 @@ async function postToolsList(headers: Record = {}) { } async function postToolsCall(headers: Record = {}) { - const { app } = await appWithMcpRoutes(); + const app = appWithMcpRoutes(); return app.request('/mcp/message', { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, @@ -171,25 +191,30 @@ async function postToolsCall(headers: Record = {}) { describe('mcpServer bearer auth routing', () => { beforeEach(() => { clearEnv(); - vi.resetModules(); - vi.clearAllMocks(); - mocks.bearerTokenAuthMiddleware.mockImplementation(async (c: any, next: any) => { + envState.oauthEnabled = false; + envState.oauthIssuer = 'https://us.example.com'; + mocks.bearerTokenAuthMiddleware.mockReset().mockImplementation(async (c: any, next: any) => { setApiKeyContext(c); return next(); }); - mocks.apiKeyAuthMiddleware.mockImplementation(async (c: any, next: any) => { + mocks.apiKeyAuthMiddleware.mockReset().mockImplementation(async (c: any, next: any) => { setApiKeyContext(c); return next(); }); - mocks.getToolDefinitions.mockReturnValue([]); - mocks.getToolTier.mockReturnValue(undefined); - mocks.executeTool.mockResolvedValue('{}'); + mocks.getToolDefinitions.mockReset().mockReturnValue([]); + mocks.getToolTier.mockReset().mockReturnValue(undefined); + mocks.executeTool.mockReset().mockResolvedValue('{}'); + mocks.writeAuditEvent.mockReset(); + mocks.rateLimiter.mockReset().mockResolvedValue({ + allowed: true, + resetAt: new Date(Date.now() + 60_000), + }); }); afterEach(() => clearEnv()); it('routes Bearer auth through bearer middleware when OAuth is enabled', async () => { - process.env.MCP_OAUTH_ENABLED = 'true'; + envState.oauthEnabled = true; const res = await postToolsList({ Authorization: 'Bearer foo' }); expect(res.status).toBe(200); expect(mocks.bearerTokenAuthMiddleware).toHaveBeenCalledTimes(1); @@ -197,7 +222,7 @@ describe('mcpServer bearer auth routing', () => { }); it('routes X-API-Key auth through api key middleware when no Bearer header exists', async () => { - process.env.MCP_OAUTH_ENABLED = 'true'; + envState.oauthEnabled = true; const res = await postToolsList({ 'X-API-Key': 'brz_abc' }); expect(res.status).toBe(200); expect(mocks.apiKeyAuthMiddleware).toHaveBeenCalledTimes(1); @@ -205,7 +230,7 @@ describe('mcpServer bearer auth routing', () => { }); it('does not route Bearer auth through bearer middleware when OAuth is disabled', async () => { - process.env.MCP_OAUTH_ENABLED = 'false'; + envState.oauthEnabled = false; const res = await postToolsList({ Authorization: 'Bearer foo' }); expect(res.status).toBe(401); expect(mocks.bearerTokenAuthMiddleware).not.toHaveBeenCalled(); @@ -213,7 +238,7 @@ describe('mcpServer bearer auth routing', () => { }); it('prefers Bearer auth when both Bearer and X-API-Key headers exist', async () => { - process.env.MCP_OAUTH_ENABLED = 'true'; + envState.oauthEnabled = true; const res = await postToolsList({ Authorization: 'Bearer foo', 'X-API-Key': 'brz_abc' }); expect(res.status).toBe(200); expect(mocks.bearerTokenAuthMiddleware).toHaveBeenCalledTimes(1); @@ -233,7 +258,7 @@ describe('mcpServer bearer auth routing', () => { // NOT null. The important invariant is the SHAPE change — it's no longer // null, and downstream `orgCondition()` now returns an IN filter instead // of undefined. - process.env.MCP_OAUTH_ENABLED = 'true'; + envState.oauthEnabled = true; mocks.bearerTokenAuthMiddleware.mockImplementation(async (c: any, next: any) => { c.set('apiKey', { id: 'oauth:jti-1', @@ -271,7 +296,7 @@ describe('mcpServer bearer auth routing', () => { // db shim returns an org-user row with no siteIds, i.e. an unrestricted // creator — so canAccessSite must be present AND permit any site (the gate // is a no-op for unrestricted callers, never a hard deny). - process.env.MCP_OAUTH_ENABLED = 'true'; + envState.oauthEnabled = true; mocks.getToolTier.mockReturnValue(1); mocks.executeTool.mockResolvedValue('ok'); @@ -294,7 +319,7 @@ describe('mcpServer bearer auth routing', () => { // cannot access must NOT have that org used as the audit_logs attribution. // Proves the wiring resolveMcpExecutionOrgId -> executionOrgId -> // writeMcpToolAuditEvent for the tier-1 sink reachable with mcp:read. - process.env.MCP_OAUTH_ENABLED = 'true'; + envState.oauthEnabled = true; mocks.bearerTokenAuthMiddleware.mockImplementation(async (c: any, next: any) => { c.set('apiKey', { id: 'oauth:jti-1', orgId: null, partnerId: 'partner-1', @@ -307,7 +332,7 @@ describe('mcpServer bearer auth routing', () => { mocks.executeTool.mockResolvedValue('ok'); const VICTIM_ORG = '99999999-9999-4999-8999-999999999999'; - const { app } = await appWithMcpRoutes(); + const app = appWithMcpRoutes(); const { writeAuditEvent } = await import('../services/auditEvents'); const res = await app.request('/mcp/message', { @@ -335,9 +360,9 @@ describe('mcpServer bearer auth routing', () => { }); it('no auth headers → 401 even with MCP_OAUTH_ENABLED (carve-out deleted in Phase 3)', async () => { - process.env.MCP_OAUTH_ENABLED = 'true'; + envState.oauthEnabled = true; delete process.env.IS_HOSTED; - const { app } = await appWithMcpRoutes(); + const app = appWithMcpRoutes(); const res = await app.request('/mcp/message', { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/apps/api/src/routes/mcpServer.bootstrapCarveout.test.ts b/apps/api/src/routes/mcpServer.bootstrapCarveout.test.ts new file mode 100644 index 0000000000..598fccd8cb --- /dev/null +++ b/apps/api/src/routes/mcpServer.bootstrapCarveout.test.ts @@ -0,0 +1,256 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +const state = vi.hoisted(() => ({ + apiKey: null as Record | null, + db: {} as Record, + bootstrap: { unauthTools: [], authTools: [] } as { + unauthTools: any[]; + authTools: any[]; + }, + oauthEnabled: true, + oauthIssuer: 'https://us.example.com', +})); + +const mocks = vi.hoisted(() => ({ + executeTool: vi.fn(), + getToolDefinitions: vi.fn(), + getToolTier: vi.fn(), + bootstrapHandler: vi.fn(), + ledgerBegin: vi.fn(), + ledgerComplete: vi.fn(), + writeAuditEvent: vi.fn(), + requestLikeFromSnapshot: vi.fn(), + rateLimiter: vi.fn(), + enforceIpAllowlist: vi.fn(), +})); + +vi.mock('../config/env', () => ({ + get MCP_OAUTH_ENABLED() { return state.oauthEnabled; }, + get OAUTH_ISSUER() { return state.oauthIssuer; }, +})); + +vi.mock('../middleware/apiKeyAuth', () => ({ + apiKeyAuthMiddleware: async (c: any, next: any) => { + if (!state.apiKey) throw new Error('should not be called without an X-API-Key header'); + c.set('apiKey', state.apiKey); + c.set('apiKeyOrgId', state.apiKey.orgId); + await next(); + }, + requireApiKeyScope: () => async (_c: any, next: any) => next(), +})); + +vi.mock('../middleware/bearerTokenAuth', () => ({ + bearerTokenAuthMiddleware: async () => { + throw new Error('should not be called without a Bearer header'); + }, + resolvePartnerAccessibleOrgIds: async () => ['org-1'], +})); + +vi.mock('../db', () => ({ + db: new Proxy({}, { + get: (_target, property) => state.db[property as string], + }), + withDbAccessContext: vi.fn((_ctx: any, fn: any) => fn()), + withSystemDbAccessContext: vi.fn((fn: any) => fn()), + runOutsideDbContext: vi.fn((fn: () => any) => fn()), +})); + +vi.mock('../db/schema', () => ({ + devices: {}, alerts: {}, scripts: {}, automations: {}, + organizations: { id: 'organizations.id', partnerId: 'organizations.partnerId' }, + partners: { id: 'partners.id', billingEmail: 'partners.billingEmail' }, +})); + +// SR2-15 (Task 3, scope re-clamp): buildAuthFromApiKey's org branch now calls +// getUserPermissions via authorizeHumanApiKeyCreator to re-validate the API +// key's stored scopes (['ai:read', 'ai:execute'] in the authed test below) +// against the creator's live permissions before an AuthContext is ever built. +// checkToolPermission is stubbed to always allow above, so this is the ONLY +// getUserPermissions call in this file's flow — it must hold the full +// devices/alerts/scripts/automations read + devices/scripts execute bundle +// both scopes require, or the carve-out test would be denied by the re-clamp +// before ever reaching the tools/list + tools/call dispatch under test. +vi.mock('../services/permissions', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getUserPermissions: vi.fn(async () => ({ + permissions: [ + { resource: 'devices', action: 'read' }, + { resource: 'devices', action: 'execute' }, + { resource: 'alerts', action: 'read' }, + { resource: 'scripts', action: 'read' }, + { resource: 'scripts', action: 'execute' }, + { resource: 'automations', action: 'read' }, + ], + partnerId: null, + orgId: 'org-1', + roleId: 'role-1', + scope: 'organization' as const, + })), + }; +}); + +vi.mock('../services/aiTools', () => ({ + getToolDefinitions: (...args: any[]) => mocks.getToolDefinitions(...args), + executeTool: (...args: any[]) => mocks.executeTool(...args), + getToolTier: (...args: any[]) => mocks.getToolTier(...args), +})); + +vi.mock('../services/aiGuardrails', () => ({ + checkGuardrails: () => ({ allowed: true, tier: 1 }), + checkToolPermission: async () => null, + checkToolRateLimit: async () => null, + checkPermissionRequirement: async () => null, +})); + +vi.mock('../services/auditEvents', () => ({ + writeAuditEvent: mocks.writeAuditEvent, + requestLikeFromSnapshot: mocks.requestLikeFromSnapshot, +})); +vi.mock('../services/redis', () => ({ getRedis: () => null })); +vi.mock('../services/rate-limit', () => ({ + rateLimiter: (...args: any[]) => mocks.rateLimiter(...args), +})); +vi.mock('../services/ipAllowlist', () => ({ + enforceIpAllowlist: (...args: any[]) => mocks.enforceIpAllowlist(...args), + IP_NOT_ALLOWED_BODY: { code: 'ip_not_allowed', error: 'Access denied from this IP address' }, + isBlocked: (decision: { decision: string }) => decision.decision === 'deny', +})); +vi.mock('../services/mcpToolExecutionLedger', () => ({ + beginMcpToolExecutionLedger: (...args: any[]) => mocks.ledgerBegin(...args), + completeMcpToolExecutionLedger: (...args: any[]) => mocks.ledgerComplete(...args), +})); +vi.mock('../modules/mcpInvites', () => ({ + initMcpBootstrap: () => state.bootstrap, +})); +vi.mock('./mcpExecutionOrg', () => ({ + resolveMcpExecutionOrgId: () => 'org-1', + resolveMcpExecutionContext: async () => ({ orgId: 'org-1' }), + McpExecutionOrgError: class McpExecutionOrgError extends Error {}, +})); + +import { __loadMcpBootstrapForTests, mcpServerRoutes } from './mcpServer'; + +beforeEach(async () => { + state.apiKey = null; + state.db = {}; + state.bootstrap = { unauthTools: [], authTools: [] }; + state.oauthEnabled = true; + state.oauthIssuer = 'https://us.example.com'; + mocks.executeTool.mockReset(); + mocks.getToolDefinitions.mockReset().mockReturnValue([]); + mocks.getToolTier.mockReset().mockReturnValue(undefined); + mocks.bootstrapHandler.mockReset(); + mocks.ledgerBegin.mockReset().mockResolvedValue({ + executionId: 'exec-1', + sessionId: 'sess-1', + orgId: 'org-1', + }); + mocks.ledgerComplete.mockReset().mockResolvedValue(undefined); + mocks.writeAuditEvent.mockReset(); + mocks.requestLikeFromSnapshot.mockReset(); + mocks.rateLimiter.mockReset().mockResolvedValue({ + allowed: true, + resetAt: new Date(Date.now() + 60_000), + }); + mocks.enforceIpAllowlist.mockReset().mockResolvedValue({ decision: 'allow' }); + await __loadMcpBootstrapForTests(); +}); + +describe('MCP bootstrap carve-out', () => { + // The route no longer reads IS_HOSTED or starts bootstrap loading at import. + // A static route graph plus an explicit loader keeps these requests out of + // the module cold-start path while exercising the real transport handlers. + it('no auth header → tools/list always returns 401 + WWW-Authenticate', async () => { + const res = await mcpServerRoutes.request('/message', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), + }); + + expect(res.status).toBe(401); + expect(res.headers.get('www-authenticate')).toContain('oauth-protected-resource'); + const body = await res.json(); + expect(body.error?.code).toBe(-32001); + }); + + it('authed key → authTools surface in tools/list AND dispatch to handler', async () => { + state.apiKey = { + id: 'key-authtool', + orgId: 'org-1', + partnerId: 'partner-1', + name: 'test', + keyPrefix: 'brz_test', + scopes: ['ai:read', 'ai:execute'], + rateLimit: 1000, + createdBy: 'user-1', + }; + state.db = { + select: () => ({ + from: () => ({ + where: () => ({ limit: async () => [{ partnerId: 'partner-1', billingEmail: 'admin@acme.com' }] }), + }), + }), + }; + mocks.bootstrapHandler.mockResolvedValue({ + invites_sent: 2, + invite_ids: ['i1', 'i2'], + skipped_duplicates: 0, + }); + state.bootstrap = { + unauthTools: [], + authTools: [{ + definition: { + name: 'send_deployment_invites', + description: 'fake authTool', + inputSchema: z.object({}).passthrough(), + }, + handler: (...args: any[]) => mocks.bootstrapHandler(...args), + }], + }; + await __loadMcpBootstrapForTests(); + + const listRes = await mcpServerRoutes.request('/message', { + method: 'POST', + headers: { 'content-type': 'application/json', 'X-API-Key': 'brz_test' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), + }); + expect(listRes.status).toBe(200); + const listBody = await listRes.json(); + const sendTool = listBody.result.tools.find( + (tool: any) => tool.name === 'send_deployment_invites', + ); + expect(sendTool).toBeDefined(); + expect(sendTool?.inputSchema?.type).toBe('object'); + + const callRes = await mcpServerRoutes.request('/message', { + method: 'POST', + headers: { 'content-type': 'application/json', 'X-API-Key': 'brz_test' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'send_deployment_invites', arguments: { emails: ['a@b.com'] } }, + }), + }); + expect(callRes.status).toBe(200); + const callBody = await callRes.json(); + expect(callBody.error).toBeUndefined(); + expect(mocks.bootstrapHandler).toHaveBeenCalledTimes(1); + const [calledInput, calledCtx] = mocks.bootstrapHandler.mock.calls[0] as unknown as [any, any]; + expect(calledInput).toEqual({ emails: ['a@b.com'] }); + expect(calledCtx.apiKey).toMatchObject({ + id: 'key-authtool', + partnerId: 'partner-1', + defaultOrgId: 'org-1', + partnerAdminEmail: 'admin@acme.com', + }); + expect(JSON.parse(callBody.result.content[0].text)).toEqual({ + invites_sent: 2, + invite_ids: ['i1', 'i2'], + skipped_duplicates: 0, + }); + }); +}); diff --git a/apps/api/src/routes/mcpServer.bootstrapLifecycle.test.ts b/apps/api/src/routes/mcpServer.bootstrapLifecycle.test.ts index 02980f1a53..8d01fc6f85 100644 --- a/apps/api/src/routes/mcpServer.bootstrapLifecycle.test.ts +++ b/apps/api/src/routes/mcpServer.bootstrapLifecycle.test.ts @@ -7,6 +7,12 @@ import { z as zod } from 'zod'; // silently degrading ledger/audit classification to 'success'. import type { SendDeploymentInvitesOutput } from '../modules/mcpInvites/tools/sendDeploymentInvites'; import type { ConfigureDefaultsOutput } from '../modules/mcpInvites/tools/configureDefaults'; +import { BootstrapError } from '../modules/mcpInvites/types'; +import { + __loadMcpBootstrapForTests, + classifyBootstrapToolResult, + mcpServerRoutes, +} from './mcpServer'; // --------------------------------------------------------------------------- // Task 7 — MCP-OAUTH-11 (bootstrap RBAC) + MCP-OAUTH-12 (shared Tier 3 @@ -20,24 +26,99 @@ import type { ConfigureDefaultsOutput } from '../modules/mcpInvites/tools/config // the handlers' DB logic. // --------------------------------------------------------------------------- -const ledgerBegin = vi.fn(async (..._args: any[]) => ({ - executionId: 'exec-1', - sessionId: 'sess-1', - orgId: 'org-1', +const testState = vi.hoisted(() => { + const originalEnv = { + NODE_ENV: process.env.NODE_ENV, + EXEC_ADMIN: process.env.MCP_REQUIRE_EXECUTE_ADMIN, + ALLOWLIST: process.env.MCP_EXECUTE_TOOL_ALLOWLIST, + }; + + // mcpServer parses the execute-tool allowlist once at module import time. + process.env.NODE_ENV = 'production'; + process.env.MCP_REQUIRE_EXECUTE_ADMIN = 'false'; + process.env.MCP_EXECUTE_TOOL_ALLOWLIST = '*'; + + return { + originalEnv, + scopes: ['ai:read', 'ai:execute'] as string[], + permissions: [{ resource: '*', action: '*' }] as Array<{ resource: string; action: string }>, + roleId: 'role-1' as string | null, + billingEmail: 'admin@acme.com', + }; +}); + +const mocks = vi.hoisted(() => ({ + ledgerBegin: vi.fn(), + ledgerComplete: vi.fn(), + writeAuditEvent: vi.fn(), + sendHandler: vi.fn() as any, + configureHandler: vi.fn() as any, + // SR2-15: getUserPermissions is called TWICE per bootstrap request (see the + // FULL_AI_READ_EXECUTE_BASELINE comment below) — a plain testState-backed + // return can't express "first call differs from every later call", so this + // is a real vi.fn() whose per-call behavior callBootstrap programs fresh + // for each request via mockResolvedValueOnce + mockResolvedValue. + getUserPermissions: vi.fn(), })); -const ledgerComplete = vi.fn(async (..._args: any[]) => undefined); vi.mock('../services/mcpToolExecutionLedger', () => ({ - beginMcpToolExecutionLedger: (...args: any[]) => ledgerBegin(...args), - completeMcpToolExecutionLedger: (...args: any[]) => ledgerComplete(...args), + beginMcpToolExecutionLedger: (...args: any[]) => mocks.ledgerBegin(...args), + completeMcpToolExecutionLedger: (...args: any[]) => mocks.ledgerComplete(...args), })); -const writeAuditEvent = vi.fn(); vi.mock('../services/auditEvents', () => ({ - writeAuditEvent: (...args: any[]) => writeAuditEvent(...args), + writeAuditEvent: (...args: any[]) => mocks.writeAuditEvent(...args), requestLikeFromSnapshot: vi.fn(() => ({})), })); +vi.mock('../db', () => ({ + db: { + select: () => ({ + from: () => ({ + where: () => ({ limit: async () => [{ billingEmail: testState.billingEmail }] }), + }), + }), + }, + withDbAccessContext: vi.fn((_ctx: any, fn: any) => fn()), + withSystemDbAccessContext: vi.fn((fn: any) => fn()), + runOutsideDbContext: vi.fn((fn: () => any) => fn()), +})); + +vi.mock('../db/schema', () => ({ + devices: {}, + alerts: {}, + scripts: {}, + automations: {}, + organizations: { id: 'organizations.id', partnerId: 'organizations.partnerId' }, + partners: { id: 'partners.id', billingEmail: 'partners.billingEmail' }, +})); + +vi.mock('../middleware/apiKeyAuth', () => ({ + apiKeyAuthMiddleware: async (c: any, next: any) => { + c.set('apiKey', { + id: 'key-1', + orgId: 'org-1', + partnerId: 'partner-1', + name: 'test', + keyPrefix: 'brz_test', + scopes: testState.scopes, + rateLimit: 1000, + createdBy: 'user-1', + }); + c.set('apiKeyOrgId', 'org-1'); + await next(); + }, + requireApiKeyScope: () => async (_c: any, next: any) => next(), +})); + +vi.mock('../services/permissions', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getUserPermissions: (...args: any[]) => mocks.getUserPermissions(...args), + }; +}); + // Truthy stub: production preflight requires a non-null Redis for the message // rate-limit gate; rateLimiter itself is mocked below to always allow. vi.mock('../services/redis', () => ({ getRedis: () => ({}) })); @@ -65,20 +146,6 @@ vi.mock('../services/aiTools', () => ({ getToolTier: () => undefined, })); -// Fake bootstrap authTools, per-test configurable handlers. Referenced lazily -// inside the mock factory so beforeEach can reset them. -// Typed `any` so per-test reassignments with differing result shapes (partial -// failures, throws) don't fight the inferred signature of the initial value. -let sendHandler: any = vi.fn(async () => ({ invites_sent: 1, invite_ids: ['i1'], skipped_duplicates: 0 })); -let configureHandler: any = vi.fn(async () => ({ - applied: { - device_group: { created: true }, - alert_policy: { created: true }, - risk_profile: { created: true }, - notification_channel: { created: true }, - }, -})); - vi.mock('../modules/mcpInvites', () => ({ initMcpBootstrap: () => ({ unauthTools: [], @@ -89,7 +156,7 @@ vi.mock('../modules/mcpInvites', () => ({ description: 'fake', inputSchema: zod.object({}).passthrough(), }, - handler: (...args: any[]) => sendHandler(...(args as [any, any])), + handler: (...args: any[]) => mocks.sendHandler(...(args as [any, any])), }, { definition: { @@ -97,23 +164,17 @@ vi.mock('../modules/mcpInvites', () => ({ description: 'fake', inputSchema: zod.object({}).passthrough(), }, - handler: (...args: any[]) => configureHandler(...(args as [any, any])), + handler: (...args: any[]) => mocks.configureHandler(...(args as [any, any])), }, ], }), })); -const ORIG = { - NODE_ENV: process.env.NODE_ENV, - EXEC_ADMIN: process.env.MCP_REQUIRE_EXECUTE_ADMIN, - ALLOWLIST: process.env.MCP_EXECUTE_TOOL_ALLOWLIST, -}; - function restoreEnv() { for (const [k, v] of [ - ['NODE_ENV', ORIG.NODE_ENV], - ['MCP_REQUIRE_EXECUTE_ADMIN', ORIG.EXEC_ADMIN], - ['MCP_EXECUTE_TOOL_ALLOWLIST', ORIG.ALLOWLIST], + ['NODE_ENV', testState.originalEnv.NODE_ENV], + ['MCP_REQUIRE_EXECUTE_ADMIN', testState.originalEnv.EXEC_ADMIN], + ['MCP_EXECUTE_TOOL_ALLOWLIST', testState.originalEnv.ALLOWLIST], ] as const) { if (v === undefined) delete process.env[k]; else process.env[k] = v; @@ -121,12 +182,19 @@ function restoreEnv() { } beforeEach(() => { - vi.resetModules(); - ledgerBegin.mockClear(); - ledgerComplete.mockClear(); - writeAuditEvent.mockClear(); - sendHandler = vi.fn(async () => ({ invites_sent: 1, invite_ids: ['i1'], skipped_duplicates: 0 })); - configureHandler = vi.fn(async () => ({ + testState.scopes = ['ai:read', 'ai:execute']; + testState.permissions = [{ resource: '*', action: '*' }]; + testState.roleId = 'role-1'; + testState.billingEmail = 'admin@acme.com'; + mocks.ledgerBegin.mockReset().mockResolvedValue({ + executionId: 'exec-1', + sessionId: 'sess-1', + orgId: 'org-1', + }); + mocks.ledgerComplete.mockReset().mockResolvedValue(undefined); + mocks.writeAuditEvent.mockReset(); + mocks.sendHandler = vi.fn(async () => ({ invites_sent: 1, invite_ids: ['i1'], skipped_duplicates: 0 })); + mocks.configureHandler = vi.fn(async () => ({ applied: { device_group: { created: true }, alert_policy: { created: true }, @@ -143,48 +211,8 @@ beforeEach(() => { afterEach(() => { restoreEnv(); - vi.doUnmock('../services/permissions'); - vi.doUnmock('../middleware/apiKeyAuth'); - vi.doUnmock('../db'); }); -function mockDb() { - // Bootstrap dispatch queries partners.billingEmail; ledger is mocked so no - // insert path is exercised here. - vi.doMock('../db', () => ({ - db: { - select: () => ({ - from: () => ({ - where: () => ({ limit: async () => [{ billingEmail: 'admin@acme.com' }] }), - }), - }), - }, - withDbAccessContext: vi.fn((_ctx: any, fn: any) => fn()), - withSystemDbAccessContext: vi.fn((fn: any) => fn()), - runOutsideDbContext: vi.fn((fn: () => any) => fn()), - })); -} - -function mockApiKey(scopes: string[]) { - vi.doMock('../middleware/apiKeyAuth', () => ({ - apiKeyAuthMiddleware: async (c: any, next: any) => { - c.set('apiKey', { - id: 'key-1', - orgId: 'org-1', - partnerId: 'partner-1', - name: 'test', - keyPrefix: 'brz_test', - scopes, - rateLimit: 1000, - createdBy: 'user-1', - }); - c.set('apiKeyOrgId', 'org-1'); - await next(); - }, - requireApiKeyScope: () => async (_c: any, next: any) => next(), - })); -} - // SR2-15 (Task 3, scope re-clamp): buildAuthFromApiKey's org branch now calls // getUserPermissions ONCE via authorizeHumanApiKeyCreator to re-validate the // key's coarse scopes (callBootstrap always drives ['ai:read', 'ai:execute'], @@ -197,6 +225,12 @@ function mockApiKey(scopes: string[]) { // SUBSEQUENT call (the real per-tool RBAC gate) returns the scenario's actual // `permissions`, preserving every test's premise (including the "missing // devices.write" denial cases) without loosening any assertion. +// +// The hoisted, module-level `services/permissions` mock above just forwards +// to `mocks.getUserPermissions` — callBootstrap (below) reprograms that vi.fn +// fresh on every call via mockResolvedValueOnce + mockResolvedValue, since a +// single testState-backed return value can't express "first call differs +// from every later call". const FULL_AI_READ_EXECUTE_BASELINE = [ { resource: 'devices', action: 'read' }, { resource: 'alerts', action: 'read' }, @@ -206,25 +240,16 @@ const FULL_AI_READ_EXECUTE_BASELINE = [ { resource: 'scripts', action: 'execute' }, ]; -function mockPerms(permissions: Array<{ resource: string; action: string }>, roleId: string | null = 'role-1') { - vi.doMock('../services/permissions', async (importOriginal) => { - const actual = await importOriginal(); - const buildResult = (perms: Array<{ resource: string; action: string }>) => - roleId === null - ? null - : { - permissions: perms, - partnerId: 'partner-1', - orgId: 'org-1', - roleId, - scope: 'organization' as const, - }; - const getUserPermissions = vi.fn(async () => buildResult(permissions)); - if (roleId !== null) { - getUserPermissions.mockResolvedValueOnce(buildResult(FULL_AI_READ_EXECUTE_BASELINE)); - } - return { ...actual, getUserPermissions }; - }); +function buildPermsResult(permissions: Array<{ resource: string; action: string }>) { + return testState.roleId === null + ? null + : { + permissions, + partnerId: 'partner-1', + orgId: 'org-1', + roleId: testState.roleId, + scope: 'organization' as const, + }; } async function callBootstrap( @@ -235,13 +260,15 @@ async function callBootstrap( args?: Record; } = {}, ) { - const scopes = opts.scopes ?? ['ai:read', 'ai:execute']; - mockApiKey(scopes); - mockPerms(opts.perms ?? [{ resource: '*', action: '*' }]); - mockDb(); - const mod = await import('./mcpServer'); - await mod.__loadMcpBootstrapForTests(); - const res = await mod.mcpServerRoutes.request('/message', { + testState.scopes = opts.scopes ?? ['ai:read', 'ai:execute']; + testState.permissions = opts.perms ?? [{ resource: '*', action: '*' }]; + mocks.getUserPermissions.mockReset(); + if (testState.roleId !== null) { + mocks.getUserPermissions.mockResolvedValueOnce(buildPermsResult(FULL_AI_READ_EXECUTE_BASELINE)); + } + mocks.getUserPermissions.mockResolvedValue(buildPermsResult(testState.permissions)); + await __loadMcpBootstrapForTests(); + const res = await mcpServerRoutes.request('/message', { method: 'POST', headers: { 'content-type': 'application/json', 'X-API-Key': 'brz_test' }, body: JSON.stringify({ @@ -266,8 +293,8 @@ describe('bootstrap tool RBAC (MCP-OAUTH-11)', () => { expect(body.error?.code).toBe(-32603); expect(body.error?.message).toContain('devices.write'); // Reject precedes ledger: no ledger row, handler never ran. - expect(ledgerBegin).not.toHaveBeenCalled(); - expect(sendHandler).not.toHaveBeenCalled(); + expect(mocks.ledgerBegin).not.toHaveBeenCalled(); + expect(mocks.sendHandler).not.toHaveBeenCalled(); }); it('send_deployment_invites: role WITH devices.write succeeds', async () => { @@ -276,7 +303,7 @@ describe('bootstrap tool RBAC (MCP-OAUTH-11)', () => { args: { emails: ['a@b.com'] }, }); expect(body.error).toBeUndefined(); - expect(sendHandler).toHaveBeenCalledTimes(1); + expect(mocks.sendHandler).toHaveBeenCalledTimes(1); }); it('configure_defaults: role with organizations.write but MISSING devices.write extra is DENIED', async () => { @@ -288,8 +315,8 @@ describe('bootstrap tool RBAC (MCP-OAUTH-11)', () => { }); expect(body.error?.code).toBe(-32603); expect(body.error?.message).toContain('devices.write'); - expect(ledgerBegin).not.toHaveBeenCalled(); - expect(configureHandler).not.toHaveBeenCalled(); + expect(mocks.ledgerBegin).not.toHaveBeenCalled(); + expect(mocks.configureHandler).not.toHaveBeenCalled(); }); it('configure_defaults: role with organizations.write + devices.write + alerts.write succeeds', async () => { @@ -301,7 +328,7 @@ describe('bootstrap tool RBAC (MCP-OAUTH-11)', () => { ], }); expect(body.error).toBeUndefined(); - expect(configureHandler).toHaveBeenCalledTimes(1); + expect(mocks.configureHandler).toHaveBeenCalledTimes(1); }); }); @@ -314,14 +341,14 @@ describe('bootstrap Tier 3 ledger/audit lifecycle (MCP-OAUTH-12)', () => { it('send_deployment_invites: ledger row is created BEFORE the handler runs', async () => { let ledgerCallsAtHandlerTime = -1; - sendHandler = vi.fn(async () => { - ledgerCallsAtHandlerTime = ledgerBegin.mock.calls.length; + mocks.sendHandler = vi.fn(async () => { + ledgerCallsAtHandlerTime = mocks.ledgerBegin.mock.calls.length; return { invites_sent: 1, invite_ids: ['i1'], skipped_duplicates: 0 }; }); await callBootstrap('send_deployment_invites', { perms: ALL, args: { emails: ['a@b.com'] } }); - expect(ledgerBegin).toHaveBeenCalledTimes(1); + expect(mocks.ledgerBegin).toHaveBeenCalledTimes(1); expect(ledgerCallsAtHandlerTime).toBe(1); - const arg = (ledgerBegin.mock.calls[0] as any[])[0]; + const arg = (mocks.ledgerBegin.mock.calls[0] as any[])[0]; expect(arg.toolName).toBe('send_deployment_invites'); expect(arg.tier).toBe(3); expect(arg.orgId).toBe('org-1'); @@ -329,31 +356,31 @@ describe('bootstrap Tier 3 ledger/audit lifecycle (MCP-OAUTH-12)', () => { it('configure_defaults: ledger row is created BEFORE the handler runs', async () => { let ledgerCallsAtHandlerTime = -1; - configureHandler = vi.fn(async () => { - ledgerCallsAtHandlerTime = ledgerBegin.mock.calls.length; + mocks.configureHandler = vi.fn(async () => { + ledgerCallsAtHandlerTime = mocks.ledgerBegin.mock.calls.length; return { applied: {} }; }); await callBootstrap('configure_defaults', { perms: ALL }); - expect(ledgerBegin).toHaveBeenCalledTimes(1); + expect(mocks.ledgerBegin).toHaveBeenCalledTimes(1); expect(ledgerCallsAtHandlerTime).toBe(1); - const arg = (ledgerBegin.mock.calls[0] as any[])[0]; + const arg = (mocks.ledgerBegin.mock.calls[0] as any[])[0]; expect(arg.toolName).toBe('configure_defaults'); expect(arg.tier).toBe(3); }); it('ledger-creation failure prevents the handler from running (fail closed)', async () => { - ledgerBegin.mockRejectedValueOnce(new Error('ledger insert boom')); + mocks.ledgerBegin.mockRejectedValueOnce(new Error('ledger insert boom')); const body = await callBootstrap('send_deployment_invites', { perms: ALL, args: { emails: ['a@b.com'] } }); expect(body.error?.code).toBe(-32000); - expect(sendHandler).not.toHaveBeenCalled(); - expect(ledgerComplete).not.toHaveBeenCalled(); + expect(mocks.sendHandler).not.toHaveBeenCalled(); + expect(mocks.ledgerComplete).not.toHaveBeenCalled(); }); it('success completes the ledger (success) and writes a uniform mcp.tool. audit', async () => { await callBootstrap('send_deployment_invites', { perms: ALL, args: { emails: ['a@b.com'] } }); - expect(ledgerComplete).toHaveBeenCalledTimes(1); - expect((ledgerComplete.mock.calls[0] as any[])[0].status).toBe('success'); - const toolAudit = writeAuditEvent.mock.calls + expect(mocks.ledgerComplete).toHaveBeenCalledTimes(1); + expect((mocks.ledgerComplete.mock.calls[0] as any[])[0].status).toBe('success'); + const toolAudit = mocks.writeAuditEvent.mock.calls .map((c: any[]) => c[1]) .find((p: any) => p?.resourceType === 'mcp_tool_execution'); expect(toolAudit).toBeDefined(); @@ -362,24 +389,21 @@ describe('bootstrap Tier 3 ledger/audit lifecycle (MCP-OAUTH-12)', () => { }); it('thrown BootstrapError completes the ledger (failure) and writes a failure audit', async () => { - sendHandler = vi.fn(async () => { - // Import the SAME (freshly reset) module instance the route uses, so the - // route's `err instanceof BootstrapError` check matches. - const { BootstrapError } = await import('../modules/mcpInvites/types'); + mocks.sendHandler = vi.fn(async () => { throw new BootstrapError('RATE_LIMITED', 'too many'); }); const body = await callBootstrap('send_deployment_invites', { perms: ALL, args: { emails: ['a@b.com'] } }); expect(body.error?.code).toBe(-32000); - expect(ledgerComplete).toHaveBeenCalledTimes(1); - expect((ledgerComplete.mock.calls[0] as any[])[0].status).toBe('failure'); - const toolAudit = writeAuditEvent.mock.calls + expect(mocks.ledgerComplete).toHaveBeenCalledTimes(1); + expect((mocks.ledgerComplete.mock.calls[0] as any[])[0].status).toBe('failure'); + const toolAudit = mocks.writeAuditEvent.mock.calls .map((c: any[]) => c[1]) .find((p: any) => p?.resourceType === 'mcp_tool_execution'); expect(toolAudit?.result).toBe('failure'); }); it('send_deployment_invites PARTIAL failure (per-invite failures) classifies the ledger as failure', async () => { - sendHandler = vi.fn(async () => ({ + mocks.sendHandler = vi.fn(async () => ({ invites_sent: 1, invite_ids: ['i1'], skipped_duplicates: 0, @@ -391,22 +415,22 @@ describe('bootstrap Tier 3 ledger/audit lifecycle (MCP-OAUTH-12)', () => { }); // Handler result is still returned to the caller (not an RPC error). expect(body.error).toBeUndefined(); - expect(ledgerComplete).toHaveBeenCalledTimes(1); - expect((ledgerComplete.mock.calls[0] as any[])[0].status).toBe('failure'); - const toolAudit = writeAuditEvent.mock.calls + expect(mocks.ledgerComplete).toHaveBeenCalledTimes(1); + expect((mocks.ledgerComplete.mock.calls[0] as any[])[0].status).toBe('failure'); + const toolAudit = mocks.writeAuditEvent.mock.calls .map((c: any[]) => c[1]) .find((p: any) => p?.resourceType === 'mcp_tool_execution'); expect(toolAudit?.result).toBe('failure'); }); it('configure_defaults PARTIAL failure (step errors) classifies the ledger as failure', async () => { - configureHandler = vi.fn(async () => ({ + mocks.configureHandler = vi.fn(async () => ({ applied: {}, errors: [{ step: 'alert_policy', error: 'boom' }], })); await callBootstrap('configure_defaults', { perms: ALL }); - expect(ledgerComplete).toHaveBeenCalledTimes(1); - expect((ledgerComplete.mock.calls[0] as any[])[0].status).toBe('failure'); + expect(mocks.ledgerComplete).toHaveBeenCalledTimes(1); + expect((mocks.ledgerComplete.mock.calls[0] as any[])[0].status).toBe('failure'); }); it('classifyBootstrapToolResult treats REAL-shaped handler output as failure (regression against field-name drift)', async () => { @@ -418,11 +442,6 @@ describe('bootstrap Tier 3 ledger/audit lifecycle (MCP-OAUTH-12)', () => { // to the ACTUAL exported types via `satisfies`: if either field is ever // renamed, this fails to typecheck (caught at build/CI time) rather than // silently degrading every partial-failure result to 'success'. - mockApiKey(['ai:read', 'ai:execute']); - mockPerms([{ resource: '*', action: '*' }]); - mockDb(); - const mod = await import('./mcpServer'); - const sendResult = { invites_sent: 1, invite_ids: ['i1'], @@ -440,14 +459,14 @@ describe('bootstrap Tier 3 ledger/audit lifecycle (MCP-OAUTH-12)', () => { errors: [{ step: 'alert_policy', error: 'boom' }], } satisfies ConfigureDefaultsOutput; - expect(mod.classifyBootstrapToolResult(sendResult)).toBe('failure'); - expect(mod.classifyBootstrapToolResult(configureResult)).toBe('failure'); + expect(classifyBootstrapToolResult(sendResult)).toBe('failure'); + expect(classifyBootstrapToolResult(configureResult)).toBe('failure'); }); it('handler-specific business audits still fire alongside the uniform audit', async () => { // Model configureDefaults writing its own bootstrap.configure_defaults audit. - configureHandler = vi.fn(async (_input: any, _ctx: any) => { - writeAuditEvent({} as any, { + mocks.configureHandler = vi.fn(async (_input: any, _ctx: any) => { + mocks.writeAuditEvent({} as any, { orgId: 'org-1', actorType: 'api_key', actorId: 'key-1', @@ -459,7 +478,7 @@ describe('bootstrap Tier 3 ledger/audit lifecycle (MCP-OAUTH-12)', () => { return { applied: {} }; }); await callBootstrap('configure_defaults', { perms: ALL }); - const actions = writeAuditEvent.mock.calls.map((c: any[]) => c[1]?.action); + const actions = mocks.writeAuditEvent.mock.calls.map((c: any[]) => c[1]?.action); expect(actions).toContain('bootstrap.configure_defaults'); // business audit intact expect(actions).toContain('mcp.tool.configure_defaults'); // uniform audit added }); @@ -475,8 +494,8 @@ describe('reject precedes ledger (ordering pin)', () => { perms: [{ resource: 'devices', action: 'read' }], }); expect(body.error).toBeDefined(); - expect(ledgerBegin).not.toHaveBeenCalled(); - expect(ledgerComplete).not.toHaveBeenCalled(); - expect(sendHandler).not.toHaveBeenCalled(); + expect(mocks.ledgerBegin).not.toHaveBeenCalled(); + expect(mocks.ledgerComplete).not.toHaveBeenCalled(); + expect(mocks.sendHandler).not.toHaveBeenCalled(); }); }); diff --git a/apps/api/src/routes/mcpServer.effectiveTier.test.ts b/apps/api/src/routes/mcpServer.effectiveTier.test.ts index 0257eca152..b4ac618e3d 100644 --- a/apps/api/src/routes/mcpServer.effectiveTier.test.ts +++ b/apps/api/src/routes/mcpServer.effectiveTier.test.ts @@ -9,20 +9,94 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; // Shared lightweight mocks for the heavy module-graph leaves. // --------------------------------------------------------------------------- -const ledgerBegin = vi.fn(async (..._args: any[]) => ({ id: 'ledger-1' })); -const ledgerComplete = vi.fn(async (..._args: any[]) => undefined); +const testState = vi.hoisted(() => ({ + scopes: ['ai:read'] as string[], + permissions: [] as Array<{ resource: string; action: string }>, + allowedSiteIds: undefined as string[] | undefined, +})); + +const mocks = vi.hoisted(() => ({ + dbSelect: vi.fn(), + executeTool: vi.fn(), + getToolDefinitions: vi.fn(), + getToolTier: vi.fn(), + ledgerBegin: vi.fn(), + ledgerComplete: vi.fn(), + writeAuditEvent: vi.fn(), +})); vi.mock('../services/mcpToolExecutionLedger', () => ({ - beginMcpToolExecutionLedger: (...args: any[]) => ledgerBegin(...args), - completeMcpToolExecutionLedger: (...args: any[]) => ledgerComplete(...args), + beginMcpToolExecutionLedger: (...args: any[]) => mocks.ledgerBegin(...args), + completeMcpToolExecutionLedger: (...args: any[]) => mocks.ledgerComplete(...args), })); -const writeAuditEvent = vi.fn(); vi.mock('../services/auditEvents', () => ({ - writeAuditEvent: (...args: any[]) => writeAuditEvent(...args), + writeAuditEvent: (...args: any[]) => mocks.writeAuditEvent(...args), requestLikeFromSnapshot: vi.fn(), })); +vi.mock('../db', () => ({ + db: { select: (...args: any[]) => mocks.dbSelect(...args) }, + withDbAccessContext: vi.fn((_ctx: any, fn: any) => fn()), + withSystemDbAccessContext: vi.fn((fn: any) => fn()), + runOutsideDbContext: vi.fn((fn: () => any) => fn()), +})); + +// Keep schema initialization out of the timed test bodies while still giving +// Drizzle real table/column objects for eq/inArray/getTableColumns. +vi.mock('../db/schema', async () => { + const { boolean, jsonb, pgTable, text, timestamp } = await import('drizzle-orm/pg-core'); + return { + devices: pgTable('test_devices', { + id: text('id'), orgId: text('org_id'), siteId: text('site_id'), hostname: text('hostname'), + status: text('status', { enum: ['online', 'offline'] }), osType: text('os_type'), + osVersion: text('os_version'), agentVersion: text('agent_version'), lastSeenAt: timestamp('last_seen_at'), + }), + alerts: pgTable('test_alerts', { + id: text('id'), orgId: text('org_id'), title: text('title'), severity: text('severity'), + status: text('status', { enum: ['active', 'resolved'] }), deviceId: text('device_id'), + triggeredAt: timestamp('triggered_at'), + }), + scripts: pgTable('test_scripts', { + id: text('id'), orgId: text('org_id'), partnerId: text('partner_id'), name: text('name'), + description: text('description'), language: text('language'), category: text('category'), + deletedAt: timestamp('deleted_at'), + }), + automations: pgTable('test_automations', { + id: text('id'), orgId: text('org_id'), partnerId: text('partner_id'), name: text('name'), + description: text('description'), enabled: boolean('enabled'), trigger: jsonb('trigger'), + }), + organizations: pgTable('test_organizations', { + id: text('id'), partnerId: text('partner_id'), createdAt: timestamp('created_at'), + }), + partners: pgTable('test_partners', { id: text('id'), billingEmail: text('billing_email') }), + }; +}); + +vi.mock('../middleware/apiKeyAuth', () => ({ + apiKeyAuthMiddleware: async (c: any, next: any) => { + c.set('apiKey', { + id: 'key-1', + orgId: 'org-1', + partnerId: 'partner-1', + name: 'test', + keyPrefix: 'brz_test', + scopes: testState.scopes, + rateLimit: 1000, + createdBy: 'user-1', + }); + c.set('apiKeyOrgId', 'org-1'); + await next(); + }, + requireApiKeyScope: () => async (_c: any, next: any) => next(), +})); + +vi.mock('../services/aiTools', () => ({ + getToolDefinitions: (...args: any[]) => mocks.getToolDefinitions(...args), + executeTool: (...args: any[]) => mocks.executeTool(...args), + getToolTier: (...args: any[]) => mocks.getToolTier(...args), +})); + vi.mock('../services/redis', () => ({ getRedis: () => null })); vi.mock('../services/rate-limit', () => ({ rateLimiter: vi.fn(async () => ({ allowed: true, resetAt: new Date(Date.now() + 60000) })), @@ -34,6 +108,16 @@ vi.mock('../middleware/bearerTokenAuth', () => ({ resolvePartnerAccessibleOrgIds: async () => [], })); +vi.mock('../services/tenantStatus', () => ({ + getActiveOrgTenant: vi.fn(async () => null), + assertActiveTenantContext: vi.fn(), + TenantInactiveError: class TenantInactiveError extends Error {}, +})); + +vi.mock('../services/recoveryBootstrap', () => ({ + resolveServerUrl: (requestUrl?: string) => requestUrl ? new URL(requestUrl).origin : 'http://localhost:3001', +})); + vi.mock('./mcpExecutionOrg', () => ({ resolveMcpExecutionOrgId: () => 'org-1', // Task 6 routed the ordinary Tier 3 path through resolveMcpExecutionContext + @@ -51,10 +135,8 @@ vi.mock('./mcpExecutionOrg', () => ({ // // checkPermissionRequirement (MCP-OAUTH-03 resource RBAC, used by // resources/read) is stubbed here too, for the same reason: the FIX-3 -// site-axis tests below exercise site narrowing, not RBAC, and per-test -// getUserPermissions() overrides via vi.doMock do not reliably propagate -// through this outer importOriginal-wrapped mock across vi.resetModules() -// cycles — stubbing it directly avoids that test-infra fragility. +// site-axis tests below exercise site narrowing, not RBAC. The dedicated +// resource-RBAC suite retains the real permission primitive. vi.mock('../services/aiGuardrails', async (importOriginal) => { const actual = await importOriginal(); return { @@ -66,125 +148,79 @@ vi.mock('../services/aiGuardrails', async (importOriginal) => { }); // Stub getUserPermissions so buildAuthFromApiKey for org keys doesn't hit the -// permissions DB. Tests that need a site restriction override this per-case. +// permissions DB. Site-axis cases mutate only allowedSiteIds, without +// rebuilding the route graph. Keep the real hasPermission helper for the +// real guardrails. // // SR2-15 (Task 3, scope re-clamp): buildAuthFromApiKey's org branch now // re-validates a key's stored scopes against these permissions via -// authorizeHumanApiKeyCreator before an AuthContext is ever built. This -// file's tests exercise tier/scope GATING (FIX 1) with `ai:read`/`ai:write`/ -// `ai:execute`-scoped keys, not scope delegation, so the default creator here -// must hold every permission those coarse scopes require (devices/alerts/ -// scripts/automations read/write/execute) — otherwise every "still works" -// assertion below would be denied by the NEW re-clamp before ever reaching -// the tier-gating logic under test. +// authorizeHumanApiKeyCreator before an AuthContext is ever built — the +// single getUserPermissions() call in this flow (checkToolPermission and +// checkPermissionRequirement are BOTH stubbed to `null` for this whole file, +// so neither makes its own separate call) has to double as that coarse +// scope-ceiling check. `permissions` is therefore always this fixed FULL +// baseline — covering devices/alerts/scripts/automations read/write/execute, +// a superset of every `ai:read`/`ai:write`/`ai:execute` combination this file +// exercises — never `testState.permissions`; the fine-grained RBAC these +// tests actually target is stubbed out, so what the mock returns for +// `permissions` doesn't drive any assertion. `allowedSiteIds` is the one +// field the site-axis (FIX 3) tests DO depend on, so it stays testState-driven. +const FULL_PERMISSIONS_BASELINE = [ + { resource: 'devices', action: 'read' }, + { resource: 'devices', action: 'write' }, + { resource: 'devices', action: 'execute' }, + { resource: 'alerts', action: 'read' }, + { resource: 'alerts', action: 'write' }, + { resource: 'scripts', action: 'read' }, + { resource: 'scripts', action: 'write' }, + { resource: 'scripts', action: 'execute' }, + { resource: 'automations', action: 'read' }, + { resource: 'automations', action: 'write' }, +]; + vi.mock('../services/permissions', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getUserPermissions: vi.fn(async () => ({ - permissions: [ - { resource: 'devices', action: 'read' }, - { resource: 'devices', action: 'write' }, - { resource: 'devices', action: 'execute' }, - { resource: 'alerts', action: 'read' }, - { resource: 'alerts', action: 'write' }, - { resource: 'scripts', action: 'read' }, - { resource: 'scripts', action: 'write' }, - { resource: 'scripts', action: 'execute' }, - { resource: 'automations', action: 'read' }, - { resource: 'automations', action: 'write' }, - ], + permissions: FULL_PERMISSIONS_BASELINE, partnerId: null, orgId: 'org-1', roleId: 'role-1', scope: 'organization' as const, - allowedSiteIds: undefined, + allowedSiteIds: testState.allowedSiteIds, })), }; }); +import { mcpServerRoutes } from './mcpServer'; + const ORIG_NODE_ENV = process.env.NODE_ENV; beforeEach(() => { - vi.resetModules(); - ledgerBegin.mockClear(); - ledgerComplete.mockClear(); - writeAuditEvent.mockClear(); + vi.clearAllMocks(); + testState.scopes = ['ai:read']; + testState.permissions = []; + testState.allowedSiteIds = undefined; + mocks.dbSelect.mockReset().mockImplementation(() => { + throw new Error('Unexpected db.select call'); + }); + mocks.executeTool.mockReset(); + mocks.getToolDefinitions.mockReset().mockReturnValue([]); + mocks.getToolTier.mockReset().mockReturnValue(undefined); + mocks.ledgerBegin.mockReset().mockResolvedValue({ id: 'ledger-1' }); + mocks.ledgerComplete.mockReset().mockResolvedValue(undefined); + mocks.writeAuditEvent.mockReset(); }); afterEach(() => { if (ORIG_NODE_ENV === undefined) delete process.env.NODE_ENV; else process.env.NODE_ENV = ORIG_NODE_ENV; - vi.doUnmock('../db'); - vi.doUnmock('../db/schema'); - vi.doUnmock('../services/aiTools'); - vi.doUnmock('../middleware/apiKeyAuth'); }); -// SR2-15 (Task 3, scope re-clamp): buildAuthFromApiKey's org branch now calls -// getUserPermissions ONCE via authorizeHumanApiKeyCreator to re-validate the -// key's coarse 'ai:read' scope (API_KEY_SCOPE_POLICIES['ai:read'] bundles -// devices+alerts+scripts+automations read as a single all-or-nothing unit) -// BEFORE resources/read's own fine-grained checkPermissionRequirement runs -// its OWN, separate getUserPermissions call. The site-axis (FIX 3) tests -// below intentionally model a site-RESTRICTED creator who holds only ONE -// resource's read grant — that's incompatible with the coarse 'ai:read' -// ceiling on its own, so: the FIRST call (the coarse ceiling) gets a full -// baseline that always satisfies 'ai:read', and every SUBSEQUENT call (the -// fine-grained per-resource + site-axis gate) returns the scenario's actual -// restricted permissions, preserving each test's premise without loosening -// any assertion. -const FULL_AI_READ_BASELINE = [ - { resource: 'devices', action: 'read' }, - { resource: 'alerts', action: 'read' }, - { resource: 'scripts', action: 'read' }, - { resource: 'automations', action: 'read' }, -]; - -function mockSiteRestrictedPerms( - perms: Array<{ resource: string; action: string }>, - allowedSiteIds: string[], -) { - vi.doMock('../services/permissions', async (importOriginal) => { - const actual = await importOriginal(); - const buildResult = (permissions: Array<{ resource: string; action: string }>) => ({ - permissions, - partnerId: null, - orgId: 'org-1', - roleId: 'role-1', - scope: 'organization' as const, - allowedSiteIds, - }); - const getUserPermissions = vi.fn(async () => buildResult(perms)); - getUserPermissions.mockResolvedValueOnce(buildResult(FULL_AI_READ_BASELINE)); - return { ...actual, getUserPermissions }; - }); -} - -function mockApiKey(scopes: string[]) { - vi.doMock('../middleware/apiKeyAuth', () => ({ - apiKeyAuthMiddleware: async (c: any, next: any) => { - c.set('apiKey', { - id: 'key-1', - orgId: 'org-1', - partnerId: 'partner-1', - name: 'test', - keyPrefix: 'brz_test', - scopes, - rateLimit: 1000, - createdBy: 'user-1', - }); - c.set('apiKeyOrgId', 'org-1'); - await next(); - }, - requireApiKeyScope: () => async (_c: any, next: any) => next(), - })); -} - async function callTool(scopes: string[], toolName: string, args: Record) { - mockApiKey(scopes); - const mod = await import('./mcpServer'); - const res = await mod.mcpServerRoutes.request('/message', { + testState.scopes = scopes; + const res = await mcpServerRoutes.request('/message', { method: 'POST', headers: { 'content-type': 'application/json', 'X-API-Key': 'brz_test' }, body: JSON.stringify({ @@ -205,31 +241,22 @@ describe('MCP tools/call effective-tier gating (FIX 1)', () => { // Use real aiGuardrails so registry_operations action:'delete_key' escalates // base tier 1 → effective tier 3 and manage_processes action:'kill' → tier 3. beforeEach(() => { - // db is unused in these tier paths until executeTool; keep a benign stub. - vi.doMock('../db', () => ({ - db: {}, - withDbAccessContext: vi.fn((_ctx: any, fn: any) => fn()), - withSystemDbAccessContext: vi.fn(), - runOutsideDbContext: vi.fn((fn: () => any) => fn()), - })); // registry_operations / manage_processes / manage_patches are base tier 1; // run_script is base tier 3; security_scan is base tier 2 (its // action:'vulnerabilities' downgrades to tier 1 in guardrails — used by the // downgrade-clamp test to prove Math.max ignores the downgrade). - vi.doMock('../services/aiTools', () => ({ - getToolDefinitions: () => [], - executeTool: vi.fn(async () => JSON.stringify({ ok: true })), - getToolTier: (name: string) => - name === 'run_script' - ? 3 - : name === 'security_scan' - ? 2 - : name === 'registry_operations' || - name === 'manage_processes' || - name === 'manage_patches' - ? 1 - : undefined, - })); + mocks.executeTool.mockResolvedValue(JSON.stringify({ ok: true })); + mocks.getToolTier.mockImplementation((name: string) => + name === 'run_script' + ? 3 + : name === 'security_scan' + ? 2 + : name === 'registry_operations' || + name === 'manage_processes' || + name === 'manage_patches' + ? 1 + : undefined, + ); }); // C1 — tier-2 escalation: manage_patches is base tier 1, action:'approve' @@ -266,7 +293,7 @@ describe('MCP tools/call effective-tier gating (FIX 1)', () => { // The route writes two audit events: a request-level 'mcp_request' and the // tool-level 'mcp_tool_execution'. Select the tool-level one and assert it // records the EFFECTIVE (escalated) tier 3, not the base tier 1. - const toolAudit = writeAuditEvent.mock.calls + const toolAudit = mocks.writeAuditEvent.mock.calls .map((call: any[]) => call[1]) .find((p: any) => p?.resourceType === 'mcp_tool_execution'); expect(toolAudit).toBeDefined(); @@ -337,15 +364,15 @@ describe('MCP tools/call effective-tier gating (FIX 1)', () => { ); const body = await res.json(); expect(body.error).toBeUndefined(); - expect(ledgerBegin).toHaveBeenCalledTimes(1); - const arg = (ledgerBegin.mock.calls[0] as any[])[0] as any; + expect(mocks.ledgerBegin).toHaveBeenCalledTimes(1); + const arg = (mocks.ledgerBegin.mock.calls[0] as any[])[0] as any; expect(arg.tier).toBe(3); expect(arg.toolName).toBe('registry_operations'); }); it('benign read action on a tier-1 tool does NOT create a ledger', async () => { await callTool(['ai:read', 'ai:execute'], 'registry_operations', { action: 'read_value' }); - expect(ledgerBegin).not.toHaveBeenCalled(); + expect(mocks.ledgerBegin).not.toHaveBeenCalled(); }); }); @@ -361,98 +388,48 @@ describe('MCP resources/read site-axis enforcement (FIX 3)', () => { { id: 'd-b', siteId: 'site-B', hostname: 'host-b' }, ]; - function buildSiteDbMock() { - // Minimal chainable query stub. resolveSiteAllowedDeviceIds selects - // {id, siteId} from devices where org; the resource queries select with a - // where(and(...)). We interpret captured conditions to filter rows. - return { - db: { - select: (cols?: any) => ({ - from: (_table: any) => { - const builder: any = { - _conds: [] as any[], - where(cond: any) { - this._conds.push(cond); - return this; - }, - limit(_n: number) { - return Promise.resolve(this._rows()); - }, - orderBy() { - return this; - }, - _rows() { - // resolveSiteAllowedDeviceIds path: no limit() call, returns all - // org devices with {id, siteId}. - return DEVICE_ROWS.map((d) => ({ ...d, status: 'online', osType: 'linux', osVersion: '1', agentVersion: '1', lastSeenAt: null })); - }, - then(resolve: any) { - // resolveSiteAllowedDeviceIds awaits the builder directly (no limit). - resolve(DEVICE_ROWS.map((d) => ({ id: d.id, siteId: d.siteId }))); - }, - }; - return builder; - }, - }), - }, - withDbAccessContext: vi.fn((_ctx: any, fn: any) => fn()), - withSystemDbAccessContext: vi.fn((fn: any) => fn()), - runOutsideDbContext: vi.fn((fn: () => any) => fn()), - }; - } - it('site-restricted caller does not see site-B devices via resources/read', async () => { // Restrict creator to site-A only. MCP-OAUTH-03: resources/read now // requires devices.read before it will even reach the site-axis // narrowing under test here, so the role must hold it. - mockSiteRestrictedPerms([{ resource: 'devices', action: 'read' }], ['site-A']); + testState.permissions = [{ resource: 'devices', action: 'read' }]; + testState.allowedSiteIds = ['site-A']; // db mock: resolveSiteAllowedDeviceIds returns both devices with siteIds; // the real canAccessSite filter (built from allowedSiteIds) then narrows to // d-a. The device list query is then narrowed by inArray(devices.id,[d-a]). // We capture the final device list query and only return d-a. const capturedDeviceListConds: any[] = []; - vi.doMock('../db', () => ({ - db: { - select: (_cols?: any) => ({ - from: (_table: any) => { - const builder: any = { - _conds: [] as any[], - where(cond: any) { - this._conds.push(cond); - capturedDeviceListConds.push(cond); - return this; - }, - limit(_n: number) { - // device/alert list path — return only the site-A row to model - // the inArray narrowing the route applied. - return Promise.resolve([ - { id: 'd-a', hostname: 'host-a', status: 'online', osType: 'linux', osVersion: '1', agentVersion: '1', lastSeenAt: null }, - ]); - }, - orderBy() { - return this; - }, - then(resolve: any) { - // resolveSiteAllowedDeviceIds path (awaited without limit). - resolve([ - { id: 'd-a', siteId: 'site-A' }, - { id: 'd-b', siteId: 'site-B' }, - ]); - }, - }; - return builder; + mocks.dbSelect.mockImplementation((_cols?: any) => ({ + from: (_table: any) => { + const builder: any = { + _conds: [] as any[], + where(cond: any) { + this._conds.push(cond); + capturedDeviceListConds.push(cond); + return this; }, - }), + limit(_n: number) { + // device list path — return only the site-A row to model the + // inArray narrowing the route applied. + return Promise.resolve([ + { id: 'd-a', hostname: 'host-a', status: 'online', osType: 'linux', osVersion: '1', agentVersion: '1', lastSeenAt: null }, + ]); + }, + orderBy() { + return this; + }, + then(resolve: any) { + // resolveSiteAllowedDeviceIds path (awaited without limit). + resolve(DEVICE_ROWS.map((d) => ({ id: d.id, siteId: d.siteId }))); + }, + }; + return builder; }, - withDbAccessContext: vi.fn((_ctx: any, fn: any) => fn()), - withSystemDbAccessContext: vi.fn((fn: any) => fn()), - runOutsideDbContext: vi.fn((fn: () => any) => fn()), })); - mockApiKey(['ai:read']); - const mod = await import('./mcpServer'); - const res = await mod.mcpServerRoutes.request('/message', { + testState.scopes = ['ai:read']; + const res = await mcpServerRoutes.request('/message', { method: 'POST', headers: { 'content-type': 'application/json', 'X-API-Key': 'brz_test' }, body: JSON.stringify({ @@ -470,9 +447,6 @@ describe('MCP resources/read site-axis enforcement (FIX 3)', () => { // The device-list query must have received a site-narrowing condition // (in addition to the org condition) — proves the route applied the axis. expect(capturedDeviceListConds.length).toBeGreaterThan(0); - // Borderline-slow under full-suite parallel load (real async MCP resources/read - // + permission re-mock); give headroom over the 5s default so it doesn't flake - // when the suite is saturated. Passes in well under 1s in isolation. }, 15_000); // C4 — alerts list site axis. Alert a-a is on site-A device d-a; a-b is on @@ -482,48 +456,38 @@ describe('MCP resources/read site-axis enforcement (FIX 3)', () => { it('C4: site-restricted caller does not see site-B alerts via resources/read', async () => { // MCP-OAUTH-03: resources/read now requires alerts.read before the // site-axis narrowing under test here even runs. - mockSiteRestrictedPerms([{ resource: 'alerts', action: 'read' }], ['site-A']); + testState.permissions = [{ resource: 'alerts', action: 'read' }]; + testState.allowedSiteIds = ['site-A']; const capturedAlertConds: any[] = []; - vi.doMock('../db', () => ({ - db: { - select: (_cols?: any) => ({ - from: (_table: any) => { - const builder: any = { - where(cond: any) { - capturedAlertConds.push(cond); - return this; - }, - limit(_n: number) { - // alert list path — return only the site-A alert to model the - // inArray(alerts.deviceId,[d-a]) narrowing the route applied. - return Promise.resolve([ - { id: 'a-a', title: 'alert-a', severity: 'high', status: 'active', deviceId: 'd-a', triggeredAt: null }, - ]); - }, - orderBy() { - return this; - }, - then(resolve: any) { - // resolveSiteAllowedDeviceIds path (awaited without limit). - resolve([ - { id: 'd-a', siteId: 'site-A' }, - { id: 'd-b', siteId: 'site-B' }, - ]); - }, - }; - return builder; + mocks.dbSelect.mockImplementation((_cols?: any) => ({ + from: (_table: any) => { + const builder: any = { + where(cond: any) { + capturedAlertConds.push(cond); + return this; + }, + limit(_n: number) { + // alert list path — return only the site-A alert to model the + // inArray(alerts.deviceId,[d-a]) narrowing the route applied. + return Promise.resolve([ + { id: 'a-a', title: 'alert-a', severity: 'high', status: 'active', deviceId: 'd-a', triggeredAt: null }, + ]); }, - }), + orderBy() { + return this; + }, + then(resolve: any) { + // resolveSiteAllowedDeviceIds path (awaited without limit). + resolve(DEVICE_ROWS.map((d) => ({ id: d.id, siteId: d.siteId }))); + }, + }; + return builder; }, - withDbAccessContext: vi.fn((_ctx: any, fn: any) => fn()), - withSystemDbAccessContext: vi.fn((fn: any) => fn()), - runOutsideDbContext: vi.fn((fn: () => any) => fn()), })); - mockApiKey(['ai:read']); - const mod = await import('./mcpServer'); - const res = await mod.mcpServerRoutes.request('/message', { + testState.scopes = ['ai:read']; + const res = await mcpServerRoutes.request('/message', { method: 'POST', headers: { 'content-type': 'application/json', 'X-API-Key': 'brz_test' }, body: JSON.stringify({ @@ -540,10 +504,6 @@ describe('MCP resources/read site-axis enforcement (FIX 3)', () => { expect(text).not.toContain('a-b'); // The alert-list query must have received a site-narrowing condition. expect(capturedAlertConds.length).toBeGreaterThan(0); - // Same shape as the devices test above (real async MCP resources/read + - // permission re-mock); borderline-slow under full-suite parallel load, so - // give the same 15s headroom over the 5s default. Passes well under 1s in - // isolation — this only prevents flakes when the suite is saturated. }, 15_000); // C4 — single-device read breeze://devices/{id}. A site-A-restricted caller @@ -554,44 +514,37 @@ describe('MCP resources/read site-axis enforcement (FIX 3)', () => { const D_B = '22222222-2222-2222-2222-222222222222'; function mockSingleDeviceDb(returnedDevice: any) { - vi.doMock('../db', () => ({ - db: { - select: (_cols?: any) => ({ - from: (_table: any) => { - const builder: any = { - where() { - return this; - }, - limit(_n: number) { - return Promise.resolve(returnedDevice ? [returnedDevice] : []); - }, - then(resolve: any) { - resolve([ - { id: D_A, siteId: 'site-A' }, - { id: D_B, siteId: 'site-B' }, - ]); - }, - }; - return builder; + mocks.dbSelect.mockImplementation((_cols?: any) => ({ + from: (_table: any) => { + const builder: any = { + where() { + return this; + }, + limit(_n: number) { + return Promise.resolve(returnedDevice ? [returnedDevice] : []); + }, + then(resolve: any) { + resolve([ + { id: D_A, siteId: 'site-A' }, + { id: D_B, siteId: 'site-B' }, + ]); }, - }), + }; + return builder; }, - withDbAccessContext: vi.fn((_ctx: any, fn: any) => fn()), - withSystemDbAccessContext: vi.fn((fn: any) => fn()), - runOutsideDbContext: vi.fn((fn: () => any) => fn()), })); } function restrictToSiteA() { // MCP-OAUTH-03: resources/read now requires devices.read before the // site-axis narrowing under test here even runs. - mockSiteRestrictedPerms([{ resource: 'devices', action: 'read' }], ['site-A']); + testState.permissions = [{ resource: 'devices', action: 'read' }]; + testState.allowedSiteIds = ['site-A']; } async function readDevice(id: string) { - mockApiKey(['ai:read']); - const mod = await import('./mcpServer'); - return mod.mcpServerRoutes.request('/message', { + testState.scopes = ['ai:read']; + return mcpServerRoutes.request('/message', { method: 'POST', headers: { 'content-type': 'application/json', 'X-API-Key': 'brz_test' }, body: JSON.stringify({ diff --git a/apps/api/src/routes/mcpServer.resourceRbac.test.ts b/apps/api/src/routes/mcpServer.resourceRbac.test.ts index d2481705ff..4d30b17d01 100644 --- a/apps/api/src/routes/mcpServer.resourceRbac.test.ts +++ b/apps/api/src/routes/mcpServer.resourceRbac.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; // MCP-OAUTH-03: resources/read must map the requested URI to a product // permission (checkPermissionRequirement — extracted from checkToolPermission @@ -8,16 +8,96 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; // is what's under test. Only the heavy module-graph leaves get lightweight // mocks, matching the pattern in mcpServer.effectiveTier.test.ts. +const testState = vi.hoisted(() => ({ + scopes: ['ai:read'] as string[], + permissions: [] as Array<{ resource: string; action: string }>, + dbRows: [] as any[], +})); + +const mocks = vi.hoisted(() => ({ + dbSelect: vi.fn(), + ledgerBegin: vi.fn(), + ledgerComplete: vi.fn(), + writeAuditEvent: vi.fn(), + // SR2-15: getUserPermissions is called TWICE per resources/read request — + // see the FULL_AI_READ_BASELINE comment below — so this needs to be a real + // vi.fn() that mockPermissions() reprograms per-call, not a plain + // testState-backed return. + getUserPermissions: vi.fn(), +})); + vi.mock('../services/mcpToolExecutionLedger', () => ({ - beginMcpToolExecutionLedger: vi.fn(async () => ({ id: 'ledger-1' })), - completeMcpToolExecutionLedger: vi.fn(async () => undefined), + beginMcpToolExecutionLedger: (...args: any[]) => mocks.ledgerBegin(...args), + completeMcpToolExecutionLedger: (...args: any[]) => mocks.ledgerComplete(...args), })); vi.mock('../services/auditEvents', () => ({ - writeAuditEvent: vi.fn(), + writeAuditEvent: (...args: any[]) => mocks.writeAuditEvent(...args), requestLikeFromSnapshot: vi.fn(), })); +vi.mock('../db', () => ({ + db: { select: (...args: any[]) => mocks.dbSelect(...args) }, + withDbAccessContext: vi.fn((_ctx: any, fn: any) => fn()), + withSystemDbAccessContext: vi.fn((fn: any) => fn()), + runOutsideDbContext: vi.fn((fn: () => any) => fn()), +})); + +vi.mock('../db/schema', async () => { + const { boolean, jsonb, pgTable, text, timestamp } = await import('drizzle-orm/pg-core'); + return { + devices: pgTable('test_devices', { + id: text('id'), orgId: text('org_id'), siteId: text('site_id'), hostname: text('hostname'), + status: text('status', { enum: ['online', 'offline'] }), osType: text('os_type'), + osVersion: text('os_version'), agentVersion: text('agent_version'), lastSeenAt: timestamp('last_seen_at'), + }), + alerts: pgTable('test_alerts', { + id: text('id'), orgId: text('org_id'), title: text('title'), severity: text('severity'), + status: text('status', { enum: ['active', 'resolved'] }), deviceId: text('device_id'), + triggeredAt: timestamp('triggered_at'), + }), + scripts: pgTable('test_scripts', { + id: text('id'), orgId: text('org_id'), partnerId: text('partner_id'), name: text('name'), + description: text('description'), language: text('language'), category: text('category'), + deletedAt: timestamp('deleted_at'), + }), + automations: pgTable('test_automations', { + id: text('id'), orgId: text('org_id'), partnerId: text('partner_id'), name: text('name'), + description: text('description'), enabled: boolean('enabled'), trigger: jsonb('trigger'), + }), + organizations: pgTable('test_organizations', { + id: text('id'), partnerId: text('partner_id'), createdAt: timestamp('created_at'), + }), + partners: pgTable('test_partners', { id: text('id'), billingEmail: text('billing_email') }), + }; +}); + +vi.mock('../middleware/apiKeyAuth', () => ({ + apiKeyAuthMiddleware: async (c: any, next: any) => { + c.set('apiKey', { + id: 'key-1', + orgId: 'org-1', + partnerId: 'partner-1', + name: 'test', + keyPrefix: 'brz_test', + scopes: testState.scopes, + rateLimit: 1000, + createdBy: 'user-1', + }); + c.set('apiKeyOrgId', 'org-1'); + await next(); + }, + requireApiKeyScope: () => async (_c: any, next: any) => next(), +})); + +// Resource RBAC does not use the eager 47-tool registry. Keep it inert while +// leaving the real aiGuardrails module (and checkPermissionRequirement) loaded. +vi.mock('../services/aiTools', () => ({ + getToolDefinitions: () => [], + executeTool: vi.fn(), + getToolTier: () => undefined, +})); + vi.mock('../services/redis', () => ({ getRedis: () => null })); vi.mock('../services/rate-limit', () => ({ rateLimiter: vi.fn(async () => ({ allowed: true, resetAt: new Date(Date.now() + 60000) })), @@ -29,27 +109,8 @@ vi.mock('../middleware/bearerTokenAuth', () => ({ resolvePartnerAccessibleOrgIds: async () => [], })); -const dbSelectSpy = vi.fn(); - function mockDb(rows: any[]) { - vi.doMock('../db', () => ({ - db: { - select: (cols?: any) => { - dbSelectSpy(cols); - return { - from: (_table: any) => ({ - where: (_cond: any) => ({ - limit: (_n: number) => Promise.resolve(rows), - }), - limit: (_n: number) => Promise.resolve(rows), - }), - }; - }, - }, - withDbAccessContext: vi.fn((_ctx: any, fn: any) => fn()), - withSystemDbAccessContext: vi.fn((fn: any) => fn()), - runOutsideDbContext: vi.fn((fn: () => any) => fn()), - })); + testState.dbRows = rows; } // SR2-15 (Task 3, scope re-clamp): buildAuthFromApiKey's org branch now calls @@ -73,47 +134,46 @@ const FULL_AI_READ_BASELINE = [ { resource: 'automations', action: 'read' }, ]; -function mockPermissions(perms: { resource: string; action: string }[]) { - vi.doMock('../services/permissions', async (importOriginal) => { - const actual = await importOriginal(); - const buildResult = (permissions: { resource: string; action: string }[]) => ({ - permissions, - partnerId: null, - orgId: 'org-1', - roleId: 'role-1', - scope: 'organization' as const, - allowedSiteIds: undefined, - }); - const getUserPermissions = vi.fn(async () => buildResult(perms)); - getUserPermissions.mockResolvedValueOnce(buildResult(FULL_AI_READ_BASELINE)); - return { ...actual, getUserPermissions }; - }); +function buildPermsResult(permissions: { resource: string; action: string }[]) { + return { + permissions, + partnerId: null, + orgId: 'org-1', + roleId: 'role-1', + scope: 'organization' as const, + allowedSiteIds: undefined, + }; } -function mockApiKey() { - vi.doMock('../middleware/apiKeyAuth', () => ({ - apiKeyAuthMiddleware: async (c: any, next: any) => { - c.set('apiKey', { - id: 'key-1', - orgId: 'org-1', - partnerId: 'partner-1', - name: 'test', - keyPrefix: 'brz_test', - scopes: ['ai:read'], - rateLimit: 1000, - createdBy: 'user-1', - }); - c.set('apiKeyOrgId', 'org-1'); - await next(); - }, - requireApiKeyScope: () => async (_c: any, next: any) => next(), - })); +function mockPermissions(perms: { resource: string; action: string }[]) { + testState.permissions = perms; + mocks.getUserPermissions.mockReset(); + mocks.getUserPermissions.mockResolvedValueOnce(buildPermsResult(FULL_AI_READ_BASELINE)); + mocks.getUserPermissions.mockResolvedValue(buildPermsResult(perms)); } +vi.mock('../services/permissions', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getUserPermissions: (...args: any[]) => mocks.getUserPermissions(...args), + }; +}); + +vi.mock('../services/tenantStatus', () => ({ + getActiveOrgTenant: vi.fn(async () => null), + assertActiveTenantContext: vi.fn(), + TenantInactiveError: class TenantInactiveError extends Error {}, +})); + +vi.mock('../services/recoveryBootstrap', () => ({ + resolveServerUrl: (requestUrl?: string) => requestUrl ? new URL(requestUrl).origin : 'http://localhost:3001', +})); + +import { mcpServerRoutes } from './mcpServer'; + async function readResource(uri: string) { - mockApiKey(); - const mod = await import('./mcpServer'); - return mod.mcpServerRoutes.request('/message', { + return mcpServerRoutes.request('/message', { method: 'POST', headers: { 'content-type': 'application/json', 'X-API-Key': 'brz_test' }, body: JSON.stringify({ @@ -126,14 +186,21 @@ async function readResource(uri: string) { } beforeEach(() => { - vi.resetModules(); - dbSelectSpy.mockClear(); -}); - -afterEach(() => { - vi.doUnmock('../db'); - vi.doUnmock('../services/permissions'); - vi.doUnmock('../middleware/apiKeyAuth'); + vi.clearAllMocks(); + testState.scopes = ['ai:read']; + testState.permissions = []; + testState.dbRows = []; + mocks.dbSelect.mockReset().mockImplementation((_cols?: any) => ({ + from: (_table: any) => ({ + where: (_cond: any) => ({ + limit: (_n: number) => Promise.resolve(testState.dbRows), + }), + limit: (_n: number) => Promise.resolve(testState.dbRows), + }), + })); + mocks.ledgerBegin.mockReset().mockResolvedValue({ id: 'ledger-1' }); + mocks.ledgerComplete.mockReset().mockResolvedValue(undefined); + mocks.writeAuditEvent.mockReset(); }); const DEVICE_ID = '11111111-1111-1111-1111-111111111111'; @@ -148,9 +215,6 @@ const CASES: Array<{ uri: string; resource: string }> = [ describe('resources/read fail-closed resource RBAC (MCP-OAUTH-03)', () => { for (const { uri, resource } of CASES) { - // First-in-suite module import (fresh mcpServer + full transitive graph) - // is borderline-slow under load, same as mcpServer.effectiveTier.test.ts — - // give headroom over the 5s default. Passes in well under 1s in isolation. it(`denies ${uri} for a role lacking ${resource}.read and runs NO db query`, async () => { mockPermissions([]); // role has no permissions at all mockDb([]); @@ -162,7 +226,7 @@ describe('resources/read fail-closed resource RBAC (MCP-OAUTH-03)', () => { expect(body.error).toBeDefined(); expect(body.error.code).toBe(-32603); expect(body.error.message).toContain(`requires ${resource}.read`); - expect(dbSelectSpy).not.toHaveBeenCalled(); + expect(mocks.dbSelect).not.toHaveBeenCalled(); }, 15_000); it(`allows ${uri} for a role holding ${resource}.read`, async () => { @@ -178,7 +242,7 @@ describe('resources/read fail-closed resource RBAC (MCP-OAUTH-03)', () => { const body = await res.json(); expect(body.error).toBeUndefined(); - expect(dbSelectSpy).toHaveBeenCalled(); + expect(mocks.dbSelect).toHaveBeenCalled(); }, 15_000); } @@ -198,6 +262,6 @@ describe('resources/read fail-closed resource RBAC (MCP-OAUTH-03)', () => { expect(body.error).toBeDefined(); expect(body.error.code).toBe(-32602); expect(body.error.message).toContain('Unknown resource URI'); - expect(dbSelectSpy).not.toHaveBeenCalled(); + expect(mocks.dbSelect).not.toHaveBeenCalled(); }); }); diff --git a/apps/api/src/routes/mcpServer.streamable.test.ts b/apps/api/src/routes/mcpServer.streamable.test.ts index 88125ae420..e6248f3b0f 100644 --- a/apps/api/src/routes/mcpServer.streamable.test.ts +++ b/apps/api/src/routes/mcpServer.streamable.test.ts @@ -7,6 +7,18 @@ const mocks = vi.hoisted(() => ({ executeTool: vi.fn(), getToolDefinitions: vi.fn(() => []), getToolTier: vi.fn((_: string): number | undefined => undefined), + writeAuditEvent: vi.fn(), + rateLimiter: vi.fn(), +})); + +const envState = vi.hoisted(() => ({ + oauthEnabled: true, + oauthIssuer: 'https://us.example.com', +})); + +vi.mock('../config/env', () => ({ + get MCP_OAUTH_ENABLED() { return envState.oauthEnabled; }, + get OAUTH_ISSUER() { return envState.oauthIssuer; }, })); const setApiKeyContext = (c: any, scopes: string[] = ['ai:read']) => { @@ -108,7 +120,7 @@ vi.mock('../services/aiGuardrails', () => ({ })); vi.mock('../services/auditEvents', () => ({ - writeAuditEvent: vi.fn(), + writeAuditEvent: mocks.writeAuditEvent, requestLikeFromSnapshot: vi.fn(), })); // Session ownership store used by the in-memory Redis mock — shared across @@ -124,37 +136,43 @@ vi.mock('../services/redis', () => ({ }), })); vi.mock('../services/rate-limit', () => ({ - rateLimiter: vi.fn(async () => ({ allowed: true, resetAt: new Date(Date.now() + 60000) })), + rateLimiter: (...args: any[]) => mocks.rateLimiter(...args), })); vi.mock('../modules/mcpInvites', () => ({ initMcpBootstrap: () => ({ unauthTools: [], authTools: [] }), })); -async function appWithMcpRoutes() { - const mod = await import('./mcpServer'); - return { app: new Hono().route('/mcp', mod.mcpServerRoutes), mod }; +import { mcpServerRoutes } from './mcpServer'; + +function appWithMcpRoutes() { + return new Hono().route('/mcp', mcpServerRoutes); } describe('Streamable HTTP transport (POST /sse)', () => { beforeEach(() => { - process.env.MCP_OAUTH_ENABLED = 'true'; - process.env.IS_HOSTED = 'true'; - process.env.OAUTH_ISSUER = 'https://us.example.com'; - vi.resetModules(); - vi.clearAllMocks(); + envState.oauthEnabled = true; + envState.oauthIssuer = 'https://us.example.com'; __sessionStore.clear(); - mocks.apiKeyAuthMiddleware.mockImplementation(async (c: any, next: any) => { + mocks.executeTool.mockReset(); + mocks.getToolDefinitions.mockReset().mockReturnValue([]); + mocks.getToolTier.mockReset().mockReturnValue(undefined); + mocks.writeAuditEvent.mockReset(); + mocks.rateLimiter.mockReset().mockResolvedValue({ + allowed: true, + resetAt: new Date(Date.now() + 60_000), + }); + mocks.apiKeyAuthMiddleware.mockReset().mockImplementation(async (c: any, next: any) => { setApiKeyContext(c); return next(); }); - mocks.bearerTokenAuthMiddleware.mockImplementation(async (c: any, next: any) => { + mocks.bearerTokenAuthMiddleware.mockReset().mockImplementation(async (c: any, next: any) => { setApiKeyContext(c); return next(); }); }); it('returns inline JSON-RPC response with 200 application/json', async () => { - const { app } = await appWithMcpRoutes(); + const app = appWithMcpRoutes(); const res = await app.request('/mcp/sse', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': 'k' }, @@ -167,7 +185,7 @@ describe('Streamable HTTP transport (POST /sse)', () => { }); it('mints server-prefixed Mcp-Session-Id header on initialize', async () => { - const { app } = await appWithMcpRoutes(); + const app = appWithMcpRoutes(); const res = await app.request('/mcp/sse', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': 'k' }, @@ -179,7 +197,7 @@ describe('Streamable HTTP transport (POST /sse)', () => { }); it('ignores client-supplied Mcp-Session-Id on initialize and mints a server-prefixed value', async () => { - const { app } = await appWithMcpRoutes(); + const app = appWithMcpRoutes(); const res = await app.request('/mcp/sse', { method: 'POST', headers: { @@ -196,7 +214,7 @@ describe('Streamable HTTP transport (POST /sse)', () => { }); it('returns 202 with empty body for notifications (no id) when carrying a valid session', async () => { - const { app } = await appWithMcpRoutes(); + const app = appWithMcpRoutes(); // Initialize first so we have a server-minted session id to present. const init = await app.request('/mcp/sse', { method: 'POST', @@ -225,7 +243,7 @@ describe('Streamable HTTP transport (POST /sse)', () => { setApiKeyContext(c, []); // no scopes return next(); }); - const { app } = await appWithMcpRoutes(); + const app = appWithMcpRoutes(); const res = await app.request('/mcp/sse', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': 'k' }, @@ -237,7 +255,7 @@ describe('Streamable HTTP transport (POST /sse)', () => { }); it('returns 400 for malformed JSON-RPC request', async () => { - const { app } = await appWithMcpRoutes(); + const app = appWithMcpRoutes(); const res = await app.request('/mcp/sse', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': 'k' }, @@ -249,7 +267,7 @@ describe('Streamable HTTP transport (POST /sse)', () => { }); it('returns 400 for invalid JSON body', async () => { - const { app } = await appWithMcpRoutes(); + const app = appWithMcpRoutes(); const res = await app.request('/mcp/sse', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': 'k' }, @@ -261,7 +279,7 @@ describe('Streamable HTTP transport (POST /sse)', () => { }); it('DELETE /sse returns 204', async () => { - const { app } = await appWithMcpRoutes(); + const app = appWithMcpRoutes(); const res = await app.request('/mcp/sse', { method: 'DELETE', headers: { 'X-API-Key': 'k' }, @@ -272,7 +290,7 @@ describe('Streamable HTTP transport (POST /sse)', () => { }); it('legacy POST /message still returns inline JSON without sessionId', async () => { - const { app } = await appWithMcpRoutes(); + const app = appWithMcpRoutes(); const res = await app.request('/mcp/message', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': 'k' }, diff --git a/apps/api/src/routes/mcpServer.test.ts b/apps/api/src/routes/mcpServer.test.ts index 1d08012689..e2b0c564cd 100644 --- a/apps/api/src/routes/mcpServer.test.ts +++ b/apps/api/src/routes/mcpServer.test.ts @@ -1,10 +1,95 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; -import { z as zod } from 'zod'; +import { describe, expect, it, vi, beforeEach, afterEach, afterAll } from 'vitest'; + +const testState = vi.hoisted(() => { + const originalAllowlist = process.env.MCP_EXECUTE_TOOL_ALLOWLIST; + const originalMaxSseSessions = process.env.MCP_MAX_SSE_SESSIONS_PER_KEY; + // The route parses this once. Individual production-gate cases use tools + // that are respectively inside/outside this fixed harness allowlist. + process.env.MCP_EXECUTE_TOOL_ALLOWLIST = 'execute_command,registry_operations'; + process.env.MCP_MAX_SSE_SESSIONS_PER_KEY = '2'; + return { + originalAllowlist, + originalMaxSseSessions, + apiKey: null as Record | null, + db: {} as Record, + redis: null as Record | null, + bootstrap: { unauthTools: [], authTools: [] } as { unauthTools: any[]; authTools: any[] }, + oauthEnabled: true, + oauthIssuer: 'https://us.example.com', + aiTools: new Map(), + realCheckGuardrails: undefined as undefined | ((...args: any[]) => any), + }; +}); + +const routeMocks = vi.hoisted(() => ({ + executeTool: vi.fn(), + getToolDefinitions: vi.fn(), + getToolTier: vi.fn(), + verifyDeviceAccess: vi.fn(), + checkGuardrails: vi.fn(), + checkToolPermission: vi.fn(), + checkToolRateLimit: vi.fn(), + checkPermissionRequirement: vi.fn(), + writeAuditEvent: vi.fn(), + rateLimiter: vi.fn(), + enforceIpAllowlist: vi.fn(), + // A real vi.fn() (not a plain testState-backed factory) so individual tests + // can reprogram it via mockReturnValueOnce/mockReturnValue without a + // vi.doMock + vi.resetModules round trip — this suite's mcpServerRoutes is + // a static top-level import (see below), so a post-load vi.doMock never + // reaches it. + getUserPermissions: vi.fn(), +})); + +// SR2-15 (Task 3, scope re-clamp): buildAuthFromApiKey's org branch now routes +// through authorizeHumanApiKeyCreator, which re-validates the API key's +// STORED scopes against these live permissions (validateApiKeyScopeDelegation) +// before returning an AuthContext. This suite's tests are exercising +// transport/session/tier concerns, not scope delegation, so the default +// creator here must hold every non-admin permission the ai:read/ai:write/ +// ai:execute policies require — otherwise every test whose mocked apiKey +// carries one of those scopes would be denied by the re-clamp guard before +// ever reaching the behavior under test. `ai:execute_admin` (which requires +// the wildcard ADMIN_ALL grant) is deliberately EXCLUDED so the "key lacks +// ai:execute_admin" tests still exercise a real denial. +const DEFAULT_PERMISSIONS_BASELINE = { + permissions: [ + { resource: 'devices', action: 'read' }, + { resource: 'devices', action: 'write' }, + { resource: 'devices', action: 'execute' }, + { resource: 'alerts', action: 'read' }, + { resource: 'alerts', action: 'write' }, + { resource: 'scripts', action: 'read' }, + { resource: 'scripts', action: 'write' }, + { resource: 'scripts', action: 'execute' }, + { resource: 'automations', action: 'read' }, + { resource: 'automations', action: 'write' }, + ], + partnerId: null, + orgId: 'org-1', + roleId: 'role-1', + scope: 'organization' as const, +}; + +const WILDCARD_PERMISSIONS = { + permissions: [{ resource: '*', action: '*' }], + partnerId: null, + orgId: 'org-1', + roleId: 'role-1', + scope: 'organization' as const, +}; + +vi.mock('../config/env', () => ({ + get MCP_OAUTH_ENABLED() { return testState.oauthEnabled; }, + get OAUTH_ISSUER() { return testState.oauthIssuer; }, +})); // Mock heavy module-graph leaves so importing ./mcpServer doesn't stand up // a real postgres client / redis connection. vi.mock('../db', () => ({ - db: {}, + db: new Proxy({}, { + get: (_target, property) => testState.db[property as string], + }), withDbAccessContext: vi.fn((_ctx: any, fn: any) => fn()), withSystemDbAccessContext: vi.fn(), runOutsideDbContext: vi.fn((fn: () => any) => fn()), @@ -32,70 +117,57 @@ vi.mock('../db/schema', () => ({ // creator's site allowlist). Keep every real export the route graph needs and // stub only getUserPermissions to an unrestricted org perms object so these // transport/bootstrap tests don't need to model the permissions DB queries. -// -// SR2-15 (Task 3, scope re-clamp): buildAuthFromApiKey's org branch now routes -// through authorizeHumanApiKeyCreator, which re-validates the API key's -// STORED scopes against these live permissions (validateApiKeyScopeDelegation) -// before returning an AuthContext. This suite's tests are exercising -// transport/session/tier concerns, not scope delegation, so the default -// creator here must hold every non-admin permission the ai:read/ai:write/ -// ai:execute policies require — otherwise every test whose mocked apiKey -// carries one of those scopes would now be denied by the NEW re-clamp guard -// before ever reaching the behavior under test. `ai:execute_admin` (which -// requires the wildcard ADMIN_ALL grant) is deliberately EXCLUDED so the -// "key lacks ai:execute_admin" test still exercises a real denial. +// Routed through routeMocks.getUserPermissions (see DEFAULT_PERMISSIONS_BASELINE +// / WILDCARD_PERMISSIONS above) so individual tests can reprogram it. vi.mock('../services/permissions', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - getUserPermissions: vi.fn(async () => ({ - permissions: [ - { resource: 'devices', action: 'read' }, - { resource: 'devices', action: 'write' }, - { resource: 'devices', action: 'execute' }, - { resource: 'alerts', action: 'read' }, - { resource: 'alerts', action: 'write' }, - { resource: 'scripts', action: 'read' }, - { resource: 'scripts', action: 'write' }, - { resource: 'scripts', action: 'execute' }, - { resource: 'automations', action: 'read' }, - { resource: 'automations', action: 'write' }, - ], - partnerId: null, - orgId: 'org-1', - roleId: 'role-1', - scope: 'organization' as const, - })), + getUserPermissions: (...args: any[]) => routeMocks.getUserPermissions(...args), }; }); vi.mock('../services/aiTools', () => ({ - getToolDefinitions: () => [], - executeTool: vi.fn(), - getToolTier: () => undefined, + getToolDefinitions: (...args: any[]) => routeMocks.getToolDefinitions(...args), + executeTool: (...args: any[]) => routeMocks.executeTool(...args), + getToolTier: (...args: any[]) => routeMocks.getToolTier(...args), + get aiTools() { return testState.aiTools; }, + verifyDeviceAccess: (...args: any[]) => routeMocks.verifyDeviceAccess(...args), })); -vi.mock('../services/aiGuardrails', () => ({ - // Return a finite tier (1) by default: the route computes the effective tier - // as Math.max(baseTier, guardrailCheck.tier), so tier-1 here is a benign - // floor that lets the per-test getToolTier base tier drive the gates. A - // non-finite tier would now (correctly) fail closed. - checkGuardrails: () => ({ allowed: true, tier: 1 }), - checkToolPermission: async () => null, - checkToolRateLimit: async () => null, -})); +vi.mock('../services/aiGuardrails', async (importOriginal) => { + const actual = await importOriginal(); + testState.realCheckGuardrails = actual.checkGuardrails; + return { + ...actual, + checkGuardrails: (...args: any[]) => routeMocks.checkGuardrails(...args), + checkToolPermission: (...args: any[]) => routeMocks.checkToolPermission(...args), + checkToolRateLimit: (...args: any[]) => routeMocks.checkToolRateLimit(...args), + checkPermissionRequirement: (...args: any[]) => routeMocks.checkPermissionRequirement(...args), + }; +}); vi.mock('../services/auditEvents', () => ({ - writeAuditEvent: vi.fn(), + writeAuditEvent: (...args: any[]) => routeMocks.writeAuditEvent(...args), requestLikeFromSnapshot: vi.fn(), })); vi.mock('../services/redis', () => ({ - getRedis: () => null, + getRedis: () => testState.redis, })); vi.mock('../services/rate-limit', () => ({ - rateLimiter: vi.fn(async () => ({ allowed: true, resetAt: new Date(Date.now() + 60000) })), + rateLimiter: (...args: any[]) => routeMocks.rateLimiter(...args), +})); + +vi.mock('../middleware/apiKeyAuth', () => ({ + apiKeyAuthMiddleware: async (c: any, next: any) => { + if (!testState.apiKey) throw new Error('API-key middleware called without test key state'); + c.set('apiKey', testState.apiKey); + if (testState.apiKey.orgId) c.set('apiKeyOrgId', testState.apiKey.orgId); + await next(); + }, + requireApiKeyScope: () => async (_c: any, next: any) => next(), })); vi.mock('../middleware/bearerTokenAuth', () => ({ @@ -124,6 +196,49 @@ vi.mock('../middleware/bearerTokenAuth', () => ({ }, })); +vi.mock('../services/ipAllowlist', () => ({ + enforceIpAllowlist: (...args: any[]) => routeMocks.enforceIpAllowlist(...args), + IP_NOT_ALLOWED_BODY: { code: 'ip_not_allowed', error: 'Access denied from this IP address' }, + isBlocked: (decision: { decision: string }) => decision.decision === 'deny', +})); + +vi.mock('../modules/mcpInvites', () => ({ + initMcpBootstrap: () => testState.bootstrap, +})); + +import { __loadMcpBootstrapForTests, mcpServerRoutes } from './mcpServer'; + +function setTestApiKey(overrides: Record = {}) { + testState.apiKey = { + id: 'key-1', + orgId: 'org-1', + partnerId: 'partner-1', + name: 'test', + keyPrefix: 'brz_test', + scopes: ['ai:read'], + rateLimit: 1000, + createdBy: 'user-1', + ...overrides, + }; +} + +function useSessionRedis(store: Map) { + testState.redis = { + setex: vi.fn(async (key: string, _ttl: number, value: string) => { + store.set(key, value); + return 'OK'; + }), + get: vi.fn(async (key: string) => store.get(key) ?? null), + }; +} + +afterAll(() => { + if (testState.originalAllowlist === undefined) delete process.env.MCP_EXECUTE_TOOL_ALLOWLIST; + else process.env.MCP_EXECUTE_TOOL_ALLOWLIST = testState.originalAllowlist; + if (testState.originalMaxSseSessions === undefined) delete process.env.MCP_MAX_SSE_SESSIONS_PER_KEY; + else process.env.MCP_MAX_SSE_SESSIONS_PER_KEY = testState.originalMaxSseSessions; +}); + // Test the pure utility functions extracted from mcpServer.ts // These are not exported, so we test them via their behavior patterns @@ -229,23 +344,45 @@ describe('MCP utility functions', () => { }); // ============================================================================ -// Bootstrap carve-out integration tests +// MCP transport integration tests // ============================================================================ // -// These tests exercise the route file directly. Because the module reads -// IS_HOSTED at import time and kicks off a background load, we -// set the env var BEFORE dynamic-importing the route module and reset modules -// between cases. +// The route no longer reads IS_HOSTED or starts bootstrap loading at import. +// This suite reuses one static route graph and changes only live mock state. -describe('MCP bootstrap carve-out', () => { +describe('MCP transport integration', () => { const originalFlag = process.env.IS_HOSTED; const originalExecuteAdmin = process.env.MCP_REQUIRE_EXECUTE_ADMIN; const originalAllowlist = process.env.MCP_EXECUTE_TOOL_ALLOWLIST; const originalNodeEnv = process.env.NODE_ENV; const originalTrustProxyHeaders = process.env.TRUST_PROXY_HEADERS; - beforeEach(() => { - vi.resetModules(); + beforeEach(async () => { + testState.apiKey = null; + testState.db = {}; + testState.redis = null; + testState.bootstrap = { unauthTools: [], authTools: [] }; + testState.oauthEnabled = true; + testState.oauthIssuer = 'https://us.example.com'; + testState.aiTools = new Map(); + routeMocks.executeTool.mockReset(); + routeMocks.getToolDefinitions.mockReset().mockReturnValue([]); + routeMocks.getToolTier.mockReset().mockReturnValue(undefined); + routeMocks.verifyDeviceAccess.mockReset().mockResolvedValue({ device: { orgId: 'org-1', siteId: null } }); + routeMocks.checkGuardrails.mockReset().mockImplementation((...args: any[]) => + testState.realCheckGuardrails!(...args)); + routeMocks.checkToolPermission.mockReset().mockResolvedValue(null); + routeMocks.checkToolRateLimit.mockReset().mockResolvedValue(null); + routeMocks.checkPermissionRequirement.mockReset().mockResolvedValue(null); + routeMocks.writeAuditEvent.mockReset(); + routeMocks.rateLimiter.mockReset().mockResolvedValue({ + allowed: true, + remaining: 100, + resetAt: new Date(Date.now() + 60_000), + }); + routeMocks.enforceIpAllowlist.mockReset().mockResolvedValue({ decision: 'allow' }); + routeMocks.getUserPermissions.mockReset().mockResolvedValue(DEFAULT_PERMISSIONS_BASELINE); + await __loadMcpBootstrapForTests(); }); afterEach(() => { @@ -259,191 +396,18 @@ describe('MCP bootstrap carve-out', () => { else process.env.NODE_ENV = originalNodeEnv; if (originalTrustProxyHeaders === undefined) delete process.env.TRUST_PROXY_HEADERS; else process.env.TRUST_PROXY_HEADERS = originalTrustProxyHeaders; - vi.doUnmock('../modules/mcpInvites'); - vi.doUnmock('../middleware/apiKeyAuth'); - vi.doUnmock('../services/ipAllowlist'); - vi.doUnmock('../services/mcpToolExecutionLedger'); - }); - - it('no auth header → tools/list always returns 401 + WWW-Authenticate', async () => { - // The bootstrap unauth carve-out was deleted in Phase 3. All unauth callers - // must receive 401 regardless of IS_HOSTED or any other flag. - delete process.env.IS_HOSTED; - - vi.doMock('../middleware/apiKeyAuth', () => ({ - apiKeyAuthMiddleware: async () => { - throw new Error('should not be called when no X-API-Key header'); - }, - requireApiKeyScope: () => async (_c: any, next: any) => next(), - })); - - const { mcpServerRoutes } = await import('./mcpServer'); - const res = await mcpServerRoutes.request('/message', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), - }); - expect(res.status).toBe(401); - const body = await res.json(); - expect(body.error?.code).toBe(-32001); - }); - - it('authed key → authTools surface in tools/list AND dispatch to handler', async () => { - delete process.env.IS_HOSTED; - - vi.doMock('../middleware/apiKeyAuth', () => ({ - apiKeyAuthMiddleware: async (c: any, next: any) => { - c.set('apiKey', { - id: 'key-authtool', - orgId: 'org-1', - partnerId: 'partner-1', - name: 'test', - keyPrefix: 'brz_test', - scopes: ['ai:read', 'ai:execute'], - rateLimit: 1000, - createdBy: 'user-1', - }); - c.set('apiKeyOrgId', 'org-1'); - await next(); - }, - requireApiKeyScope: () => async (_c: any, next: any) => next(), - })); - - vi.doMock('../services/aiTools', () => ({ - getToolDefinitions: () => [], - executeTool: vi.fn(), - getToolTier: () => undefined, - })); - - vi.doMock('../db', () => ({ - db: { - select: () => ({ - from: () => ({ - where: () => ({ limit: async () => [{ partnerId: 'partner-1', billingEmail: 'admin@acme.com' }] }), - }), - }), - }, - withDbAccessContext: vi.fn((_ctx: any, fn: any) => fn()), - withSystemDbAccessContext: vi.fn(), - runOutsideDbContext: vi.fn((fn: () => any) => fn()), - })); - - const handlerMock = vi.fn(async () => ({ invites_sent: 2, invite_ids: ['i1', 'i2'], skipped_duplicates: 0 })); - const fakeAuthTool = { - definition: { - name: 'send_deployment_invites', - description: 'fake authTool', - // Real zod schema — was previously a hand-rolled mock that always - // returned success, which silently bypassed any schema regressions. - // Use z.object({}).passthrough() so existing tests that pass arbitrary - // arguments (e.g. { emails: ['a@b.com'] }) still flow through. - inputSchema: zod.object({}).passthrough(), - }, - handler: handlerMock, - }; - vi.doMock('../modules/mcpInvites', () => ({ - initMcpBootstrap: () => ({ - unauthTools: [], - authTools: [fakeAuthTool], - }), - })); - - // Bootstrap authTools now run through the shared Tier 3 ledger lifecycle - // (MCP-OAUTH-12), so this dispatch creates a ledger row. Scope the ledger - // stub to THIS test (a global mock would break the tier-3 tests below that - // assert the real ledger's insert values). - vi.doMock('../services/mcpToolExecutionLedger', () => ({ - beginMcpToolExecutionLedger: vi.fn(async () => ({ - executionId: 'exec-1', - sessionId: 'sess-1', - orgId: 'org-1', - })), - completeMcpToolExecutionLedger: vi.fn(async () => undefined), - })); - - const mod = await import('./mcpServer'); - await mod.__loadMcpBootstrapForTests(); - - // 1) tools/list surfaces send_deployment_invites for an ai:execute key. - const listRes = await mod.mcpServerRoutes.request('/message', { - method: 'POST', - headers: { 'content-type': 'application/json', 'X-API-Key': 'brz_test' }, - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), - }); - expect(listRes.status).toBe(200); - const listBody = await listRes.json(); - const names = listBody.result.tools.map((t: any) => t.name); - expect(names).toContain('send_deployment_invites'); - - // Regression: the bootstrap tool's zod schema must convert to a JSON Schema - // that carries a `type`. The old hand-rolled converter switched on the - // zod-3 `_def.typeName` (removed in zod 4) and fell through to `{}`, which - // MCP clients reject — failing the ENTIRE tools/list (0 tools loaded). - const sendTool = listBody.result.tools.find( - (t: any) => t.name === 'send_deployment_invites', - ); - expect(sendTool?.inputSchema?.type).toBe('object'); - - // 2) tools/call dispatches to the handler with parsed input + ctx.apiKey. - const callRes = await mod.mcpServerRoutes.request('/message', { - method: 'POST', - headers: { 'content-type': 'application/json', 'X-API-Key': 'brz_test' }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'send_deployment_invites', arguments: { emails: ['a@b.com'] } }, - }), - }); - expect(callRes.status).toBe(200); - const callBody = await callRes.json(); - expect(callBody.error).toBeUndefined(); - expect(handlerMock).toHaveBeenCalledTimes(1); - const call = handlerMock.mock.calls[0] as unknown as [any, any]; - const [calledInput, calledCtx] = call; - expect(calledInput).toEqual({ emails: ['a@b.com'] }); - expect(calledCtx.apiKey.id).toBe('key-authtool'); - expect(calledCtx.apiKey.partnerId).toBe('partner-1'); - expect(calledCtx.apiKey.defaultOrgId).toBe('org-1'); - expect(calledCtx.apiKey.partnerAdminEmail).toBe('admin@acme.com'); - const contentText = callBody.result.content[0].text; - expect(JSON.parse(contentText)).toEqual({ - invites_sent: 2, - invite_ids: ['i1', 'i2'], - skipped_duplicates: 0, - }); }); it('partner-scoped API key is denied by the partner IP allowlist before MCP dispatch', async () => { delete process.env.IS_HOSTED; - vi.doMock('../middleware/apiKeyAuth', () => ({ - apiKeyAuthMiddleware: async (c: any, next: any) => { - c.set('apiKey', { - id: 'key-partner', - orgId: null, - partnerId: 'partner-1', - name: 'partner', - keyPrefix: 'brz_partner', - scopes: ['ai:read'], - rateLimit: 1000, - createdBy: 'user-1', - }); - await next(); - }, - requireApiKeyScope: () => async (_c: any, next: any) => next(), - })); + setTestApiKey({ id: 'key-partner', orgId: null, name: 'partner', keyPrefix: 'brz_partner' }); - const enforceMock = vi.fn(async () => ({ decision: 'deny', reason: 'not_in_list' })); - vi.doMock('../services/ipAllowlist', () => ({ - enforceIpAllowlist: enforceMock, - IP_NOT_ALLOWED_BODY: { code: 'ip_not_allowed', error: 'Access denied from this IP address' }, - isBlocked: (decision: { decision: string }) => decision.decision === 'deny', - })); + const enforceMock = routeMocks.enforceIpAllowlist; + enforceMock.mockResolvedValue({ decision: 'deny', reason: 'not_in_list' }); let selectCall = 0; - vi.doMock('../db', () => ({ - db: { + testState.db = { select: () => { selectCall += 1; if (selectCall === 1) { @@ -461,13 +425,8 @@ describe('MCP bootstrap carve-out', () => { }), }; }, - }, - withDbAccessContext: vi.fn((_ctx: any, fn: any) => fn()), - withSystemDbAccessContext: vi.fn((fn: any) => fn()), - runOutsideDbContext: vi.fn((fn: () => any) => fn()), - })); + }; - const { mcpServerRoutes } = await import('./mcpServer'); const res = await mcpServerRoutes.request('/message', { method: 'POST', headers: { 'content-type': 'application/json', 'X-API-Key': 'brz_partner' }, @@ -494,25 +453,8 @@ describe('MCP bootstrap carve-out', () => { it('rejects oversized authed MCP JSON-RPC bodies before parsing', async () => { delete process.env.IS_HOSTED; - vi.doMock('../middleware/apiKeyAuth', () => ({ - apiKeyAuthMiddleware: async (c: any, next: any) => { - c.set('apiKey', { - id: 'key-1', - orgId: 'org-1', - partnerId: 'partner-1', - name: 'test', - keyPrefix: 'brz_test', - scopes: ['ai:read'], - rateLimit: 1000, - createdBy: 'user-1', - }); - c.set('apiKeyOrgId', 'org-1'); - await next(); - }, - requireApiKeyScope: () => async (_c: any, next: any) => next(), - })); + setTestApiKey(); - const { mcpServerRoutes } = await import('./mcpServer'); const res = await mcpServerRoutes.request('/message', { method: 'POST', headers: { 'content-type': 'application/json', 'X-API-Key': 'brz_test' }, @@ -536,34 +478,17 @@ describe('MCP bootstrap carve-out', () => { delete process.env.IS_HOSTED; process.env.NODE_ENV = 'production'; - vi.doMock('../middleware/apiKeyAuth', () => ({ - apiKeyAuthMiddleware: async (c: any, next: any) => { - c.set('apiKey', { - id: 'oauth:access-jti-1', - oauthGrantId: 'grant-stable-1', - orgId: 'org-1', - partnerId: 'partner-1', - name: 'OAuth bearer', - keyPrefix: 'oauth', - scopes: ['ai:read'], - rateLimit: 1000, - createdBy: 'user-1', - }); - c.set('apiKeyOrgId', 'org-1'); - await next(); - }, - requireApiKeyScope: () => async (_c: any, next: any) => next(), - })); + setTestApiKey({ + id: 'oauth:access-jti-1', + oauthGrantId: 'grant-stable-1', + name: 'OAuth bearer', + keyPrefix: 'oauth', + }); - const rateLimiter = vi.fn(async () => ({ allowed: true, remaining: 119, resetAt: new Date(Date.now() + 60_000) })); - vi.doMock('../services/redis', () => ({ - getRedis: () => ({}), - })); - vi.doMock('../services/rate-limit', () => ({ - rateLimiter, - })); + const rateLimiter = routeMocks.rateLimiter; + rateLimiter.mockResolvedValue({ allowed: true, remaining: 119, resetAt: new Date(Date.now() + 60_000) }); + testState.redis = {}; - const { mcpServerRoutes } = await import('./mcpServer'); const res = await mcpServerRoutes.request('/message', { method: 'POST', headers: { 'content-type': 'application/json', 'X-API-Key': 'oauth-bearer-test' }, @@ -577,43 +502,20 @@ describe('MCP bootstrap carve-out', () => { it('production defaults to requiring ai:execute_admin for tier-3 MCP calls', async () => { delete process.env.IS_HOSTED; process.env.NODE_ENV = 'production'; - process.env.MCP_EXECUTE_TOOL_ALLOWLIST = 'execute_command'; delete process.env.MCP_REQUIRE_EXECUTE_ADMIN; - vi.doMock('../middleware/apiKeyAuth', () => ({ - apiKeyAuthMiddleware: async (c: any, next: any) => { - c.set('apiKey', { - id: 'key-1', - orgId: 'org-1', - partnerId: 'partner-1', - name: 'test', - keyPrefix: 'brz_test', - scopes: ['ai:read', 'ai:execute'], - rateLimit: 1000, - createdBy: 'user-1', - }); - c.set('apiKeyOrgId', 'org-1'); - await next(); - }, - requireApiKeyScope: () => async (_c: any, next: any) => next(), - })); + setTestApiKey({ scopes: ['ai:read', 'ai:execute'] }); - const executeTool = vi.fn(async () => '{"ok":true}'); - vi.doMock('../services/aiTools', () => ({ - getToolDefinitions: () => [{ name: 'execute_command', description: '', input_schema: {} }], - executeTool, - getToolTier: (name: string) => (name === 'execute_command' ? 3 : undefined), - aiTools: new Map([['execute_command', { deviceArgs: ['deviceId'] }]]), - verifyDeviceAccess: vi.fn(async () => ({ device: { orgId: 'org-1', siteId: null } })), - })); - vi.doMock('../services/redis', () => ({ - getRedis: () => ({}), - })); + const executeTool = routeMocks.executeTool; + executeTool.mockResolvedValue('{"ok":true}'); + routeMocks.getToolDefinitions.mockReturnValue([{ name: 'execute_command', description: '', input_schema: {} }]); + routeMocks.getToolTier.mockImplementation((name: string) => (name === 'execute_command' ? 3 : undefined)); + testState.aiTools = new Map([['execute_command', { deviceArgs: ['deviceId'] }]]); + testState.redis = { get: vi.fn(async () => null) }; const ledgerInsertValues: any[] = []; const ledgerUpdateSet = vi.fn(); - vi.doMock('../db', () => ({ - db: { + testState.db = { select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ partnerId: 'partner-1' }] }), @@ -631,13 +533,8 @@ describe('MCP bootstrap carve-out', () => { return { where: async () => undefined }; }, }), - }, - withDbAccessContext: vi.fn((_ctx: any, fn: any) => fn()), - withSystemDbAccessContext: vi.fn(), - runOutsideDbContext: vi.fn((fn: () => any) => fn()), - })); + }; - const { mcpServerRoutes } = await import('./mcpServer'); const res = await mcpServerRoutes.request('/message', { method: 'POST', headers: { 'content-type': 'application/json', 'X-API-Key': 'brz_test' }, @@ -658,43 +555,20 @@ describe('MCP bootstrap carve-out', () => { it('production can explicitly opt out of the execute-admin requirement while keeping the allowlist gate', async () => { delete process.env.IS_HOSTED; process.env.NODE_ENV = 'production'; - process.env.MCP_EXECUTE_TOOL_ALLOWLIST = 'execute_command'; process.env.MCP_REQUIRE_EXECUTE_ADMIN = 'false'; - vi.doMock('../middleware/apiKeyAuth', () => ({ - apiKeyAuthMiddleware: async (c: any, next: any) => { - c.set('apiKey', { - id: 'key-1', - orgId: 'org-1', - partnerId: 'partner-1', - name: 'test', - keyPrefix: 'brz_test', - scopes: ['ai:read', 'ai:execute'], - rateLimit: 1000, - createdBy: 'user-1', - }); - c.set('apiKeyOrgId', 'org-1'); - await next(); - }, - requireApiKeyScope: () => async (_c: any, next: any) => next(), - })); + setTestApiKey({ scopes: ['ai:read', 'ai:execute'] }); - const executeTool = vi.fn(async () => '{"ok":true}'); - vi.doMock('../services/aiTools', () => ({ - getToolDefinitions: () => [{ name: 'execute_command', description: '', input_schema: {} }], - executeTool, - getToolTier: (name: string) => (name === 'execute_command' ? 3 : undefined), - aiTools: new Map([['execute_command', { deviceArgs: ['deviceId'] }]]), - verifyDeviceAccess: vi.fn(async () => ({ device: { orgId: 'org-1', siteId: null } })), - })); - vi.doMock('../services/redis', () => ({ - getRedis: () => ({}), - })); + const executeTool = routeMocks.executeTool; + executeTool.mockResolvedValue('{"ok":true}'); + routeMocks.getToolDefinitions.mockReturnValue([{ name: 'execute_command', description: '', input_schema: {} }]); + routeMocks.getToolTier.mockImplementation((name: string) => (name === 'execute_command' ? 3 : undefined)); + testState.aiTools = new Map([['execute_command', { deviceArgs: ['deviceId'] }]]); + testState.redis = { get: vi.fn(async () => null) }; const ledgerInsertValues: any[] = []; const ledgerUpdateSet = vi.fn(); - vi.doMock('../db', () => ({ - db: { + testState.db = { select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ partnerId: 'partner-1' }] }), @@ -712,13 +586,8 @@ describe('MCP bootstrap carve-out', () => { return { where: async () => undefined }; }, }), - }, - withDbAccessContext: vi.fn((_ctx: any, fn: any) => fn()), - withSystemDbAccessContext: vi.fn(), - runOutsideDbContext: vi.fn((fn: () => any) => fn()), - })); + }; - const { mcpServerRoutes } = await import('./mcpServer'); const res = await mcpServerRoutes.request('/message', { method: 'POST', headers: { 'content-type': 'application/json', 'X-API-Key': 'brz_test' }, @@ -744,39 +613,16 @@ describe('MCP bootstrap carve-out', () => { delete process.env.IS_HOSTED; process.env.NODE_ENV = 'development'; - vi.doMock('../middleware/apiKeyAuth', () => ({ - apiKeyAuthMiddleware: async (c: any, next: any) => { - c.set('apiKey', { - id: 'key-1', - orgId: 'org-1', - partnerId: 'partner-1', - oauthGrantId: 'grant-1', - name: 'test', - keyPrefix: 'brz_test', - scopes: ['ai:read', 'ai:execute'], - rateLimit: 1000, - createdBy: 'user-1', - }); - c.set('apiKeyOrgId', 'org-1'); - await next(); - }, - requireApiKeyScope: () => async (_c: any, next: any) => next(), - })); + setTestApiKey({ oauthGrantId: 'grant-1', scopes: ['ai:read', 'ai:execute'] }); - vi.doMock('../services/aiTools', () => ({ - getToolDefinitions: () => [{ name: 'execute_command', description: '', input_schema: {} }], - executeTool: vi.fn(async () => JSON.stringify({ status: 'completed', stdout: 'token=raw-secret' })), - getToolTier: (name: string) => (name === 'execute_command' ? 3 : undefined), - aiTools: new Map([['execute_command', { deviceArgs: ['deviceId'] }]]), - verifyDeviceAccess: vi.fn(async () => ({ device: { orgId: 'org-1', siteId: null } })), - })); - vi.doMock('../services/redis', () => ({ - getRedis: () => ({}), - })); + routeMocks.getToolDefinitions.mockReturnValue([{ name: 'execute_command', description: '', input_schema: {} }]); + routeMocks.executeTool.mockResolvedValue(JSON.stringify({ status: 'completed', stdout: 'token=raw-secret' })); + routeMocks.getToolTier.mockImplementation((name: string) => (name === 'execute_command' ? 3 : undefined)); + testState.aiTools = new Map([['execute_command', { deviceArgs: ['deviceId'] }]]); + testState.redis = { get: vi.fn(async () => null) }; const ledgerInsertValues: any[] = []; const ledgerUpdateSet = vi.fn(); - vi.doMock('../db', () => ({ - db: { + testState.db = { select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ partnerId: 'partner-1' }] }), @@ -794,18 +640,9 @@ describe('MCP bootstrap carve-out', () => { return { where: async () => undefined }; }, }), - }, - withDbAccessContext: vi.fn((_ctx: any, fn: any) => fn()), - withSystemDbAccessContext: vi.fn(), - runOutsideDbContext: vi.fn((fn: () => any) => fn()), - })); - const writeAuditEvent = vi.fn(); - vi.doMock('../services/auditEvents', () => ({ - writeAuditEvent, - requestLikeFromSnapshot: vi.fn(), - })); + }; + const writeAuditEvent = routeMocks.writeAuditEvent; - const { mcpServerRoutes } = await import('./mcpServer'); // Pass an attacker-forged ?sessionId=. With MED-1 follow-through the // server now drops sessionIds the caller doesn't own — the audit row // MUST NOT echo `mcp-attacker-forged`. The sanitization assertions @@ -897,37 +734,15 @@ describe('MCP bootstrap carve-out', () => { delete process.env.IS_HOSTED; process.env.NODE_ENV = 'development'; - vi.doMock('../middleware/apiKeyAuth', () => ({ - apiKeyAuthMiddleware: async (c: any, next: any) => { - c.set('apiKey', { - id: 'key-1', - orgId: 'org-1', - partnerId: 'partner-1', - name: 'test', - keyPrefix: 'brz_test', - scopes: ['ai:read', 'ai:execute'], - rateLimit: 1000, - createdBy: 'user-1', - }); - c.set('apiKeyOrgId', 'org-1'); - await next(); - }, - requireApiKeyScope: () => async (_c: any, next: any) => next(), - })); - vi.doMock('../services/aiTools', () => ({ - getToolDefinitions: () => [{ name: 'execute_command', description: '', input_schema: {} }], - executeTool: vi.fn(async () => { - throw new TypeError('boom with token=raw-secret'); - }), - getToolTier: (name: string) => (name === 'execute_command' ? 3 : undefined), - aiTools: new Map([['execute_command', { deviceArgs: ['deviceId'] }]]), - verifyDeviceAccess: vi.fn(async () => ({ device: { orgId: 'org-1', siteId: null } })), - })); - vi.doMock('../services/redis', () => ({ getRedis: () => ({}) })); + setTestApiKey({ scopes: ['ai:read', 'ai:execute'] }); + routeMocks.getToolDefinitions.mockReturnValue([{ name: 'execute_command', description: '', input_schema: {} }]); + routeMocks.executeTool.mockRejectedValue(new TypeError('boom with token=raw-secret')); + routeMocks.getToolTier.mockImplementation((name: string) => (name === 'execute_command' ? 3 : undefined)); + testState.aiTools = new Map([['execute_command', { deviceArgs: ['deviceId'] }]]); + testState.redis = { get: vi.fn(async () => null) }; const ledgerInsertValues: any[] = []; const ledgerUpdateSet = vi.fn(); - vi.doMock('../db', () => ({ - db: { + testState.db = { select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ partnerId: 'partner-1' }] }), @@ -945,18 +760,9 @@ describe('MCP bootstrap carve-out', () => { return { where: async () => undefined }; }, }), - }, - withDbAccessContext: vi.fn((_ctx: any, fn: any) => fn()), - withSystemDbAccessContext: vi.fn(), - runOutsideDbContext: vi.fn((fn: () => any) => fn()), - })); - const writeAuditEvent = vi.fn(); - vi.doMock('../services/auditEvents', () => ({ - writeAuditEvent, - requestLikeFromSnapshot: vi.fn(), - })); + }; + const writeAuditEvent = routeMocks.writeAuditEvent; - const { mcpServerRoutes } = await import('./mcpServer'); const res = await mcpServerRoutes.request('/message', { method: 'POST', headers: { 'content-type': 'application/json', 'X-API-Key': 'brz_test' }, @@ -1019,25 +825,7 @@ describe('MCP bootstrap carve-out', () => { // content-length header is omitted entirely — the loop must still 413. delete process.env.IS_HOSTED; - vi.doMock('../middleware/apiKeyAuth', () => ({ - apiKeyAuthMiddleware: async (c: any, next: any) => { - c.set('apiKey', { - id: 'key-stream', - orgId: 'org-1', - partnerId: 'partner-1', - name: 'test', - keyPrefix: 'brz_test', - scopes: ['ai:read'], - rateLimit: 1000, - createdBy: 'user-1', - }); - c.set('apiKeyOrgId', 'org-1'); - await next(); - }, - requireApiKeyScope: () => async (_c: any, next: any) => next(), - })); - - const { mcpServerRoutes } = await import('./mcpServer'); + setTestApiKey({ id: 'key-stream' }); const MAX = 64 * 1024; // matches MCP_MESSAGE_MAX_BODY_BYTES default const chunkSize = 8 * 1024; @@ -1074,29 +862,13 @@ describe('MCP bootstrap carve-out', () => { it('keeps SSE queues partitioned per OAuth grant — distinct grants for the same user do NOT share a queue', async () => { // Regression test for mcpPrincipalKey() bucketing. Two access tokens // belonging to the same user but different OAuth grants must end up in - // separate sse queues; otherwise a leaked grant could replay another - // grant's responses. We exercise the bucketing via the per-key SSE - // session cap (5) — issuing 5 SSE GETs on grant A then a 6th on grant B - // must succeed (different bucket), while a 6th on grant A must 429. + // separate SSE queues. Keep each successful response open while asserting + // the cap, then cancel every reader and prove the same principal can fill + // its cap again without inheriting module-owned sessions from this phase. delete process.env.IS_HOSTED; process.env.NODE_ENV = 'production'; - process.env.MCP_MAX_SSE_SESSIONS_PER_KEY = '2'; - - let nextApiKey: any = null; - vi.doMock('../middleware/apiKeyAuth', () => ({ - apiKeyAuthMiddleware: async (c: any, next: any) => { - c.set('apiKey', nextApiKey); - c.set('apiKeyOrgId', nextApiKey.orgId); - await next(); - }, - requireApiKeyScope: () => async (_c: any, next: any) => next(), - })); - vi.doMock('../services/redis', () => ({ getRedis: () => ({}) })); - vi.doMock('../services/rate-limit', () => ({ - rateLimiter: vi.fn(async () => ({ allowed: true, remaining: 100, resetAt: new Date(Date.now() + 60_000) })), - })); - const { mcpServerRoutes } = await import('./mcpServer'); + testState.redis = { get: vi.fn(async () => null) }; const grantA = { id: 'oauth:jti-a-1', @@ -1111,67 +883,69 @@ describe('MCP bootstrap carve-out', () => { }; const grantB = { ...grantA, id: 'oauth:jti-b-1', oauthGrantId: 'grant-B', name: 'B' }; - // 1) Open MCP_MAX_SSE_SESSIONS_PER_KEY (2) sessions on grant A. We must - // abort each request immediately so the SSE handler doesn't hang the - // test — the session is registered synchronously in the handler before - // streamSSE starts pumping. - async function openSse(apiKey: any) { - nextApiKey = apiKey; - const ac = new AbortController(); - const promise = mcpServerRoutes.request('/sse', { + async function openSse( + apiKey: typeof grantA, + readers: Array>, + ) { + testState.apiKey = apiKey; + const response = await mcpServerRoutes.request('/sse', { method: 'GET', headers: { 'X-API-Key': 'whatever' }, - signal: ac.signal, }); - // Yield so the handler runs at least up to sseSessionQueues.set(). - await new Promise((r) => setTimeout(r, 10)); - ac.abort(); - try { await promise; } catch { /* aborted */ } + expect(response.status).toBe(200); + expect(response.body).not.toBeNull(); + + const reader = response.body!.getReader(); + readers.push(reader); + const firstChunk = await reader.read(); + expect(firstChunk.done).toBe(false); + expect(new TextDecoder().decode(firstChunk.value)).toContain('event: endpoint'); + } + + async function cancelReaders( + readers: Array>, + ) { + await Promise.all(readers.splice(0).map((reader) => reader.cancel())); + // The route poll loop sleeps for 100ms. Give aborted callbacks one poll + // turn to reach their finally blocks and clear keepalive intervals. + await new Promise((resolve) => setTimeout(resolve, 125)); + } + + const liveReaders: Array> = []; + try { + await openSse(grantA, liveReaders); + await openSse(grantA, liveReaders); + + // Grant A has filled its per-principal cap. + testState.apiKey = grantA; + const overA = await mcpServerRoutes.request('/sse', { + method: 'GET', + headers: { 'X-API-Key': 'whatever' }, + }); + expect(overA.status).toBe(429); + + // A distinct grant for the same user owns a separate queue bucket. + await openSse(grantB, liveReaders); + + const grantKey = (grant: { id: string; oauthGrantId?: string | null }) => + grant.oauthGrantId ? `oauth-grant:${grant.oauthGrantId}` : grant.id; + expect(grantKey(grantA)).toBe('oauth-grant:grant-A'); + expect(grantKey(grantB)).toBe('oauth-grant:grant-B'); + expect(grantKey({ id: 'oauth:jti-a-1' })).toBe('oauth:jti-a-1'); + expect(grantKey({ id: 'oauth:jti-a-1' }).startsWith('oauth-grant:')).toBe(false); + } finally { + await cancelReaders(liveReaders); + } + + // Cancellation must release both grant-A slots. Opening two fresh streams + // proves no prior session remains in the module-owned cap map. + const postCleanupReaders: Array> = []; + try { + await openSse(grantA, postCleanupReaders); + await openSse(grantA, postCleanupReaders); + } finally { + await cancelReaders(postCleanupReaders); } - await openSse(grantA); - await openSse(grantA); - - // 2) A 3rd grant-A SSE attempt must 429 (per-key cap exhausted). - nextApiKey = grantA; - const overA = await mcpServerRoutes.request('/sse', { - method: 'GET', - headers: { 'X-API-Key': 'whatever' }, - }); - expect(overA.status).toBe(429); - - // 3) A grant-B SSE attempt must succeed — it lives in its own bucket. - // (We expect a 200 streaming response; abort immediately to avoid - // leaving an open stream around.) - nextApiKey = grantB; - const acB = new AbortController(); - const bPromise = mcpServerRoutes.request('/sse', { - method: 'GET', - headers: { 'X-API-Key': 'whatever' }, - signal: acB.signal, - }); - await new Promise((r) => setTimeout(r, 10)); - acB.abort(); - let bRes: Response | null = null; - try { bRes = await bPromise; } catch { /* aborted */ } - // Status either 200 (already returned) or undefined (aborted before - // headers flushed). 429 would fail. Accept both 200 and an aborted - // throw — the lack of a 429 is the assertion that matters. - if (bRes) expect(bRes.status).toBe(200); - - // 4) Structural lock-in: the principalKey for an OAuth grant is - // `oauth-grant:`. An apiKey-id principalKey is the bare UUID - // (e.g. "uuid-…"). The "oauth-grant:" prefix means a raw apiKey.id - // cannot collide unless an apiKey row's id literally starts with - // that prefix — which the codebase never produces. We document the - // invariant rather than testing collision behavior directly. - const grantKey = (g: { id: string; oauthGrantId?: string | null }) => - g.oauthGrantId ? `oauth-grant:${g.oauthGrantId}` : g.id; - expect(grantKey(grantA)).toBe('oauth-grant:grant-A'); - expect(grantKey(grantB)).toBe('oauth-grant:grant-B'); - expect(grantKey({ id: 'oauth:jti-a-1' })).toBe('oauth:jti-a-1'); - expect(grantKey({ id: 'oauth:jti-a-1' }).startsWith('oauth-grant:')).toBe(false); - - delete process.env.MCP_MAX_SSE_SESSIONS_PER_KEY; }); it('SSE endpoint event uses the configured public base URL scheme/host, not the raw request URL', async () => { @@ -1185,22 +959,10 @@ describe('MCP bootstrap carve-out', () => { delete process.env.BREEZE_SERVER; process.env.PUBLIC_API_URL = 'https://mcp.example.com'; - vi.doMock('../middleware/apiKeyAuth', () => ({ - apiKeyAuthMiddleware: async (c: any, next: any) => { - c.set('apiKey', { - id: 'key-sse-scheme', orgId: 'org-1', partnerId: 'partner-1', - name: 'test', keyPrefix: 'brz_test', scopes: ['ai:read'], - rateLimit: 1000, createdBy: 'user-1', - }); - c.set('apiKeyOrgId', 'org-1'); - await next(); - }, - requireApiKeyScope: () => async (_c: any, next: any) => next(), - })); - vi.doMock('../services/redis', () => ({ getRedis: () => null })); + setTestApiKey({ id: 'key-sse-scheme' }); + testState.redis = null; try { - const { mcpServerRoutes } = await import('./mcpServer'); const res = await mcpServerRoutes.request('/sse', { method: 'GET', headers: { 'X-API-Key': 'whatever' }, @@ -1243,36 +1005,11 @@ describe('MCP bootstrap carve-out', () => { it('MED-1 initialize: ignores client-supplied Mcp-Session-Id, mints server-prefixed value', async () => { delete process.env.IS_HOSTED; - vi.doMock('../middleware/apiKeyAuth', () => ({ - apiKeyAuthMiddleware: async (c: any, next: any) => { - c.set('apiKey', { - id: 'key-med1-init', - orgId: 'org-1', - partnerId: 'partner-1', - name: 'test', - keyPrefix: 'brz_test', - scopes: ['ai:read'], - rateLimit: 1000, - createdBy: 'user-1', - }); - c.set('apiKeyOrgId', 'org-1'); - await next(); - }, - requireApiKeyScope: () => async (_c: any, next: any) => next(), - })); + setTestApiKey({ id: 'key-med1-init' }); const sessionStore = new Map(); - vi.doMock('../services/redis', () => ({ - getRedis: () => ({ - setex: vi.fn(async (k: string, _ttl: number, v: string) => { - sessionStore.set(k, v); - return 'OK'; - }), - get: vi.fn(async (k: string) => sessionStore.get(k) ?? null), - }), - })); + useSessionRedis(sessionStore); - const { mcpServerRoutes } = await import('./mcpServer'); const res = await mcpServerRoutes.request('/sse', { method: 'POST', headers: { @@ -1292,28 +1029,8 @@ describe('MCP bootstrap carve-out', () => { it('MED-1 reuse: rejects subsequent calls with a session-id owned by a different principal', async () => { delete process.env.IS_HOSTED; - let activeApiKey: any = null; - vi.doMock('../middleware/apiKeyAuth', () => ({ - apiKeyAuthMiddleware: async (c: any, next: any) => { - c.set('apiKey', activeApiKey); - c.set('apiKeyOrgId', activeApiKey.orgId); - await next(); - }, - requireApiKeyScope: () => async (_c: any, next: any) => next(), - })); - const sessionStore = new Map(); - vi.doMock('../services/redis', () => ({ - getRedis: () => ({ - setex: vi.fn(async (k: string, _ttl: number, v: string) => { - sessionStore.set(k, v); - return 'OK'; - }), - get: vi.fn(async (k: string) => sessionStore.get(k) ?? null), - }), - })); - - const { mcpServerRoutes } = await import('./mcpServer'); + useSessionRedis(sessionStore); const keyA = { id: 'key-med1-A', @@ -1336,7 +1053,7 @@ describe('MCP bootstrap carve-out', () => { createdBy: 'user-B', }; - activeApiKey = keyA; + testState.apiKey = keyA; const initA = await mcpServerRoutes.request('/sse', { method: 'POST', headers: { 'content-type': 'application/json', 'X-API-Key': 'brz_a' }, @@ -1347,7 +1064,7 @@ describe('MCP bootstrap carve-out', () => { expect(sessionId).toMatch(/^mcp-[a-f0-9]{20,}$/); // Principal B tries to ride principal A's session. - activeApiKey = keyB; + testState.apiKey = keyB; const stolen = await mcpServerRoutes.request('/sse', { method: 'POST', headers: { @@ -1365,36 +1082,11 @@ describe('MCP bootstrap carve-out', () => { it('MED-1 same-principal: originating caller can reuse the minted session id', async () => { delete process.env.IS_HOSTED; - vi.doMock('../middleware/apiKeyAuth', () => ({ - apiKeyAuthMiddleware: async (c: any, next: any) => { - c.set('apiKey', { - id: 'key-med1-same', - orgId: 'org-1', - partnerId: 'partner-1', - name: 'test', - keyPrefix: 'brz_test', - scopes: ['ai:read'], - rateLimit: 1000, - createdBy: 'user-1', - }); - c.set('apiKeyOrgId', 'org-1'); - await next(); - }, - requireApiKeyScope: () => async (_c: any, next: any) => next(), - })); + setTestApiKey({ id: 'key-med1-same' }); const sessionStore = new Map(); - vi.doMock('../services/redis', () => ({ - getRedis: () => ({ - setex: vi.fn(async (k: string, _ttl: number, v: string) => { - sessionStore.set(k, v); - return 'OK'; - }), - get: vi.fn(async (k: string) => sessionStore.get(k) ?? null), - }), - })); + useSessionRedis(sessionStore); - const { mcpServerRoutes } = await import('./mcpServer'); const initRes = await mcpServerRoutes.request('/sse', { method: 'POST', headers: { 'content-type': 'application/json', 'X-API-Key': 'brz_test' }, @@ -1422,36 +1114,10 @@ describe('MCP bootstrap carve-out', () => { it('MED-1 missing-or-malformed session id on non-initialize → 400', async () => { delete process.env.IS_HOSTED; - vi.doMock('../middleware/apiKeyAuth', () => ({ - apiKeyAuthMiddleware: async (c: any, next: any) => { - c.set('apiKey', { - id: 'key-med1-noid', - orgId: 'org-1', - partnerId: 'partner-1', - name: 'test', - keyPrefix: 'brz_test', - scopes: ['ai:read'], - rateLimit: 1000, - createdBy: 'user-1', - }); - c.set('apiKeyOrgId', 'org-1'); - await next(); - }, - requireApiKeyScope: () => async (_c: any, next: any) => next(), - })); + setTestApiKey({ id: 'key-med1-noid' }); const sessionStore = new Map(); - vi.doMock('../services/redis', () => ({ - getRedis: () => ({ - setex: vi.fn(async (k: string, _ttl: number, v: string) => { - sessionStore.set(k, v); - return 'OK'; - }), - get: vi.fn(async (k: string) => sessionStore.get(k) ?? null), - }), - })); - - const { mcpServerRoutes } = await import('./mcpServer'); + useSessionRedis(sessionStore); // No Mcp-Session-Id at all. const noHeader = await mcpServerRoutes.request('/sse', { @@ -1478,50 +1144,27 @@ describe('MCP bootstrap carve-out', () => { // ------------------------------------------------------------------------- // C2 — production gates apply to the ESCALATED effective tier, not the base - // tier. A base-tier-1 tool (registry_operations) with a TIER3 action - // (delete_key) escalates to tier 3, so the prod allowlist + execute_admin - // levers must fire exactly as they do for a statically-tier-3 tool. Uses the - // REAL aiGuardrails so the escalation is genuine. + // tier. Base-tier-1 registry/process tools with TIER3 actions escalate to + // tier 3, so the prod allowlist + execute_admin levers must fire exactly as + // they do for a statically-tier-3 tool. Uses the REAL aiGuardrails so the + // escalation is genuine. // ------------------------------------------------------------------------- function mockKeyWithScopes(scopes: string[]) { - vi.doMock('../middleware/apiKeyAuth', () => ({ - apiKeyAuthMiddleware: async (c: any, next: any) => { - c.set('apiKey', { - id: 'key-1', - orgId: 'org-1', - partnerId: 'partner-1', - name: 'test', - keyPrefix: 'brz_test', - scopes, - rateLimit: 1000, - createdBy: 'user-1', - }); - c.set('apiKeyOrgId', 'org-1'); - await next(); - }, - requireApiKeyScope: () => async (_c: any, next: any) => next(), - })); + setTestApiKey({ scopes }); } - function mockRealGuardrailsWithRegistryTier1() { - // registry_operations base tier 1; real aiGuardrails escalates - // action:'delete_key' → tier 3. Stub the RBAC/rate-limit checks (orthogonal). - vi.doMock('../services/aiGuardrails', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - checkToolPermission: vi.fn(async () => null), - checkToolRateLimit: vi.fn(async () => null), - }; - }); - vi.doMock('../services/aiTools', () => ({ - getToolDefinitions: () => [{ name: 'registry_operations', description: '', input_schema: {} }], - executeTool: vi.fn(async () => JSON.stringify({ ok: true })), - getToolTier: (name: string) => (name === 'registry_operations' ? 1 : undefined), - })); - vi.doMock('../db', () => ({ - db: { + function mockRealGuardrailsWithEscalatedTier1Tools() { + // Both tools are base tier 1; real aiGuardrails escalates their destructive + // actions to tier 3. RBAC/rate-limit checks remain the shared benign stubs. + routeMocks.getToolDefinitions.mockReturnValue([ + { name: 'registry_operations', description: '', input_schema: {} }, + { name: 'manage_processes', description: '', input_schema: {} }, + ]); + routeMocks.executeTool.mockResolvedValue(JSON.stringify({ ok: true })); + routeMocks.getToolTier.mockImplementation((name: string) => + name === 'registry_operations' || name === 'manage_processes' ? 1 : undefined); + testState.db = { select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ partnerId: 'partner-1' }] }), @@ -1533,42 +1176,24 @@ describe('MCP bootstrap carve-out', () => { update: () => ({ set: () => ({ where: async () => undefined }), }), - }, - withDbAccessContext: vi.fn((_ctx: any, fn: any) => fn()), - withSystemDbAccessContext: vi.fn(), - runOutsideDbContext: vi.fn((fn: () => any) => fn()), - })); - vi.doMock('../services/redis', () => ({ getRedis: () => ({}) })); + }; + testState.redis = { get: vi.fn(async () => null) }; } it('C2: escalated tier-3 action NOT in the prod allowlist is denied', async () => { delete process.env.IS_HOSTED; process.env.NODE_ENV = 'production'; - process.env.MCP_EXECUTE_TOOL_ALLOWLIST = 'execute_command'; // registry_operations absent mockKeyWithScopes(['ai:read', 'ai:execute', 'ai:execute_admin']); // SR2-15 (Task 3, scope re-clamp): this key's stored scopes include - // ai:execute_admin (requires the wildcard ADMIN_ALL grant). The module- - // level getUserPermissions mock deliberately withholds that grant (so the + // ai:execute_admin (requires the wildcard ADMIN_ALL grant). The default + // getUserPermissions mock deliberately withholds that grant (so the // "key lacks ai:execute_admin" test below stays a real denial); override // it here with a wildcard-permissioned creator so the coarse scope // re-clamp passes and this test can exercise the ACTUAL concern under // test — the MCP_EXECUTE_TOOL_ALLOWLIST gate, not scope delegation. - vi.doMock('../services/permissions', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getUserPermissions: vi.fn(async () => ({ - permissions: [{ resource: '*', action: '*' }], - partnerId: null, - orgId: 'org-1', - roleId: 'role-1', - scope: 'organization' as const, - })), - }; - }); - mockRealGuardrailsWithRegistryTier1(); + routeMocks.getUserPermissions.mockResolvedValue(WILDCARD_PERMISSIONS); + mockRealGuardrailsWithEscalatedTier1Tools(); - const { mcpServerRoutes } = await import('./mcpServer'); const res = await mcpServerRoutes.request('/message', { method: 'POST', headers: { 'content-type': 'application/json', 'X-API-Key': 'brz_test' }, @@ -1576,7 +1201,7 @@ describe('MCP bootstrap carve-out', () => { jsonrpc: '2.0', id: 1, method: 'tools/call', - params: { name: 'registry_operations', arguments: { action: 'delete_key', key: 'HKLM\\foo' } }, + params: { name: 'manage_processes', arguments: { action: 'kill', pid: 7 } }, }), }); expect(res.status).toBe(200); @@ -1587,12 +1212,10 @@ describe('MCP bootstrap carve-out', () => { it('C2: escalated tier-3 action in the allowlist but key lacks ai:execute_admin is denied', async () => { delete process.env.IS_HOSTED; process.env.NODE_ENV = 'production'; - process.env.MCP_EXECUTE_TOOL_ALLOWLIST = 'registry_operations'; delete process.env.MCP_REQUIRE_EXECUTE_ADMIN; mockKeyWithScopes(['ai:read', 'ai:execute']); // no ai:execute_admin - mockRealGuardrailsWithRegistryTier1(); + mockRealGuardrailsWithEscalatedTier1Tools(); - const { mcpServerRoutes } = await import('./mcpServer'); const res = await mcpServerRoutes.request('/message', { method: 'POST', headers: { 'content-type': 'application/json', 'X-API-Key': 'brz_test' }, @@ -1621,34 +1244,15 @@ describe('MCP bootstrap carve-out', () => { // above — this key's ai:execute_admin scope needs the wildcard ADMIN_ALL // grant to pass the coarse re-clamp; the concern under test here is the // non-finite-tier fail-closed guard, not scope delegation. - vi.doMock('../services/permissions', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getUserPermissions: vi.fn(async () => ({ - permissions: [{ resource: '*', action: '*' }], - partnerId: null, - orgId: 'org-1', - roleId: 'role-1', - scope: 'organization' as const, - })), - }; - }); + routeMocks.getUserPermissions.mockResolvedValue(WILDCARD_PERMISSIONS); - const executeTool = vi.fn(async () => JSON.stringify({ ok: true })); - vi.doMock('../services/aiTools', () => ({ - getToolDefinitions: () => [{ name: 'manage_tags', description: '', input_schema: {} }], - executeTool, - getToolTier: (name: string) => (name === 'manage_tags' ? 1 : undefined), - })); - vi.doMock('../services/aiGuardrails', () => ({ - checkGuardrails: () => ({ allowed: true, tier: undefined }), - checkToolPermission: async () => null, - checkToolRateLimit: async () => null, - })); - vi.doMock('../services/redis', () => ({ getRedis: () => ({}) })); + const executeTool = routeMocks.executeTool; + executeTool.mockResolvedValue(JSON.stringify({ ok: true })); + routeMocks.getToolDefinitions.mockReturnValue([{ name: 'manage_tags', description: '', input_schema: {} }]); + routeMocks.getToolTier.mockImplementation((name: string) => (name === 'manage_tags' ? 1 : undefined)); + routeMocks.checkGuardrails.mockReturnValue({ allowed: true, tier: undefined }); + testState.redis = { get: vi.fn(async () => null) }; - const { mcpServerRoutes } = await import('./mcpServer'); const res = await mcpServerRoutes.request('/message', { method: 'POST', headers: { 'content-type': 'application/json', 'X-API-Key': 'brz_test' }, @@ -1664,36 +1268,9 @@ describe('MCP bootstrap carve-out', () => { expect(body.error?.code).toBe(-32000); expect(body.error?.message).toContain('Unable to evaluate tool guardrails'); expect(executeTool).not.toHaveBeenCalled(); - - // `vi.doMock` registrations aren't cleared by `vi.resetModules()` — restore - // the file's default (non-wildcard) getUserPermissions mock so it doesn't - // leak forward into the "MCP instructions + prompts" tests below (doUnmock - // would strip the top-level `vi.mock` for the rest of the file, so we - // re-register the default factory explicitly instead). - vi.doMock('../services/permissions', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getUserPermissions: vi.fn(async () => ({ - permissions: [ - { resource: 'devices', action: 'read' }, - { resource: 'devices', action: 'write' }, - { resource: 'devices', action: 'execute' }, - { resource: 'alerts', action: 'read' }, - { resource: 'alerts', action: 'write' }, - { resource: 'scripts', action: 'read' }, - { resource: 'scripts', action: 'write' }, - { resource: 'scripts', action: 'execute' }, - { resource: 'automations', action: 'read' }, - { resource: 'automations', action: 'write' }, - ], - partnerId: null, - orgId: 'org-1', - roleId: 'role-1', - scope: 'organization' as const, - })), - }; - }); + // No manual restore needed: the top-level beforeEach resets + // routeMocks.getUserPermissions back to DEFAULT_PERMISSIONS_BASELINE + // before every test, so this override never leaks forward. }); // ------------------------------------------------------------------------- @@ -1705,14 +1282,13 @@ describe('MCP bootstrap carve-out', () => { // Nothing previously exercised these over the real JSON-RPC transport // (HTTP → apiKeyAuthMiddleware → handleJsonRpc dispatch → handler → // response). These tests close that gap using the same /message harness - // (mockKeyWithScopes + dynamic import) as the rest of this describe block. + // (mockKeyWithScopes + the shared static route) as the rest of this block. // ------------------------------------------------------------------------- describe('MCP instructions + prompts over the wire', () => { it('initialize returns non-trivial instructions, the prompts capability, and the protocol version', async () => { delete process.env.IS_HOSTED; mockKeyWithScopes(['ai:read']); - const { mcpServerRoutes } = await import('./mcpServer'); const res = await mcpServerRoutes.request('/message', { method: 'POST', headers: { 'content-type': 'application/json', 'X-API-Key': 'brz_test' }, @@ -1731,7 +1307,6 @@ describe('MCP bootstrap carve-out', () => { delete process.env.IS_HOSTED; mockKeyWithScopes(['ai:read']); - const { mcpServerRoutes } = await import('./mcpServer'); const res = await mcpServerRoutes.request('/message', { method: 'POST', headers: { 'content-type': 'application/json', 'X-API-Key': 'brz_test' }, @@ -1757,7 +1332,6 @@ describe('MCP bootstrap carve-out', () => { delete process.env.IS_HOSTED; mockKeyWithScopes(['ai:read']); - const { mcpServerRoutes } = await import('./mcpServer'); const res = await mcpServerRoutes.request('/message', { method: 'POST', headers: { 'content-type': 'application/json', 'X-API-Key': 'brz_test' }, @@ -1778,7 +1352,6 @@ describe('MCP bootstrap carve-out', () => { delete process.env.IS_HOSTED; mockKeyWithScopes(['ai:read']); - const { mcpServerRoutes } = await import('./mcpServer'); const res = await mcpServerRoutes.request('/message', { method: 'POST', headers: { 'content-type': 'application/json', 'X-API-Key': 'brz_test' }, diff --git a/apps/api/src/routes/mcpServer.ts b/apps/api/src/routes/mcpServer.ts index 0eac57b571..6088583612 100644 --- a/apps/api/src/routes/mcpServer.ts +++ b/apps/api/src/routes/mcpServer.ts @@ -334,6 +334,18 @@ mcpServerRoutes.get( sseSessionQueues.set(sessionId, { queue: [], principalKey, createdAt: Date.now() }); return streamSSE(c, async (stream) => { + let alive = true; + let keepalive: ReturnType | undefined; + const cleanup = () => { + alive = false; + sseSessionQueues.delete(sessionId); + if (keepalive) { + clearInterval(keepalive); + keepalive = undefined; + } + }; + stream.onAbort(cleanup); + // Send endpoint event so client knows where to POST messages. // // The scheme/host come from the configured public base URL @@ -353,15 +365,8 @@ mcpServerRoutes.get( data: messageUrl }); - // Poll for messages to send back to the client - let alive = true; - const cleanup = () => { - alive = false; - sseSessionQueues.delete(sessionId); - }; - // Send keepalive pings - const keepalive = setInterval(async () => { + keepalive = setInterval(async () => { try { await stream.writeSSE({ event: 'ping', data: '' }); } catch (err) { @@ -388,7 +393,6 @@ mcpServerRoutes.get( await new Promise(resolve => setTimeout(resolve, 100)); } } finally { - clearInterval(keepalive); cleanup(); } }); diff --git a/apps/api/src/routes/metrics.test.ts b/apps/api/src/routes/metrics.test.ts index 0f865c753e..046b294ce8 100644 --- a/apps/api/src/routes/metrics.test.ts +++ b/apps/api/src/routes/metrics.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Hono } from 'hono'; +const selectMock = vi.hoisted(() => vi.fn()); + vi.mock('../services', () => ({})); vi.mock('../db', () => ({ @@ -8,7 +10,7 @@ vi.mock('../db', () => ({ withDbAccessContext: vi.fn(async (_ctx: unknown, fn: () => Promise) => fn()), withSystemDbAccessContext: vi.fn(async (fn: () => Promise) => fn()), db: { - select: vi.fn(), + select: selectMock, insert: vi.fn(), update: vi.fn(), delete: vi.fn() @@ -33,6 +35,35 @@ vi.mock('../middleware/auth', () => ({ })); import { authMiddleware } from '../middleware/auth'; +import { + metricsMiddleware, + metricsRoutes, + recordAgentHeartbeat, + recordBackupCommandTimeout, + recordBackupDispatchFailure, + recordBackupVerificationResult, + recordBackupVerificationSkip, + recordHttpRequest, + recordRestoreTimeout, + recordScriptExecution, + recordSensitiveDataFinding, + recordSensitiveDataRemediationDecision, + recordSensitiveDataScanQueued, + resetMetricsForTesting, + setLowReadinessDevices, + updateBusinessMetrics, +} from './metrics'; +import { + recordAgentEnrollment, + recordCommandDispatch, + recordFailedLogin, +} from '../services/anomalyMetrics'; +import { + getS1MetricsSnapshot, + recordS1ActionDispatch, + recordS1ActionPollTransition, + recordS1SyncRun, +} from '../services/sentinelOne/metrics'; function mockRollupTrendRows(selectMock: ReturnType, rows: unknown[]) { selectMock.mockReturnValueOnce({ @@ -69,42 +100,19 @@ function getMetricLine(metrics: string, name: string, labels?: Record { let app: Hono; - let metricsRoutes: typeof import('./metrics').metricsRoutes; - let recordHttpRequest: typeof import('./metrics').recordHttpRequest; - let recordAgentHeartbeat: typeof import('./metrics').recordAgentHeartbeat; - let recordScriptExecution: typeof import('./metrics').recordScriptExecution; - let recordSensitiveDataFinding: typeof import('./metrics').recordSensitiveDataFinding; - let recordSensitiveDataRemediationDecision: typeof import('./metrics').recordSensitiveDataRemediationDecision; - let recordSensitiveDataScanQueued: typeof import('./metrics').recordSensitiveDataScanQueued; - let recordBackupDispatchFailure: typeof import('./metrics').recordBackupDispatchFailure; - let recordBackupCommandTimeout: typeof import('./metrics').recordBackupCommandTimeout; - let recordBackupVerificationResult: typeof import('./metrics').recordBackupVerificationResult; - let recordBackupVerificationSkip: typeof import('./metrics').recordBackupVerificationSkip; - let recordRestoreTimeout: typeof import('./metrics').recordRestoreTimeout; - let setLowReadinessDevices: typeof import('./metrics').setLowReadinessDevices; - let updateBusinessMetrics: typeof import('./metrics').updateBusinessMetrics; - let metricsMiddleware: typeof import('./metrics').metricsMiddleware; - - beforeEach(async () => { + + beforeEach(() => { vi.clearAllMocks(); - vi.resetModules(); + selectMock.mockReset(); + selectMock.mockReturnValue({ + from: () => ({ + where: () => Promise.resolve([{ count: 0 }]), + }), + }); + process.env.NODE_ENV = 'test'; process.env.METRICS_SCRAPE_TOKEN = 'test-scrape-token'; - const metricsModule = await import('./metrics'); - metricsRoutes = metricsModule.metricsRoutes; - recordHttpRequest = metricsModule.recordHttpRequest; - recordAgentHeartbeat = metricsModule.recordAgentHeartbeat; - recordScriptExecution = metricsModule.recordScriptExecution; - recordSensitiveDataFinding = metricsModule.recordSensitiveDataFinding; - recordSensitiveDataRemediationDecision = metricsModule.recordSensitiveDataRemediationDecision; - recordSensitiveDataScanQueued = metricsModule.recordSensitiveDataScanQueued; - recordBackupDispatchFailure = metricsModule.recordBackupDispatchFailure; - recordBackupCommandTimeout = metricsModule.recordBackupCommandTimeout; - recordBackupVerificationResult = metricsModule.recordBackupVerificationResult; - recordBackupVerificationSkip = metricsModule.recordBackupVerificationSkip; - recordRestoreTimeout = metricsModule.recordRestoreTimeout; - setLowReadinessDevices = metricsModule.setLowReadinessDevices; - updateBusinessMetrics = metricsModule.updateBusinessMetrics; - metricsMiddleware = metricsModule.metricsMiddleware; + delete process.env.METRICS_INCLUDE_ORG_ID; + resetMetricsForTesting(); app = new Hono(); app.route('/', metricsRoutes); }); @@ -181,13 +189,9 @@ describe('metrics routes', () => { it('returns 503 for /scrape when token is not configured', async () => { delete process.env.METRICS_SCRAPE_TOKEN; - vi.resetModules(); + resetMetricsForTesting(); - const metricsModule = await import('./metrics'); - const appNoToken = new Hono(); - appNoToken.route('/', metricsModule.metricsRoutes); - - const res = await appNoToken.request('/scrape', { + const res = await app.request('/scrape', { headers: { Authorization: 'Bearer test-scrape-token' } }); expect(res.status).toBe(503); @@ -301,7 +305,28 @@ describe('metrics routes', () => { ); }); + it('clears SentinelOne snapshot state when resetting metrics', () => { + recordS1SyncRun('sync-integration', 'success', 25); + recordS1ActionDispatch('isolate', 'accepted'); + recordS1ActionPollTransition('completed'); + + expect(getS1MetricsSnapshot().syncRuns).toHaveLength(1); + + resetMetricsForTesting(); + + expect(getS1MetricsSnapshot()).toEqual({ + syncRuns: [], + actionDispatches: [], + actionPollTransitions: [], + }); + }); + it('records backup operational metrics', async () => { + selectMock.mockReturnValueOnce({ + from: () => ({ + where: () => Promise.resolve([{ count: 3 }]), + }), + }); recordBackupDispatchFailure('manual_restore', 'device_offline'); recordBackupCommandTimeout('mssql_backup', 'sync_wait'); recordBackupVerificationResult('test_restore', 'failed'); @@ -343,17 +368,13 @@ describe('metrics routes', () => { }); it('records anomaly counters with tenant attribution (non-production)', async () => { - // metricsRoutes is already imported in beforeEach, which registers the - // anomaly recorder; importing anomalyMetrics after that resolves to the - // same module instance under the current resetModules state. - const anomaly = await import('../services/anomalyMetrics'); - anomaly.recordFailedLogin('invalid_password', 'org-1'); - anomaly.recordFailedLogin('invalid_password', 'org-1'); - anomaly.recordFailedLogin('rate_limited_ip'); - anomaly.recordAgentEnrollment('success', 'partner-1'); - anomaly.recordAgentEnrollment('denied'); - anomaly.recordCommandDispatch('reboot', 'user', 'org-1'); - anomaly.recordCommandDispatch('script', 'system'); + recordFailedLogin('invalid_password', 'org-1'); + recordFailedLogin('invalid_password', 'org-1'); + recordFailedLogin('rate_limited_ip'); + recordAgentEnrollment('success', 'partner-1'); + recordAgentEnrollment('denied'); + recordCommandDispatch('reboot', 'user', 'org-1'); + recordCommandDispatch('script', 'system'); const res = await app.request('/metrics', { headers: { Authorization: 'Bearer token' } @@ -385,16 +406,14 @@ describe('metrics routes', () => { const prevNodeEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'production'; process.env.METRICS_SCRAPE_TOKEN = 'test-scrape-token'; - vi.resetModules(); + resetMetricsForTesting(); try { - const metricsModule = await import('./metrics'); - const anomaly = await import('../services/anomalyMetrics'); - anomaly.recordFailedLogin('invalid_password', 'org-secret'); - anomaly.recordAgentEnrollment('success', 'partner-secret'); - anomaly.recordCommandDispatch('reboot', 'user', 'org-secret'); + recordFailedLogin('invalid_password', 'org-secret'); + recordAgentEnrollment('success', 'partner-secret'); + recordCommandDispatch('reboot', 'user', 'org-secret'); const prodApp = new Hono(); - prodApp.route('/', metricsModule.metricsRoutes); + prodApp.route('/', metricsRoutes); const res = await prodApp.request('/scrape', { headers: { Authorization: 'Bearer test-scrape-token' } }); @@ -414,7 +433,7 @@ describe('metrics routes', () => { ).toBe(true); } finally { process.env.NODE_ENV = prevNodeEnv; - vi.resetModules(); + resetMetricsForTesting(); } }); }); diff --git a/apps/api/src/routes/metrics.ts b/apps/api/src/routes/metrics.ts index 84340bff81..137ec2c6dc 100644 --- a/apps/api/src/routes/metrics.ts +++ b/apps/api/src/routes/metrics.ts @@ -14,7 +14,7 @@ import { deviceMetrics, devices, metricRollups, recoveryReadiness as recoveryRea import { authMiddleware, requirePermission, requireScope } from '../middleware/auth'; import { getTrustedClientIpOrUndefined } from '../services/clientIp'; import { PERMISSIONS } from '../services/permissions'; -import { BACKUP_LOW_READINESS_THRESHOLD } from './backup/verificationService'; +import { BACKUP_LOW_READINESS_THRESHOLD } from './backup/constants'; import { recordBackupCommandTimeout, recordBackupDispatchFailure, @@ -26,6 +26,7 @@ import { } from '../services/backupMetrics'; import { getS1MetricsSnapshot, + resetS1MetricsForTesting, setS1MetricsRecorder } from '../services/sentinelOne/metrics'; import { setAnomalyMetricsRecorder } from '../services/anomalyMetrics'; @@ -43,12 +44,16 @@ export { export const metricsRoutes = new Hono(); const requireMetricsRead = requirePermission(PERMISSIONS.DEVICES_READ.resource, PERMISSIONS.DEVICES_READ.action); -const rawMetricsScrapeToken = process.env.METRICS_SCRAPE_TOKEN?.trim(); -// Production hardening: refuse to run with obvious placeholder tokens. -const METRICS_SCRAPE_TOKEN = - (process.env.NODE_ENV ?? 'development') === 'production' && (!rawMetricsScrapeToken || rawMetricsScrapeToken === 'REDACTED_DEV_TOKEN') + +function resolveMetricsScrapeToken(): string | undefined { + const rawToken = process.env.METRICS_SCRAPE_TOKEN?.trim(); + // Production hardening: refuse to run with obvious placeholder tokens. + return (process.env.NODE_ENV ?? 'development') === 'production' && (!rawToken || rawToken === 'REDACTED_DEV_TOKEN') ? undefined - : rawMetricsScrapeToken; + : rawToken; +} + +let METRICS_SCRAPE_TOKEN = resolveMetricsScrapeToken(); function envFlag(name: string, fallback: boolean): boolean { const raw = process.env[name]; @@ -69,12 +74,16 @@ function safeEqual(a: string, b: string): boolean { } // Default: hide org IDs in Prometheus labels in production (they can leak tenant identifiers). -const METRICS_INCLUDE_ORG_ID = envFlag( - 'METRICS_INCLUDE_ORG_ID', - (process.env.NODE_ENV ?? 'development') !== 'production' -); +function resolveMetricsIncludeOrgId(): boolean { + return envFlag( + 'METRICS_INCLUDE_ORG_ID', + (process.env.NODE_ENV ?? 'development') !== 'production' + ); +} + +let METRICS_INCLUDE_ORG_ID = resolveMetricsIncludeOrgId(); -const METRICS_SCRAPE_IP_ALLOWLIST = parseCsvSet(process.env.METRICS_SCRAPE_IP_ALLOWLIST); +let METRICS_SCRAPE_IP_ALLOWLIST = parseCsvSet(process.env.METRICS_SCRAPE_IP_ALLOWLIST); const register = new Registry(); @@ -316,35 +325,39 @@ const nodejsVersionInfoGauge = new Gauge({ registers: [register] }); -httpRequestsInFlight.set(0); -devicesActiveGauge.set(0); -organizationsTotalGauge.set(0); -commandsTotalCounter.labels('script').inc(0); -alertsTotalCounter.labels('info').inc(0); -alertQueueLengthGauge.set(0); -agentHeartbeatTotal.labels('success').inc(0); -agentHeartbeatTotal.labels('failed').inc(0); -scriptsExecutedTotal.inc(0); -backupDispatchFailuresTotal.labels('manual_backup', 'device_offline').inc(0); -backupVerificationSkipsTotal.labels('integrity', 'device_offline').inc(0); -restoreTimeoutsTotal.labels('backup_restore').inc(0); -backupCommandTimeoutsTotal.labels('backup_restore', 'reaper').inc(0); -backupVerificationResultsTotal.labels('integrity', 'passed').inc(0); -backupLowReadinessDevicesGauge.set(0); -softwarePolicyEvaluationsTotal.labels('allowlist', 'compliant', 'evaluated').inc(0); -softwarePolicyViolationsTotal.labels('allowlist').inc(0); -softwareRemediationDecisionsTotal.labels('queued').inc(0); -s1SyncRunsTotal.labels('sync-integration', 'success').inc(0); -s1ActionDispatchTotal.labels('isolate', 'accepted').inc(0); -s1ActionPollTransitionsTotal.labels('queued').inc(0); -failedLoginsTotal.labels('invalid_password', 'redacted').inc(0); -agentEnrollmentsTotal.labels('success', 'redacted').inc(0); -commandsDispatchedTotal.labels('script', 'user', 'redacted').inc(0); -abuseSignalsFiredTotal.labels('alert').inc(0); -abuseSweepRunsTotal.labels('success').inc(0); -opsAlertDeliveriesTotal.labels('webhook', 'success').inc(0); -proxyTrustUntrustedPeerTotal.inc(0); -nodejsVersionInfoGauge.labels(process.version).set(1); +function initializeMetricDefaults(): void { + httpRequestsInFlight.set(0); + devicesActiveGauge.set(0); + organizationsTotalGauge.set(0); + commandsTotalCounter.labels('script').inc(0); + alertsTotalCounter.labels('info').inc(0); + alertQueueLengthGauge.set(0); + agentHeartbeatTotal.labels('success').inc(0); + agentHeartbeatTotal.labels('failed').inc(0); + scriptsExecutedTotal.inc(0); + backupDispatchFailuresTotal.labels('manual_backup', 'device_offline').inc(0); + backupVerificationSkipsTotal.labels('integrity', 'device_offline').inc(0); + restoreTimeoutsTotal.labels('backup_restore').inc(0); + backupCommandTimeoutsTotal.labels('backup_restore', 'reaper').inc(0); + backupVerificationResultsTotal.labels('integrity', 'passed').inc(0); + backupLowReadinessDevicesGauge.set(0); + softwarePolicyEvaluationsTotal.labels('allowlist', 'compliant', 'evaluated').inc(0); + softwarePolicyViolationsTotal.labels('allowlist').inc(0); + softwareRemediationDecisionsTotal.labels('queued').inc(0); + s1SyncRunsTotal.labels('sync-integration', 'success').inc(0); + s1ActionDispatchTotal.labels('isolate', 'accepted').inc(0); + s1ActionPollTransitionsTotal.labels('queued').inc(0); + failedLoginsTotal.labels('invalid_password', 'redacted').inc(0); + agentEnrollmentsTotal.labels('success', 'redacted').inc(0); + commandsDispatchedTotal.labels('script', 'user', 'redacted').inc(0); + abuseSignalsFiredTotal.labels('alert').inc(0); + abuseSweepRunsTotal.labels('success').inc(0); + opsAlertDeliveriesTotal.labels('webhook', 'success').inc(0); + proxyTrustUntrustedPeerTotal.inc(0); + nodejsVersionInfoGauge.labels(process.version).set(1); +} + +initializeMetricDefaults(); interface CounterValue { labels: Record; @@ -630,44 +643,83 @@ export function recordSoftwareRemediationDecision(decision: string, count = 1): }, safeCount); } -setS1MetricsRecorder({ - onSyncRun: (job, outcome, durationMs) => { - const safeDuration = Number.isFinite(durationMs) ? Math.max(durationMs, 0) : 0; - s1SyncRunsTotal.labels(job, outcome).inc(); - s1SyncDurationSeconds.labels(job, outcome).observe(safeDuration / 1000); - }, - onActionDispatch: (action, outcome) => { - s1ActionDispatchTotal.labels(action, outcome).inc(); - }, - onActionPollTransition: (status) => { - s1ActionPollTransitionsTotal.labels(status).inc(); - } -}); +function bindMetricsRecorders(): void { + setS1MetricsRecorder({ + onSyncRun: (job, outcome, durationMs) => { + const safeDuration = Number.isFinite(durationMs) ? Math.max(durationMs, 0) : 0; + s1SyncRunsTotal.labels(job, outcome).inc(); + s1SyncDurationSeconds.labels(job, outcome).observe(safeDuration / 1000); + }, + onActionDispatch: (action, outcome) => { + s1ActionDispatchTotal.labels(action, outcome).inc(); + }, + onActionPollTransition: (status) => { + s1ActionPollTransitionsTotal.labels(status).inc(); + } + }); -setBackupMetricsRecorder({ - onDispatchFailure: recordBackupDispatchFailureMetric, - onVerificationSkip: recordBackupVerificationSkipMetric, - onRestoreTimeout: recordRestoreTimeoutMetric, - onCommandTimeout: recordBackupCommandTimeoutMetric, - onVerificationResult: recordBackupVerificationResultMetric, - onLowReadinessDevices: setLowReadinessDevicesMetric, -}); + setBackupMetricsRecorder({ + onDispatchFailure: recordBackupDispatchFailureMetric, + onVerificationSkip: recordBackupVerificationSkipMetric, + onRestoreTimeout: recordRestoreTimeoutMetric, + onCommandTimeout: recordBackupCommandTimeoutMetric, + onVerificationResult: recordBackupVerificationResultMetric, + onLowReadinessDevices: setLowReadinessDevicesMetric, + }); -setAnomalyMetricsRecorder({ - onFailedLogin: recordFailedLoginMetric, - onAgentEnrollment: recordAgentEnrollmentMetric, - onCommandDispatch: recordCommandDispatchMetric, -}); + setAnomalyMetricsRecorder({ + onFailedLogin: recordFailedLoginMetric, + onAgentEnrollment: recordAgentEnrollmentMetric, + onCommandDispatch: recordCommandDispatchMetric, + }); -setAbuseMetricsRecorder({ - onSignalFired: (severity) => abuseSignalsFiredTotal.labels(normalizeMetricLabel(severity, 'unknown')).inc(), - onSweepRun: (result) => abuseSweepRunsTotal.labels(result).inc(), - onAlertDelivery: (channel, result) => opsAlertDeliveriesTotal.labels(normalizeMetricLabel(channel, 'unknown'), result).inc(), -}); + setAbuseMetricsRecorder({ + onSignalFired: (severity) => abuseSignalsFiredTotal.labels(normalizeMetricLabel(severity, 'unknown')).inc(), + onSweepRun: (result) => abuseSweepRunsTotal.labels(result).inc(), + onAlertDelivery: (channel, result) => opsAlertDeliveriesTotal.labels(normalizeMetricLabel(channel, 'unknown'), result).inc(), + }); -setProxyTrustMetricsRecorder({ - onForwardedHeadersFromUntrustedPeer: () => proxyTrustUntrustedPeerTotal.inc(), -}); + setProxyTrustMetricsRecorder({ + onForwardedHeadersFromUntrustedPeer: () => proxyTrustUntrustedPeerTotal.inc(), + }); +} + +bindMetricsRecorders(); + +export function resetMetricsForTesting(): void { + METRICS_SCRAPE_TOKEN = resolveMetricsScrapeToken(); + METRICS_INCLUDE_ORG_ID = resolveMetricsIncludeOrgId(); + METRICS_SCRAPE_IP_ALLOWLIST = parseCsvSet(process.env.METRICS_SCRAPE_IP_ALLOWLIST); + + resetS1MetricsForTesting(); + register.resetMetrics(); + initializeMetricDefaults(); + + httpRequestState.clear(); + agentHeartbeatState.clear(); + softwarePolicyEvaluationState.clear(); + softwareRemediationDecisionState.clear(); + sensitiveDataFindingState.clear(); + sensitiveDataRemediationState.clear(); + backupDispatchFailureState.clear(); + backupVerificationSkipState.clear(); + restoreTimeoutState.clear(); + backupCommandTimeoutState.clear(); + backupVerificationResultState.clear(); + + backupLowReadinessDevices = 0; + sensitiveDataScansQueuedTotal = 0; + devicesActive = 0; + organizationsTotal = 0; + commandsTotal = 0; + alertsTotal = 0; + alertQueueLength = 0; + scriptsExecutedCount = 0; + inFlightRequests = 0; + softwarePolicyViolationsCount = 0; + + bindMetricsRecorders(); +} async function refreshBackupOperationalGauges(): Promise { try { diff --git a/apps/api/src/routes/oauth.disabled.test.ts b/apps/api/src/routes/oauth.disabled.test.ts new file mode 100644 index 0000000000..1a1a0f0e52 --- /dev/null +++ b/apps/api/src/routes/oauth.disabled.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Hono } from 'hono'; + +const mocks = vi.hoisted(() => ({ + getProvider: vi.fn(), +})); + +vi.mock('../config/env', () => ({ + MCP_OAUTH_ENABLED: false, + OAUTH_DCR_ENABLED: false, + OAUTH_ISSUER: 'https://api.example', + OAUTH_RESOURCE_URL: 'https://api.example/api/v1/mcp', +})); +vi.mock('../oauth/provider', () => ({ getProvider: mocks.getProvider })); +vi.mock('../services/redis', () => ({ getRedis: vi.fn(() => null) })); +vi.mock('../services/rate-limit', () => ({ rateLimiter: vi.fn() })); + +import { oauthRoutes } from './oauth'; + +describe('oauthRoutes when MCP OAuth is disabled', () => { + it('does not mount the catch-all or resolve the provider', async () => { + const app = new Hono().route('/oauth', oauthRoutes); + const res = await app.request('/oauth/anything', { method: 'GET' }); + + expect(res.status).toBe(404); + expect(mocks.getProvider).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/routes/oauth.rate-limit.disabled.test.ts b/apps/api/src/routes/oauth.rate-limit.disabled.test.ts new file mode 100644 index 0000000000..63ab3515af --- /dev/null +++ b/apps/api/src/routes/oauth.rate-limit.disabled.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Hono } from 'hono'; + +const mocks = vi.hoisted(() => ({ + getProvider: vi.fn(), + getRedis: vi.fn(() => null), + rateLimiter: vi.fn(), +})); + +vi.mock('../config/env', () => ({ + MCP_OAUTH_ENABLED: false, + OAUTH_DCR_ENABLED: false, + OAUTH_ISSUER: 'https://test.example', + OAUTH_RESOURCE_URL: 'https://test.example/mcp/server', +})); +vi.mock('../oauth/provider', () => ({ getProvider: mocks.getProvider })); +vi.mock('../services/redis', () => ({ getRedis: mocks.getRedis })); +vi.mock('../services/rate-limit', () => ({ rateLimiter: mocks.rateLimiter })); + +import { oauthRoutes } from './oauth'; + +describe('oauthRoutes rate limits when MCP OAuth is disabled', () => { + it('does not mount the OAuth topology or call the rate limiter', async () => { + const app = new Hono().route('/oauth', oauthRoutes); + + const res = await app.request('/oauth/reg', { + method: 'POST', + headers: { 'x-forwarded-for': '203.0.113.60' }, + }); + + expect(res.status).toBe(404); + expect(mocks.getRedis).not.toHaveBeenCalled(); + expect(mocks.rateLimiter).not.toHaveBeenCalled(); + expect(mocks.getProvider).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/routes/oauth.rate-limit.test.ts b/apps/api/src/routes/oauth.rate-limit.test.ts index 862c1dd615..eadafb7234 100644 --- a/apps/api/src/routes/oauth.rate-limit.test.ts +++ b/apps/api/src/routes/oauth.rate-limit.test.ts @@ -1,7 +1,34 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { Hono } from 'hono'; import { createHash } from 'node:crypto'; +const configState = vi.hoisted(() => ({ + dcrEnabled: true, +})); + +type CallableDelegate = (...args: any[]) => any; + +const mocks = vi.hoisted(() => ({ + getProvider: vi.fn(), + getRedis: vi.fn(), + rateLimiter: vi.fn(), +})); + +vi.mock('../config/env', () => Object.defineProperty({ + MCP_OAUTH_ENABLED: true, + OAUTH_ISSUER: 'https://test.example', + OAUTH_RESOURCE_URL: 'https://test.example/mcp/server', +}, 'OAUTH_DCR_ENABLED', { + enumerable: true, + configurable: true, + get: () => configState.dcrEnabled, +})); +vi.mock('../oauth/provider', () => ({ getProvider: mocks.getProvider })); +vi.mock('../services/redis', () => ({ getRedis: mocks.getRedis })); +vi.mock('../services/rate-limit', () => ({ rateLimiter: mocks.rateLimiter })); + +import { oauthRoutes } from './oauth'; + const ENV_KEYS = [ 'MCP_OAUTH_ENABLED', 'OAUTH_ISSUER', @@ -11,6 +38,7 @@ const ENV_KEYS = [ 'OAUTH_DCR_ENABLED', 'NODE_ENV', 'TRUST_PROXY_HEADERS', + 'TRUSTED_PROXY_CIDRS', ] as const; const resetEnv = () => { @@ -22,31 +50,10 @@ const resetAt = new Date('2026-04-23T00:00:00.000Z'); const tokenClientKey = (clientId: string) => `oauth:token:client:${createHash('sha256').update(clientId).digest('hex').slice(0, 32)}`; -const importApp = async ( - rateLimiter: ReturnType = vi.fn(async () => ({ allowed: true, remaining: 1, resetAt })), +const loadApp = ( + rateLimiter: CallableDelegate = vi.fn(async () => ({ allowed: true, remaining: 1, resetAt })), ) => { - process.env.MCP_OAUTH_ENABLED = 'true'; - // DCR defaults to OFF in every environment (Task 21). Most tests in this - // file exercise registration-endpoint metadata + rate-limit policy, which - // requires DCR enabled. Tests that exercise the "DCR disabled" path set - // OAUTH_DCR_ENABLED=false explicitly before calling importApp. - if (process.env.OAUTH_DCR_ENABLED === undefined && process.env.NODE_ENV !== 'production') { - process.env.OAUTH_DCR_ENABLED = 'true'; - } - vi.doMock('../services/redis', () => ({ - getRedis: vi.fn(() => null), - })); - vi.doMock('../services/rate-limit', () => ({ - rateLimiter, - })); - vi.doMock('../oauth/provider', () => ({ - getProvider: vi.fn(async () => { - throw new Error('provider sentinel'); - }), - })); - vi.resetModules(); - - const { oauthRoutes } = await import('./oauth'); + mocks.rateLimiter.mockImplementation(rateLimiter); const app = new Hono(); app.onError(() => new Response('provider sentinel', { status: 200 })); app.route('/oauth', oauthRoutes); @@ -56,14 +63,13 @@ const importApp = async ( describe('oauthRoutes rate limits', () => { beforeEach(() => { resetEnv(); - vi.resetModules(); - }); - - afterEach(() => { - resetEnv(); - vi.doUnmock('../services/redis'); - vi.doUnmock('../services/rate-limit'); - vi.doUnmock('../oauth/provider'); + configState.dcrEnabled = true; + mocks.getProvider.mockReset(); + mocks.getProvider.mockRejectedValue(new Error('provider sentinel')); + mocks.getRedis.mockReset(); + mocks.getRedis.mockReturnValue(null); + mocks.rateLimiter.mockReset(); + mocks.rateLimiter.mockResolvedValue({ allowed: true, remaining: 1, resetAt }); }); it('returns 429 on the 11th POST /oauth/reg from the same IP', async () => { @@ -72,7 +78,7 @@ describe('oauthRoutes rate limits', () => { remaining: 0, resetAt, })); - const app = await importApp(rateLimiter); + const app = await loadApp(rateLimiter); for (let i = 0; i < 10; i++) { const res = await app.request('/oauth/reg', { @@ -94,8 +100,9 @@ describe('oauthRoutes rate limits', () => { it('disables DCR by default in production', async () => { process.env.NODE_ENV = 'production'; + configState.dcrEnabled = false; const rateLimiter = vi.fn(async () => ({ allowed: true, remaining: 9, resetAt })); - const app = await importApp(rateLimiter); + const app = await loadApp(rateLimiter); const res = await app.request('/oauth/reg', { method: 'POST', @@ -118,9 +125,9 @@ describe('oauthRoutes rate limits', () => { it('allows DCR in production when OAUTH_DCR_ENABLED=true', async () => { process.env.NODE_ENV = 'production'; - process.env.OAUTH_DCR_ENABLED = 'true'; + configState.dcrEnabled = true; const rateLimiter = vi.fn(async () => ({ allowed: true, remaining: 9, resetAt })); - const app = await importApp(rateLimiter); + const app = await loadApp(rateLimiter); const res = await app.request('/oauth/reg', { method: 'POST', @@ -142,7 +149,7 @@ describe('oauthRoutes rate limits', () => { it('returns 413 for oversized POST /oauth/reg bodies before provider bridge', async () => { const rateLimiter = vi.fn(async () => ({ allowed: true, remaining: 9, resetAt })); - const app = await importApp(rateLimiter); + const app = await loadApp(rateLimiter); const res = await app.request('/oauth/reg', { method: 'POST', @@ -163,7 +170,7 @@ describe('oauthRoutes rate limits', () => { it('rejects DCR metadata with too many redirect URIs', async () => { const rateLimiter = vi.fn(async () => ({ allowed: true, remaining: 9, resetAt })); - const app = await importApp(rateLimiter); + const app = await loadApp(rateLimiter); const res = await app.request('/oauth/reg', { method: 'POST', @@ -186,7 +193,7 @@ describe('oauthRoutes rate limits', () => { it('rejects DCR metadata with unsupported scopes', async () => { const rateLimiter = vi.fn(async () => ({ allowed: true, remaining: 9, resetAt })); - const app = await importApp(rateLimiter); + const app = await loadApp(rateLimiter); const res = await app.request('/oauth/reg', { method: 'POST', @@ -210,7 +217,7 @@ describe('oauthRoutes rate limits', () => { it('rejects confidential DCR token endpoint auth methods', async () => { const rateLimiter = vi.fn(async () => ({ allowed: true, remaining: 9, resetAt })); - const app = await importApp(rateLimiter); + const app = await loadApp(rateLimiter); const res = await app.request('/oauth/reg', { method: 'POST', @@ -234,7 +241,7 @@ describe('oauthRoutes rate limits', () => { it('rejects unsupported DCR grant and response types', async () => { const rateLimiter = vi.fn(async () => ({ allowed: true, remaining: 9, resetAt })); - const app = await importApp(rateLimiter); + const app = await loadApp(rateLimiter); const grantRes = await app.request('/oauth/reg', { method: 'POST', @@ -275,7 +282,7 @@ describe('oauthRoutes rate limits', () => { it('rejects DCR remote key and request metadata', async () => { const rateLimiter = vi.fn(async () => ({ allowed: true, remaining: 9, resetAt })); - const app = await importApp(rateLimiter); + const app = await loadApp(rateLimiter); const res = await app.request('/oauth/reg', { method: 'POST', @@ -299,7 +306,7 @@ describe('oauthRoutes rate limits', () => { it('applies the same DCR metadata policy to registration-management updates', async () => { const rateLimiter = vi.fn(async () => ({ allowed: true, remaining: 9, resetAt })); - const app = await importApp(rateLimiter); + const app = await loadApp(rateLimiter); const res = await app.request('/oauth/reg/client-1', { method: 'PUT', @@ -325,7 +332,7 @@ describe('oauthRoutes rate limits', () => { it('rejects DCR registration with a remote http:// redirect_uri (MCP-OAUTH-09)', async () => { const rateLimiter = vi.fn(async () => ({ allowed: true, remaining: 9, resetAt })); - const app = await importApp(rateLimiter); + const app = await loadApp(rateLimiter); const res = await app.request('/oauth/reg', { method: 'POST', @@ -347,7 +354,7 @@ describe('oauthRoutes rate limits', () => { it('rejects http://localhost redirect_uri but not the loopback IP (RFC 8252)', async () => { const rateLimiter = vi.fn(async () => ({ allowed: true, remaining: 9, resetAt })); - const rejected = await (await importApp(rateLimiter)).request('/oauth/reg', { + const rejected = await (await loadApp(rateLimiter)).request('/oauth/reg', { method: 'POST', headers: { 'content-type': 'application/json', 'x-forwarded-for': '203.0.113.31' }, body: JSON.stringify({ client_name: 'x', redirect_uris: ['http://localhost:8080/cb'] }), @@ -357,7 +364,7 @@ describe('oauthRoutes rate limits', () => { // The literal loopback IP passes the pre-handler and falls through to the // provider bridge (mocked to throw → onError yields the 200 sentinel). - const accepted = await (await importApp(rateLimiter)).request('/oauth/reg', { + const accepted = await (await loadApp(rateLimiter)).request('/oauth/reg', { method: 'POST', headers: { 'content-type': 'application/json', 'x-forwarded-for': '203.0.113.31' }, body: JSON.stringify({ client_name: 'x', redirect_uris: ['http://127.0.0.1:49152/cb'] }), @@ -367,7 +374,7 @@ describe('oauthRoutes rate limits', () => { it('applies the redirect-uri transport policy to registration-management updates (MCP-OAUTH-09)', async () => { const rateLimiter = vi.fn(async () => ({ allowed: true, remaining: 9, resetAt })); - const app = await importApp(rateLimiter); + const app = await loadApp(rateLimiter); const res = await app.request('/oauth/reg/client-1', { method: 'PUT', @@ -384,7 +391,7 @@ describe('oauthRoutes rate limits', () => { it('rate-limits registration-management deletes', async () => { const rateLimiter = vi.fn(async () => ({ allowed: false, remaining: 0, resetAt })); - const app = await importApp(rateLimiter); + const app = await loadApp(rateLimiter); const res = await app.request('/oauth/reg/client-1', { method: 'DELETE', @@ -398,7 +405,7 @@ describe('oauthRoutes rate limits', () => { it('rate-limits registration-management lookups', async () => { const rateLimiter = vi.fn(async () => ({ allowed: false, remaining: 0, resetAt })); - const app = await importApp(rateLimiter); + const app = await loadApp(rateLimiter); const res = await app.request('/oauth/reg/client-1', { method: 'GET', @@ -412,7 +419,7 @@ describe('oauthRoutes rate limits', () => { it('keys POST /oauth/token by IP and client_id when client_id is present', async () => { const rateLimiter = vi.fn(async () => ({ allowed: true, remaining: 59, resetAt })); - const app = await importApp(rateLimiter); + const app = await loadApp(rateLimiter); const res = await app.request('/oauth/token', { method: 'POST', @@ -430,7 +437,7 @@ describe('oauthRoutes rate limits', () => { it('keys POST /oauth/token by IP when client_id is missing', async () => { const rateLimiter = vi.fn(async () => ({ allowed: true, remaining: 59, resetAt })); - const app = await importApp(rateLimiter); + const app = await loadApp(rateLimiter); const res = await app.request('/oauth/token', { method: 'POST', @@ -450,7 +457,7 @@ describe('oauthRoutes rate limits', () => { process.env.NODE_ENV = 'production'; process.env.TRUST_PROXY_HEADERS = 'false'; const rateLimiter = vi.fn(async () => ({ allowed: true, remaining: 59, resetAt })); - const app = await importApp(rateLimiter); + const app = await loadApp(rateLimiter); const res = await app.request('/oauth/token', { method: 'POST', @@ -473,7 +480,7 @@ describe('oauthRoutes rate limits', () => { remaining: 0, resetAt, })); - const app = await importApp(rateLimiter); + const app = await loadApp(rateLimiter); for (let i = 0; i < 60; i++) { const res = await app.request('/oauth/token', { @@ -503,7 +510,7 @@ describe('oauthRoutes rate limits', () => { } return { allowed: true, remaining: 59, resetAt }; }); - const app = await importApp(rateLimiter); + const app = await loadApp(rateLimiter); for (let i = 0; i < 30; i++) { const res = await app.request('/oauth/token', { @@ -533,7 +540,7 @@ describe('oauthRoutes rate limits', () => { it('returns 413 for oversized POST /oauth/token bodies before provider bridge', async () => { const rateLimiter = vi.fn(async () => ({ allowed: true, remaining: 59, resetAt })); - const app = await importApp(rateLimiter); + const app = await loadApp(rateLimiter); const res = await app.request('/oauth/token', { method: 'POST', @@ -554,7 +561,7 @@ describe('oauthRoutes rate limits', () => { it('rate-limits POST /oauth/token/revocation by IP', async () => { const rateLimiter = vi.fn(async () => ({ allowed: false, remaining: 0, resetAt })); - const app = await importApp(rateLimiter); + const app = await loadApp(rateLimiter); const res = await app.request('/oauth/token/revocation', { method: 'POST', @@ -572,7 +579,7 @@ describe('oauthRoutes rate limits', () => { it('keys GET /oauth/auth by IP and rate-limits at 20/minute', async () => { const rateLimiter = vi.fn(async () => ({ allowed: false, remaining: 0, resetAt })); - const app = await importApp(rateLimiter); + const app = await loadApp(rateLimiter); const res = await app.request('/oauth/auth', { method: 'GET', @@ -586,7 +593,7 @@ describe('oauthRoutes rate limits', () => { it('does not rate-limit other OAuth paths', async () => { const rateLimiter = vi.fn(async () => ({ allowed: false, remaining: 0, resetAt })); - const app = await importApp(rateLimiter); + const app = await loadApp(rateLimiter); const res = await app.request('/oauth/me', { method: 'POST', @@ -599,7 +606,7 @@ describe('oauthRoutes rate limits', () => { it('returns 400 when DCR receives malformed JSON (does not silently treat as empty)', async () => { const rateLimiter = vi.fn(async () => ({ allowed: true, remaining: 9, resetAt })); - const app = await importApp(rateLimiter); + const app = await loadApp(rateLimiter); const res = await app.request('/oauth/reg', { method: 'POST', @@ -619,7 +626,7 @@ describe('oauthRoutes rate limits', () => { it('preserves token rawBody for the oidc-provider bridge to replay (production node path)', async () => { const rateLimiter = vi.fn(async () => ({ allowed: true, remaining: 59, resetAt })); - const app = await importApp(rateLimiter); + const app = await loadApp(rateLimiter); // Simulate a real Node IncomingMessage (with .on()) so the route uses // the rawBody pre-buffer path. Hono's app.request accepts an env arg @@ -659,24 +666,4 @@ describe('oauthRoutes rate limits', () => { expect((incoming.rawBody as Buffer).toString('utf8')).toBe(bodyStr); }); - it('does not attach the middleware when MCP_OAUTH_ENABLED is false', async () => { - const rateLimiter = vi.fn(async () => ({ allowed: false, remaining: 0, resetAt })); - vi.doMock('../services/redis', () => ({ - getRedis: vi.fn(() => null), - })); - vi.doMock('../services/rate-limit', () => ({ - rateLimiter, - })); - vi.resetModules(); - - const { oauthRoutes } = await import('./oauth'); - const app = new Hono().route('/oauth', oauthRoutes); - const res = await app.request('/oauth/reg', { - method: 'POST', - headers: { 'x-forwarded-for': '203.0.113.60' }, - }); - - expect(res.status).toBe(404); - expect(rateLimiter).not.toHaveBeenCalled(); - }); }); diff --git a/apps/api/src/routes/oauth.revocation.test.ts b/apps/api/src/routes/oauth.revocation.test.ts index 0bc4a84e00..4d069d4245 100644 --- a/apps/api/src/routes/oauth.revocation.test.ts +++ b/apps/api/src/routes/oauth.revocation.test.ts @@ -1,8 +1,47 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest'; import { Hono } from 'hono'; import { randomUUID } from 'crypto'; +import type { JWK } from 'jose'; import { generateTestKeypair, signTestJwt } from '../oauth/testHelpers'; +const configState = vi.hoisted(() => ({ + privateJwks: '', +})); + +type CallableDelegate = (...args: any[]) => any; + +const mocks = vi.hoisted(() => ({ + getProvider: vi.fn(), + revokeJti: vi.fn(), + revokeGrant: vi.fn(), + isJtiRevoked: vi.fn(), + isGrantRevoked: vi.fn(), + getRedis: vi.fn(), + rateLimiter: vi.fn(), +})); + +vi.mock('../config/env', () => Object.defineProperty({ + MCP_OAUTH_ENABLED: true, + OAUTH_DCR_ENABLED: false, + OAUTH_ISSUER: 'https://test.example', + OAUTH_RESOURCE_URL: 'https://test.example/mcp/server', +}, 'OAUTH_JWKS_PRIVATE_JWK', { + enumerable: true, + configurable: true, + get: () => configState.privateJwks, +})); +vi.mock('../oauth/provider', () => ({ getProvider: mocks.getProvider })); +vi.mock('../oauth/revocationCache', () => ({ + revokeJti: mocks.revokeJti, + revokeGrant: mocks.revokeGrant, + isJtiRevoked: mocks.isJtiRevoked, + isGrantRevoked: mocks.isGrantRevoked, +})); +vi.mock('../services/redis', () => ({ getRedis: mocks.getRedis })); +vi.mock('../services/rate-limit', () => ({ rateLimiter: mocks.rateLimiter })); + +import { oauthRoutes } from './oauth'; + const ENV_KEYS = [ 'MCP_OAUTH_ENABLED', 'OAUTH_ISSUER', @@ -20,53 +59,37 @@ const AUDIENCE = 'https://test.example/mcp/server'; interface Harness { app: Hono; - privateJwk: import('jose').JWK; + privateJwk: JWK; kid: string; - revokeJti: ReturnType; - revokeGrant: ReturnType; + revokeJti: typeof mocks.revokeJti; + revokeGrant: typeof mocks.revokeGrant; providerCalled: () => number; } -const importHarness = async ( +let privateJwk: JWK; +let kid: string; + +const app = new Hono(); +app.onError((err) => new Response(`bridge-reached:${(err as Error).message}`, { status: 599 })); +app.route('/oauth', oauthRoutes); + +const getHarness = ( cacheBehavior: { - revokeJti?: ReturnType; - revokeGrant?: ReturnType; + revokeJti?: CallableDelegate; + revokeGrant?: CallableDelegate; } = {} ): Promise => { - const kp = await generateTestKeypair(); - // Provide a JWKS env var with both private+public so loadJwks() works. - process.env.MCP_OAUTH_ENABLED = 'true'; - process.env.OAUTH_ISSUER = ISSUER; - process.env.OAUTH_RESOURCE_URL = AUDIENCE; - process.env.OAUTH_COOKIE_SECRET = 'x'.repeat(48); - process.env.OAUTH_JWKS_PRIVATE_JWK = JSON.stringify({ keys: [kp.privateJwk] }); - - const revokeJti = cacheBehavior.revokeJti ?? vi.fn(async () => undefined); - const revokeGrant = cacheBehavior.revokeGrant ?? vi.fn(async () => undefined); - - let providerCalls = 0; - vi.doMock('../oauth/provider', () => ({ - getProvider: vi.fn(async () => { - providerCalls += 1; - // Throw a sentinel so the catch-all bridge resolves to a known error. - throw new Error('provider sentinel — bridge reached'); - }), - })); - vi.doMock('../oauth/revocationCache', () => ({ revokeJti, revokeGrant, isJtiRevoked: vi.fn(), isGrantRevoked: vi.fn() })); - vi.doMock('../services/redis', () => ({ getRedis: vi.fn(() => null) })); - vi.doMock('../services/rate-limit', () => ({ - rateLimiter: vi.fn(async () => ({ allowed: true, remaining: 1, resetAt: new Date() })), - })); - vi.resetModules(); - - const { oauthRoutes } = await import('./oauth'); - const app = new Hono(); - // Surface the bridge sentinel as 599 so tests can distinguish "fell through to bridge" - // from "short-circuited at pre-handler". - app.onError((err) => new Response(`bridge-reached:${(err as Error).message}`, { status: 599 })); - app.route('/oauth', oauthRoutes); - - return { app, privateJwk: kp.privateJwk, kid: kp.kid, revokeJti, revokeGrant, providerCalled: () => providerCalls }; + if (cacheBehavior.revokeJti) mocks.revokeJti.mockImplementation(cacheBehavior.revokeJti); + if (cacheBehavior.revokeGrant) mocks.revokeGrant.mockImplementation(cacheBehavior.revokeGrant); + + return Promise.resolve({ + app, + privateJwk, + kid, + revokeJti: mocks.revokeJti, + revokeGrant: mocks.revokeGrant, + providerCalled: () => mocks.getProvider.mock.calls.length, + }); }; const post = (app: Hono, body: Record) => @@ -84,20 +107,32 @@ const postRaw = (app: Hono, body: string) => }); describe('POST /oauth/token/revocation pre-handler — JWT signature gating', () => { - beforeEach(() => { - clearEnv(); - vi.resetModules(); + beforeAll(async () => { + const keypair = await generateTestKeypair(); + privateJwk = keypair.privateJwk; + kid = keypair.kid; + configState.privateJwks = JSON.stringify({ keys: [privateJwk] }); }); - afterEach(() => { + + beforeEach(() => { clearEnv(); - vi.doUnmock('../oauth/provider'); - vi.doUnmock('../oauth/revocationCache'); - vi.doUnmock('../services/redis'); - vi.doUnmock('../services/rate-limit'); + configState.privateJwks = JSON.stringify({ keys: [privateJwk] }); + mocks.getProvider.mockReset(); + mocks.getProvider.mockRejectedValue(new Error('provider sentinel — bridge reached')); + mocks.revokeJti.mockReset(); + mocks.revokeJti.mockResolvedValue(undefined); + mocks.revokeGrant.mockReset(); + mocks.revokeGrant.mockResolvedValue(undefined); + mocks.isJtiRevoked.mockReset(); + mocks.isGrantRevoked.mockReset(); + mocks.getRedis.mockReset(); + mocks.getRedis.mockReturnValue(null); + mocks.rateLimiter.mockReset(); + mocks.rateLimiter.mockResolvedValue({ allowed: true, remaining: 1, resetAt: new Date() }); }); it('does NOT write the cache for a forged JWT signed with a foreign key', async () => { - const h = await importHarness(); + const h = await getHarness(); // Sign with a DIFFERENT keypair than the one in OAUTH_JWKS_PRIVATE_JWK const foreignKp = await generateTestKeypair(); const forged = await signTestJwt( @@ -121,7 +156,7 @@ describe('POST /oauth/token/revocation pre-handler — JWT signature gating', () // caller is not authorized to act on. Returning 401/400/599 here used // to leak token validity to a probing client. The cache MUST stay // untouched (the legitimate owner can still use the token). - const h = await importHarness(); + const h = await getHarness(); const tokenForA = await signTestJwt( h.privateJwk, h.kid, @@ -141,7 +176,7 @@ describe('POST /oauth/token/revocation pre-handler — JWT signature gating', () }); it('returns 200 when the request omits client_id entirely (no leak)', async () => { - const h = await importHarness(); + const h = await getHarness(); const token = await signTestJwt( h.privateJwk, h.kid, @@ -157,7 +192,7 @@ describe('POST /oauth/token/revocation pre-handler — JWT signature gating', () }); it('writes the cache and short-circuits 200 when JWT + client_id both verify', async () => { - const h = await importHarness(); + const h = await getHarness(); const jti = randomUUID(); const grantId = randomUUID(); const token = await signTestJwt( @@ -176,7 +211,7 @@ describe('POST /oauth/token/revocation pre-handler — JWT signature gating', () }); it('returns 503 when the JTI cache write fails (Redis-down propagation, NOT 200)', async () => { - const h = await importHarness({ + const h = await getHarness({ revokeJti: vi.fn(async () => { throw new Error('Redis unavailable'); }), @@ -196,7 +231,7 @@ describe('POST /oauth/token/revocation pre-handler — JWT signature gating', () }); it('returns 503 when the GRANT cache write fails (after JTI succeeded)', async () => { - const h = await importHarness({ + const h = await getHarness({ revokeGrant: vi.fn(async () => { throw new Error('Redis unavailable'); }), @@ -214,7 +249,7 @@ describe('POST /oauth/token/revocation pre-handler — JWT signature gating', () }); it('rejects oversized revocation bodies before provider bridge or cache writes', async () => { - const h = await importHarness(); + const h = await getHarness(); const oversized = `token=${'a'.repeat(70 * 1024)}&client_id=client-A`; const res = await postRaw(h.app, oversized); @@ -228,7 +263,7 @@ describe('POST /oauth/token/revocation pre-handler — JWT signature gating', () }); it('falls through to bridge for opaque (non three-part) refresh tokens', async () => { - const h = await importHarness(); + const h = await getHarness(); const res = await post(h.app, { token: 'opaque-refresh-token-no-dots', client_id: 'client-A' }); // Bridge is reached (sentinel) and revocation cache is untouched. diff --git a/apps/api/src/routes/oauth.test.ts b/apps/api/src/routes/oauth.test.ts index d0d09b724c..aba92f4836 100644 --- a/apps/api/src/routes/oauth.test.ts +++ b/apps/api/src/routes/oauth.test.ts @@ -1,91 +1,56 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { Hono } from 'hono'; import { Readable } from 'node:stream'; -const ENV_KEYS = [ - 'MCP_OAUTH_ENABLED', - 'OAUTH_ISSUER', - 'OAUTH_RESOURCE_URL', - 'OAUTH_COOKIE_SECRET', - 'OAUTH_JWKS_PRIVATE_JWK', -] as const; - -const clearEnv = () => { - for (const key of ENV_KEYS) delete process.env[key]; -}; - -describe('oauthRoutes', () => { - beforeEach(() => { - clearEnv(); - vi.resetModules(); +const mocks = vi.hoisted(() => ({ + getProvider: vi.fn(), + rateLimiter: vi.fn(), + getRedis: vi.fn(() => null), +})); + +vi.mock('../config/env', () => ({ + MCP_OAUTH_ENABLED: true, + OAUTH_DCR_ENABLED: false, + OAUTH_ISSUER: 'https://region.example', + OAUTH_RESOURCE_URL: 'https://region.example/api/v1/mcp', +})); + +vi.mock('../oauth/provider', () => ({ getProvider: mocks.getProvider })); +vi.mock('../services/redis', () => ({ getRedis: mocks.getRedis })); +vi.mock('../services/rate-limit', () => ({ rateLimiter: mocks.rateLimiter })); + +import { oauthRoutes } from './oauth'; + +function loadApp() { + return new Hono().route('/oauth', oauthRoutes); +} + +beforeEach(() => { + mocks.getProvider.mockReset(); + mocks.getProvider.mockRejectedValue(new Error('bridge sentinel')); + mocks.rateLimiter.mockReset(); + mocks.rateLimiter.mockResolvedValue({ + allowed: true, + remaining: 1, + resetAt: new Date('2026-04-23T00:00:00.000Z'), }); +}); - afterEach(() => { - clearEnv(); - vi.doUnmock('../oauth/provider'); - }); +describe('oauthRoutes', () => { + it('mounts a catch-all when MCP_OAUTH_ENABLED is true (provider call deferred)', async () => { + const app = loadApp(); + expect(mocks.getProvider).not.toHaveBeenCalled(); - it('does not mount the catch-all when MCP_OAUTH_ENABLED is false', async () => { - const { oauthRoutes } = await import('./oauth'); - const app = new Hono().route('/oauth', oauthRoutes); const res = await app.request('/oauth/anything', { method: 'GET' }); - expect(res.status).toBe(404); - }); - it('mounts a catch-all when MCP_OAUTH_ENABLED is true (provider call deferred)', async () => { - process.env.MCP_OAUTH_ENABLED = 'true'; - vi.doMock('../oauth/provider', () => ({ - getProvider: vi.fn(async () => { - throw new Error('provider not ready in this smoke test'); - }), - })); - vi.resetModules(); - - const { oauthRoutes } = await import('./oauth'); - const app = new Hono().route('/oauth', oauthRoutes); - const res = await app.request('/oauth/anything', { method: 'GET' }); expect(res.status).toBe(500); + expect(mocks.getProvider).toHaveBeenCalledTimes(1); }); }); describe('oauthRoutes — resource-indicator alias normalization (#2363)', () => { const RESOURCE = 'https://region.example/api/v1/mcp'; - beforeEach(() => { - clearEnv(); - vi.resetModules(); - }); - - afterEach(() => { - clearEnv(); - vi.doUnmock('../oauth/provider'); - vi.doUnmock('../services/redis'); - vi.doUnmock('../services/rate-limit'); - }); - - /** - * Build the routes with MCP OAuth enabled, the provider bridge mocked to - * throw a sentinel (the pre-handler under test runs BEFORE the bridge), - * and Redis/rate-limit mocked so the /token and /auth limiter branches - * don't need infrastructure. - */ - const importApp = async (): Promise => { - process.env.MCP_OAUTH_ENABLED = 'true'; - process.env.OAUTH_RESOURCE_URL = RESOURCE; - vi.doMock('../oauth/provider', () => ({ - getProvider: vi.fn(async () => { - throw new Error('bridge sentinel'); - }), - })); - vi.doMock('../services/redis', () => ({ getRedis: vi.fn(() => null) })); - vi.doMock('../services/rate-limit', () => ({ - rateLimiter: vi.fn(async () => ({ allowed: true, remaining: 1, resetAt: new Date() })), - })); - vi.resetModules(); - const { oauthRoutes } = await import('./oauth'); - return new Hono().route('/oauth', oauthRoutes); - }; - /** Fake Node IncomingMessage: a real Readable plus headers/url. */ const fakeIncoming = (opts: { body?: string; url?: string }) => { const stream = (opts.body !== undefined @@ -104,7 +69,7 @@ describe('oauthRoutes — resource-indicator alias normalization (#2363)', () => }; it('rewrites an /sse-alias resource in the token body to the canonical resource before the bridge', async () => { - const app = await importApp(); + const app = loadApp(); const body = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: 'rt-1', @@ -131,7 +96,7 @@ describe('oauthRoutes — resource-indicator alias normalization (#2363)', () => }); it('leaves an unrelated resource untouched in the token body (still fails invalid_target downstream)', async () => { - const app = await importApp(); + const app = loadApp(); const body = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: 'rt-1', @@ -152,7 +117,7 @@ describe('oauthRoutes — resource-indicator alias normalization (#2363)', () => }); it('rewrites an alias resource in the authorization request query before the bridge reads incoming.url', async () => { - const app = await importApp(); + const app = loadApp(); const query = new URLSearchParams({ response_type: 'code', client_id: 'client-1', @@ -171,7 +136,7 @@ describe('oauthRoutes — resource-indicator alias normalization (#2363)', () => }); it('does not touch the authorization URL when the resource is already canonical', async () => { - const app = await importApp(); + const app = loadApp(); const query = new URLSearchParams({ response_type: 'code', client_id: 'client-1', diff --git a/apps/api/src/routes/oauthInteraction.disabled.test.ts b/apps/api/src/routes/oauthInteraction.disabled.test.ts new file mode 100644 index 0000000000..7ffa1fc977 --- /dev/null +++ b/apps/api/src/routes/oauthInteraction.disabled.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Hono } from 'hono'; + +vi.mock('../config/env', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + MCP_OAUTH_ENABLED: false, + OAUTH_ISSUER: 'https://api.example', + OAUTH_RESOURCE_URL: 'https://api.example/mcp/server', + BILLING_URL: '', + }; +}); + +vi.mock('../oauth/provider', () => ({ getProvider: vi.fn() })); +vi.mock('../oauth/adapter', () => ({ setGrantBreezeMeta: vi.fn() })); +vi.mock('../oauth/effectiveScopes', () => ({ computeEffectiveMcpScopes: vi.fn() })); +vi.mock('../oauth/log', () => ({ + ERROR_IDS: {}, + logOauthError: vi.fn(), + logOauthDebug: vi.fn(), + logOAuthEvent: vi.fn(), +})); +vi.mock('../middleware/auth', () => ({ authMiddleware: vi.fn() })); +vi.mock('../db', () => ({ + db: {}, + runOutsideDbContext: vi.fn(), + withSystemDbAccessContext: vi.fn(), +})); +vi.mock('../db/schema', () => ({ + oauthClients: {}, + oauthClientPartnerGrants: {}, + partners: {}, + partnerUsers: {}, + users: {}, +})); +vi.mock('../services/auditEvents', () => ({ writeRouteAudit: vi.fn() })); + +import { oauthInteractionRoutes } from './oauthInteraction'; + +describe('oauthInteractionRoutes when MCP OAuth is disabled', () => { + it('does not mount interaction routes', async () => { + const app = new Hono().route('/api/v1/oauth', oauthInteractionRoutes); + const res = await app.request('/api/v1/oauth/interaction/uid-1'); + + expect(res.status).toBe(404); + }); +}); diff --git a/apps/api/src/routes/oauthInteraction.test.ts b/apps/api/src/routes/oauthInteraction.test.ts index 5c6e6c55e7..acde599376 100644 --- a/apps/api/src/routes/oauthInteraction.test.ts +++ b/apps/api/src/routes/oauthInteraction.test.ts @@ -1,9 +1,13 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Hono } from 'hono'; import { HTTPException } from 'hono/http-exception'; const PARTNER_ID = '11111111-1111-4111-8111-111111111111'; +const configState = vi.hoisted(() => ({ + billingUrl: undefined as string | undefined, +})); + const mocks = vi.hoisted(() => { class Grant { static instances: Grant[] = []; @@ -31,9 +35,31 @@ const mocks = vi.hoisted(() => { runOutsideDbContext: vi.fn((fn: () => unknown) => fn()), withSystemDbAccessContext: vi.fn(async (fn: () => unknown) => fn()), computeEffectiveMcpScopes: vi.fn(), + authMiddleware: vi.fn(), }; }); +vi.mock('../config/env', async (importOriginal) => { + const actual = await importOriginal(); + return Object.defineProperty({ + ...actual, + MCP_OAUTH_ENABLED: true, + OAUTH_ISSUER: 'https://api.example', + OAUTH_RESOURCE_URL: 'https://api.example/mcp/server', + }, 'BILLING_URL', { + enumerable: true, + configurable: true, + get: () => configState.billingUrl ?? '', + }); +}); + +vi.mock('../oauth/log', () => ({ + ERROR_IDS: new Proxy({}, { get: (_target, property) => property }), + logOauthError: vi.fn(), + logOauthDebug: vi.fn(), + logOAuthEvent: vi.fn(), +})); + // Mock the effective-scope resolver so route tests exercise WIRING (is it // called with the right requested/displayed/partnerId/hasGrant args at the // right call sites?) without re-testing the policy-intersection math itself @@ -61,19 +87,7 @@ vi.mock('../oauth/provider', () => ({ })); vi.mock('../middleware/auth', () => ({ - authMiddleware: vi.fn(async (c: any, next: any) => { - c.set('auth', { - user: { id: 'u1', email: 'user@example.com', name: 'User One' }, - token: {}, - partnerId: '11111111-1111-4111-8111-111111111111', - orgId: 'current-org', - scope: 'partner', - accessibleOrgIds: null, - orgCondition: vi.fn(), - canAccessOrg: vi.fn(), - }); - await next(); - }), + authMiddleware: mocks.authMiddleware, // Pulled in transitively via monitorWorker.ts -> monitors.ts when the // routes module imports the agent WS layer. Stub as no-op middleware so // the import chain doesn't blow up at module-load time. @@ -100,6 +114,8 @@ vi.mock('../services/auditEvents', () => ({ writeAuditEvent: vi.fn(), })); +import { oauthInteractionRoutes } from './oauthInteraction'; + function details(overrides: Record = {}): { uid: string; exp: number; @@ -184,11 +200,7 @@ function queueInsertGrantReturning(opts: { firstConsented: boolean }) { return { values, onConflictDoUpdate, returning }; } -async function loadApp(enabled = true) { - process.env.MCP_OAUTH_ENABLED = enabled ? 'true' : 'false'; - process.env.OAUTH_RESOURCE_URL = 'https://api.example/mcp/server'; - vi.resetModules(); - const { oauthInteractionRoutes } = await import('./oauthInteraction'); +function loadApp() { const app = new Hono().route('/api/v1/oauth', oauthInteractionRoutes); app.onError((err, c) => { if (err instanceof HTTPException) { @@ -207,6 +219,26 @@ async function request(app: Hono, path: string, init?: RequestInit) { describe('oauthInteractionRoutes', () => { beforeEach(() => { vi.clearAllMocks(); + configState.billingUrl = undefined; + mocks.interactionDetails.mockReset(); + mocks.select.mockReset(); + mocks.update.mockReset(); + mocks.insert.mockReset(); + mocks.computeEffectiveMcpScopes.mockReset(); + mocks.authMiddleware.mockReset(); + mocks.authMiddleware.mockImplementation(async (c: any, next: any) => { + c.set('auth', { + user: { id: 'u1', email: 'user@example.com', name: 'User One' }, + token: {}, + partnerId: '11111111-1111-4111-8111-111111111111', + orgId: 'current-org', + scope: 'partner', + accessibleOrgIds: null, + orgCondition: vi.fn(), + canAccessOrg: vi.fn(), + }); + await next(); + }); mocks.Grant.instances.length = 0; auditMocks.writeRouteAudit.mockReset(); // Default: no partner-policy narrowing — pass through whatever mcp:* @@ -216,18 +248,13 @@ describe('oauthInteractionRoutes', () => { ); }); - afterEach(() => { - delete process.env.MCP_OAUTH_ENABLED; - delete process.env.OAUTH_RESOURCE_URL; - }); - it('returns 404 when Interaction.find returns undefined (not found / expired)', async () => { // The route now uses Interaction.find(uid) directly — see oauthInteraction.ts // intentional comment: this avoids relying on the _interaction cookie // which can lag the URL UID in multi-prompt flows. A missing/expired // interaction surfaces as `undefined`, which the route maps to 404. mocks.interactionDetails.mockResolvedValue(undefined); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1'); + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1'); expect(res.status).toBe(404); }); @@ -238,7 +265,7 @@ describe('oauthInteractionRoutes', () => { // oauth_clients so the consent UI can show the human-readable // `client_name` instead of the opaque `client_id`. queueSelect([{ metadata: { client_name: 'Claude Desktop' } }], 'limit'); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1'); + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1'); const body = await res.json(); expect(res.status).toBe(200); expect(body).toEqual({ @@ -277,7 +304,7 @@ describe('oauthInteractionRoutes', () => { partnerId === OTHER_PARTNER_ID ? ['mcp:read'] : ['mcp:read', 'mcp:write'], ); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1'); + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1'); const body = await res.json() as { partners: Array<{ partnerId: string; effectiveScopes: string[] }> }; expect(res.status).toBe(200); expect(body.partners).toEqual([ @@ -302,7 +329,7 @@ describe('oauthInteractionRoutes', () => { })); queueSelect([{ partnerId: PARTNER_ID, partnerName: 'Acme MSP' }]); queueSelect([{ metadata: {} }], 'limit'); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1'); + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1'); const body = await res.json() as { client: { client_id: string; display_name: string } }; expect(res.status).toBe(200); expect(body.client).toEqual({ @@ -327,7 +354,7 @@ describe('oauthInteractionRoutes', () => { })); queueSelect([{ partnerId: PARTNER_ID, partnerName: 'Acme MSP' }]); queueSelect([{ metadata: { client_name: 'Microsoft 365' } }], 'limit'); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1'); + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1'); const body = await res.json() as { client: { verification: string; redirect_uri: string; redirect_origin: string; display_name: string }; }; @@ -353,7 +380,7 @@ describe('oauthInteractionRoutes', () => { })); queueSelect([{ partnerId: PARTNER_ID, partnerName: 'Acme MSP' }]); queueSelect([{ metadata: { client_name: 'Claude Desktop' } }], 'limit'); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1'); + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1'); const body = await res.json() as { client: { redirect_uri: string; redirect_origin: string } }; expect(res.status).toBe(200); expect(body.client.redirect_uri).toBe(''); @@ -364,12 +391,10 @@ describe('oauthInteractionRoutes', () => { // Route writes the result directly onto the interaction and calls // details.save() (rather than provider.interactionResult, which would // read the wrong UID from the cookie in multi-prompt flows). The - // canonical resume URL is `${OAUTH_ISSUER}/oauth/auth/` — note the - // OAUTH_ISSUER env isn't set in these tests so it stringifies as - // "undefined/oauth/auth/uid-1". + // canonical resume URL is `${OAUTH_ISSUER}/oauth/auth/`. const d = details(); mocks.interactionDetails.mockResolvedValue(d); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { method: 'POST', body: JSON.stringify({ partner_id: PARTNER_ID, approve: false }), }); @@ -384,7 +409,7 @@ describe('oauthInteractionRoutes', () => { mocks.interactionDetails.mockResolvedValue(details({ params: { client_id: 'client-1', resource: 'https://evil.example/mcp' }, })); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { method: 'POST', body: JSON.stringify({ partner_id: PARTNER_ID, approve: true }), }); @@ -407,7 +432,7 @@ describe('oauthInteractionRoutes', () => { })); queueSelect([{ partnerId: PARTNER_ID, partnerName: 'Acme MSP' }]); queueSelect([{ metadata: { client_name: 'Claude Desktop' } }], 'limit'); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1'); + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1'); expect(res.status).toBe(200); const body = await res.json() as { resource: string }; expect(body.resource).toBe('https://api.example/mcp/server/sse'); @@ -426,7 +451,7 @@ describe('oauthInteractionRoutes', () => { }, }); mocks.interactionDetails.mockResolvedValue(d); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { method: 'POST', body: JSON.stringify({ partner_id: PARTNER_ID, approve: false }), }); @@ -436,7 +461,7 @@ describe('oauthInteractionRoutes', () => { it('rejects malformed consent JSON before membership checks', async () => { mocks.interactionDetails.mockResolvedValue(details()); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { method: 'POST', body: '{', }); @@ -449,14 +474,14 @@ describe('oauthInteractionRoutes', () => { it('rejects consent bodies with invalid approve or partner_id shape', async () => { mocks.interactionDetails.mockResolvedValue(details()); - const approveRes = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { + const approveRes = await request(loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { method: 'POST', body: JSON.stringify({ partner_id: PARTNER_ID, approve: 'yes' }), }); expect(approveRes.status).toBe(400); expect(await approveRes.json()).toEqual({ message: 'approve must be a boolean' }); - const partnerRes = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { + const partnerRes = await request(loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { method: 'POST', body: JSON.stringify({ partner_id: 'partner-1', approve: true }), }); @@ -467,7 +492,7 @@ describe('oauthInteractionRoutes', () => { it('rejects consent for partners where the user is not a member', async () => { mocks.interactionDetails.mockResolvedValue(details()); queueSelect([], 'limit'); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { method: 'POST', body: JSON.stringify({ partner_id: PARTNER_ID, approve: true }), }); @@ -500,7 +525,7 @@ describe('oauthInteractionRoutes', () => { // `firstConsented: true` simulates a fresh row (firstConsentedAt === // lastConsentedAt), which routes to the `partner_grant_recorded` audit. const grantInsert = queueInsertGrantReturning({ firstConsented: true }); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { method: 'POST', body: JSON.stringify({ partner_id: PARTNER_ID, approve: true }), }); @@ -561,7 +586,7 @@ describe('oauthInteractionRoutes', () => { queueUpdate(); queueInsertGrantReturning({ firstConsented: true }); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { method: 'POST', body: JSON.stringify({ partner_id: PARTNER_ID, approve: true }), }); @@ -585,7 +610,7 @@ describe('oauthInteractionRoutes', () => { // JWT, they should NOT be able to finish another user's consent flow. const d = details({ session: { accountId: 'victim-id' } } as any); mocks.interactionDetails.mockResolvedValue(d); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { method: 'POST', body: JSON.stringify({ partner_id: PARTNER_ID, approve: true }), }); @@ -601,7 +626,7 @@ describe('oauthInteractionRoutes', () => { // leaks information about a victim's flow — fail closed on GET too. const d = details({ session: { accountId: 'victim-id' } } as any); mocks.interactionDetails.mockResolvedValue(d); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1'); + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1'); expect(res.status).toBe(403); expect(await res.json()).toEqual({ message: 'interaction_user_mismatch' }); }); @@ -612,7 +637,7 @@ describe('oauthInteractionRoutes', () => { // POST (by user B = u1 from authMiddleware mock) must be rejected. const d = details({ lastSubmission: { accountId: 'user-a-id' } } as any); mocks.interactionDetails.mockResolvedValue(d); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { method: 'POST', body: JSON.stringify({ partner_id: PARTNER_ID, approve: true }), }); @@ -633,7 +658,7 @@ describe('oauthInteractionRoutes', () => { queueSelect([{ status: 'active' }], 'limit'); // partner status check queueUpdate(); queueInsertGrantReturning({ firstConsented: true }); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { method: 'POST', body: JSON.stringify({ partner_id: PARTNER_ID, approve: true }), }); @@ -664,7 +689,7 @@ describe('oauthInteractionRoutes', () => { // route classifies this as a re-consent (existing row, conflict-update). queueInsertGrantReturning({ firstConsented: false }); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { method: 'POST', body: JSON.stringify({ partner_id: PARTNER_ID, approve: true }), }); @@ -696,7 +721,7 @@ describe('oauthInteractionRoutes', () => { queueUpdate(); queueInsertGrantReturning({ firstConsented: true }); - const resA = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { + const resA = await request(loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { method: 'POST', body: JSON.stringify({ partner_id: PARTNER_ID, approve: true }), }); @@ -731,7 +756,7 @@ describe('oauthInteractionRoutes', () => { queueUpdate(); queueInsertGrantReturning({ firstConsented: true }); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { method: 'POST', body: JSON.stringify({ partner_id: PARTNER_ID, approve: true }), }); @@ -768,7 +793,7 @@ describe('oauthInteractionRoutes', () => { queueSelect([{ partnerId: PARTNER_ID, orgId: 'org-1' }], 'limit'); queueSelect([{ status: 'active' }], 'limit'); // partner status check - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { method: 'POST', body: JSON.stringify({ partner_id: PARTNER_ID, approve: true }), }); @@ -795,7 +820,7 @@ describe('oauthInteractionRoutes', () => { queueUpdate(); // setGrantBreezeMeta on oauth_grants queueInsertGrantReturning({ firstConsented: true }); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { method: 'POST', body: JSON.stringify({ partner_id: PARTNER_ID, approve: true }), }); @@ -829,7 +854,7 @@ describe('oauthInteractionRoutes', () => { // down to mcp:read only — simulates a read-only partner. mocks.computeEffectiveMcpScopes.mockResolvedValueOnce(['mcp:read']); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { method: 'POST', body: JSON.stringify({ partner_id: PARTNER_ID, approve: true }), }); @@ -875,7 +900,7 @@ describe('oauthInteractionRoutes', () => { queueInsertGrantReturning({ firstConsented: true }); mocks.computeEffectiveMcpScopes.mockResolvedValueOnce(['mcp:read']); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { method: 'POST', body: JSON.stringify({ partner_id: PARTNER_ID, approve: true }), }); @@ -926,7 +951,7 @@ describe('oauthInteractionRoutes', () => { // Selected partner's policy allows only mcp:read. mocks.computeEffectiveMcpScopes.mockResolvedValueOnce(['mcp:read']); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { method: 'POST', body: JSON.stringify({ partner_id: PARTNER_ID, approve: true }), }); @@ -979,7 +1004,7 @@ describe('oauthInteractionRoutes', () => { queueSelect([{ partnerId: PARTNER_ID, partnerName: 'Acme' }]); queueSelect([{ metadata: { client_name: 'Claude Desktop' } }], 'limit'); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1'); + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1'); expect(res.status).toBe(200); const body = await res.json() as { scopes: string[] }; expect(body.scopes).toEqual(['openid', 'offline_access', 'mcp:read', 'mcp:write']); @@ -991,12 +1016,8 @@ describe('oauthInteractionRoutes', () => { // to the billing service (if BILLING_URL is set) or returns 402. // ------------------------------------------------------------------- describe('consent redirects inactive partners to BILLING_URL', () => { - afterEach(() => { - delete process.env.BILLING_URL; - }); - it('returns redirectTo BILLING_URL when partner.status=pending and BILLING_URL is set', async () => { - process.env.BILLING_URL = 'https://billing.example.com/setup'; + configState.billingUrl = 'https://billing.example.com/setup'; const d = details({ session: { accountId: 'u1' }, prompt: { details: { scopes: { new: ['openid', 'offline_access', 'mcp:read', 'mcp:write'] } } }, @@ -1009,7 +1030,7 @@ describe('oauthInteractionRoutes', () => { // Queue 3: partner status check — returns 'pending' queueSelect([{ status: 'pending' }], 'limit'); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { method: 'POST', body: JSON.stringify({ partner_id: PARTNER_ID, approve: true }), }); @@ -1021,7 +1042,7 @@ describe('oauthInteractionRoutes', () => { }); it('falls through to grant.save when partner.status=active', async () => { - process.env.BILLING_URL = 'https://billing.example.com/setup'; + configState.billingUrl = 'https://billing.example.com/setup'; const d = details({ session: { accountId: 'u1' }, prompt: { details: { scopes: { new: ['openid', 'offline_access', 'mcp:read', 'mcp:write'] } } }, @@ -1036,7 +1057,7 @@ describe('oauthInteractionRoutes', () => { queueUpdate(); // setGrantBreezeMeta on oauth_grants queueInsertGrantReturning({ firstConsented: true }); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { method: 'POST', body: JSON.stringify({ partner_id: PARTNER_ID, approve: true }), }); @@ -1049,7 +1070,7 @@ describe('oauthInteractionRoutes', () => { }); it('returns 404 when partner row is missing despite confirmed membership', async () => { - process.env.BILLING_URL = 'https://billing.example.com/setup'; + configState.billingUrl = 'https://billing.example.com/setup'; const d = details({ session: { accountId: 'u1' }, prompt: { details: { scopes: { new: ['openid', 'offline_access', 'mcp:read', 'mcp:write'] } } }, @@ -1062,7 +1083,7 @@ describe('oauthInteractionRoutes', () => { // Queue 3: partner status check — returns [] (row missing, data integrity edge case) queueSelect([], 'limit'); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { method: 'POST', body: JSON.stringify({ partner_id: PARTNER_ID, approve: true }), }); @@ -1073,7 +1094,7 @@ describe('oauthInteractionRoutes', () => { }); it('redirects to BILLING_URL when partner.status=suspended', async () => { - process.env.BILLING_URL = 'https://billing.example.com/setup'; + configState.billingUrl = 'https://billing.example.com/setup'; const d = details({ session: { accountId: 'u1' }, prompt: { details: { scopes: { new: ['openid', 'offline_access', 'mcp:read', 'mcp:write'] } } }, @@ -1086,7 +1107,7 @@ describe('oauthInteractionRoutes', () => { // Queue 3: partner status check — returns 'suspended' queueSelect([{ status: 'suspended' }], 'limit'); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { method: 'POST', body: JSON.stringify({ partner_id: PARTNER_ID, approve: true }), }); @@ -1098,7 +1119,7 @@ describe('oauthInteractionRoutes', () => { }); it('returns 402 subscription_required when status=pending and BILLING_URL is empty', async () => { - delete process.env.BILLING_URL; + configState.billingUrl = undefined; const d = details({ session: { accountId: 'u1' }, prompt: { details: { scopes: { new: ['openid', 'offline_access', 'mcp:read', 'mcp:write'] } } }, @@ -1111,7 +1132,7 @@ describe('oauthInteractionRoutes', () => { // Queue 3: partner status check — returns 'pending', no BILLING_URL queueSelect([{ status: 'pending' }], 'limit'); - const res = await request(await loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1/consent', { method: 'POST', body: JSON.stringify({ partner_id: PARTNER_ID, approve: true }), }); @@ -1121,14 +1142,9 @@ describe('oauthInteractionRoutes', () => { }); }); - it('does not mount routes when MCP_OAUTH_ENABLED is false', async () => { - const res = await request(await loadApp(false), '/api/v1/oauth/interaction/uid-1'); - expect(res.status).toBe(404); - }); - it('returns 500 when interactionDetails throws an unexpected error', async () => { mocks.interactionDetails.mockRejectedValueOnce(new Error('boom')); - const res = await request(await loadApp(true), '/api/v1/oauth/interaction/uid-1'); + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1'); expect(res.status).toBe(500); }); @@ -1141,7 +1157,7 @@ describe('oauthInteractionRoutes', () => { vi.mocked(authMod.authMiddleware).mockImplementationOnce(async () => { throw new HTTPException(401, { message: 'Missing or invalid authorization header' }); }); - const res = await request(await loadApp(true), '/api/v1/oauth/interaction/uid-1'); + const res = await request(loadApp(), '/api/v1/oauth/interaction/uid-1'); expect(res.status).toBe(401); }); }); diff --git a/apps/api/src/routes/partner_multi_org_orgid.test.ts b/apps/api/src/routes/partner_multi_org_orgid.test.ts index 649d2e00a0..4e2b375224 100644 --- a/apps/api/src/routes/partner_multi_org_orgid.test.ts +++ b/apps/api/src/routes/partner_multi_org_orgid.test.ts @@ -17,8 +17,13 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Hono } from 'hono'; +import { discoveryRoutes } from './discovery'; +import { huntressRoutes } from './huntress'; +import { orgRoutes } from './orgs'; +import { softwareRoutes } from './software'; +import { softwareInventoryRoutes } from './softwareInventory'; -const { authState, canAccessOrgSpy, ORG_A, ORG_B, FOREIGN_ORG } = vi.hoisted(() => { +const { authState, canAccessOrgSpy, dbSelectDelegate, ORG_A, ORG_B, FOREIGN_ORG } = vi.hoisted(() => { const ORG_A = '11111111-1111-1111-1111-111111111111'; const ORG_B = '22222222-2222-2222-2222-222222222222'; const FOREIGN_ORG = '33333333-3333-3333-3333-333333333333'; @@ -38,6 +43,7 @@ const { authState, canAccessOrgSpy, ORG_A, ORG_B, FOREIGN_ORG } = vi.hoisted(() // vi.clearAllMocks() in beforeEach clears call history but keeps this impl. canAccessOrgSpy: vi.fn((id: string) => Array.isArray(state.accessibleOrgIds) && state.accessibleOrgIds.includes(id)), + dbSelectDelegate: vi.fn(), }; }); @@ -65,7 +71,7 @@ vi.mock('../middleware/auth', () => ({ })); // --------------------------------------------------------------------------- -// db / chain mocks shared across all four route imports +// db / chain mocks shared across the route modules // --------------------------------------------------------------------------- function chainMock(terminalValue: any) { const handler: ProxyHandler = { @@ -92,7 +98,7 @@ vi.mock('../db', () => ({ withSystemDbAccessContext: vi.fn(async (fn: () => Promise) => fn()), SYSTEM_DB_ACCESS_CONTEXT: { scope: 'system', orgId: null, accessibleOrgIds: null }, db: { - select: vi.fn(() => chainMock([{ count: 0 }])), + select: vi.fn((...args: unknown[]) => dbSelectDelegate(...args)), insert: vi.fn(() => chainMock([])), update: vi.fn(() => chainMock(undefined)), delete: vi.fn(() => chainMock(undefined)), @@ -143,6 +149,7 @@ vi.mock('../db/schema', () => ({ discoveryProfiles: { id: 'id', orgId: 'orgId', siteId: 'siteId' }, discoveryJobs: { id: 'id', status: 'status', completedAt: 'completedAt', errors: 'errors', updatedAt: 'updatedAt' }, discoveredAssets: { id: 'id', orgId: 'orgId' }, + sites: { id: 'id', orgId: 'orgId', createdAt: 'createdAt' }, huntressIntegrations: { id: 'id', orgId: 'orgId', @@ -224,8 +231,18 @@ vi.mock('../services/permissions', () => ({ // Tests // --------------------------------------------------------------------------- +const discoveryApp = new Hono().route('/discovery', discoveryRoutes); +const huntressApp = new Hono().route('/huntress', huntressRoutes); +const orgApp = new Hono().route('/orgs', orgRoutes); +const softwareApp = new Hono().route('/software', softwareRoutes); +const softwareInventoryApp = new Hono().route('/software-inventory', softwareInventoryRoutes); + beforeEach(() => { vi.clearAllMocks(); + dbSelectDelegate + .mockReset() + .mockImplementationOnce(() => chainMock([{ count: 0 }])) + .mockImplementation(() => chainMock([])); // Default: partner-scope user with TWO accessible orgs. authState.scope = 'partner'; authState.orgId = null; @@ -244,10 +261,7 @@ async function expectNotOrgIdRequired400(res: Response) { describe('issue #620: partner-multi-org orgId pass-through', () => { describe('software catalog routes', () => { it('GET /software/catalog accepts ?orgId= for partner-multi-org user', async () => { - const { softwareRoutes } = await import('./software'); - const app = new Hono().route('/software', softwareRoutes); - - const res = await app.request(`/software/catalog?orgId=${ORG_A}`, { + const res = await softwareApp.request(`/software/catalog?orgId=${ORG_A}`, { method: 'GET', headers: { Authorization: 'Bearer t' }, }); @@ -261,10 +275,7 @@ describe('issue #620: partner-multi-org orgId pass-through', () => { // Orgs") when no orgId is supplied, instead of 400ing. (Explicit foreign // orgIds still 403 — see the test below — and single-org catalog detail / // write paths are unchanged.) - const { softwareRoutes } = await import('./software'); - const app = new Hono().route('/software', softwareRoutes); - - const res = await app.request('/software/catalog', { + const res = await softwareApp.request('/software/catalog', { method: 'GET', headers: { Authorization: 'Bearer t' }, }); @@ -276,10 +287,7 @@ describe('issue #620: partner-multi-org orgId pass-through', () => { }); it('GET /software/catalog 403s when ?orgId= points outside accessibleOrgIds', async () => { - const { softwareRoutes } = await import('./software'); - const app = new Hono().route('/software', softwareRoutes); - - const res = await app.request(`/software/catalog?orgId=${FOREIGN_ORG}`, { + const res = await softwareApp.request(`/software/catalog?orgId=${FOREIGN_ORG}`, { method: 'GET', headers: { Authorization: 'Bearer t' }, }); @@ -288,10 +296,7 @@ describe('issue #620: partner-multi-org orgId pass-through', () => { }); it('GET /software/catalog/search accepts ?orgId=', async () => { - const { softwareRoutes } = await import('./software'); - const app = new Hono().route('/software', softwareRoutes); - - const res = await app.request(`/software/catalog/search?q=foo&orgId=${ORG_A}`, { + const res = await softwareApp.request(`/software/catalog/search?q=foo&orgId=${ORG_A}`, { method: 'GET', headers: { Authorization: 'Bearer t' }, }); @@ -300,10 +305,7 @@ describe('issue #620: partner-multi-org orgId pass-through', () => { }); it('POST /software/catalog accepts orgId in body', async () => { - const { softwareRoutes } = await import('./software'); - const app = new Hono().route('/software', softwareRoutes); - - const res = await app.request('/software/catalog', { + const res = await softwareApp.request('/software/catalog', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer t' }, body: JSON.stringify({ name: 'Acme', orgId: ORG_A }), @@ -315,10 +317,7 @@ describe('issue #620: partner-multi-org orgId pass-through', () => { describe('software inventory routes', () => { it('GET /software-inventory accepts ?orgId=', async () => { - const { softwareInventoryRoutes } = await import('./softwareInventory'); - const app = new Hono().route('/software-inventory', softwareInventoryRoutes); - - const res = await app.request(`/software-inventory?orgId=${ORG_A}`, { + const res = await softwareInventoryApp.request(`/software-inventory?orgId=${ORG_A}`, { method: 'GET', headers: { Authorization: 'Bearer t' }, }); @@ -332,10 +331,7 @@ describe('issue #620: partner-multi-org orgId pass-through', () => { // the same as of #1933; the discovery routes below still require an explicit // orgId.) it('GET /software-inventory aggregates across all orgs when orgId is missing', async () => { - const { softwareInventoryRoutes } = await import('./softwareInventory'); - const app = new Hono().route('/software-inventory', softwareInventoryRoutes); - - const res = await app.request('/software-inventory', { + const res = await softwareInventoryApp.request('/software-inventory', { method: 'GET', headers: { Authorization: 'Bearer t' }, }); @@ -344,10 +340,7 @@ describe('issue #620: partner-multi-org orgId pass-through', () => { }); it('GET /software-inventory 403s when ?orgId= is foreign', async () => { - const { softwareInventoryRoutes } = await import('./softwareInventory'); - const app = new Hono().route('/software-inventory', softwareInventoryRoutes); - - const res = await app.request(`/software-inventory?orgId=${FOREIGN_ORG}`, { + const res = await softwareInventoryApp.request(`/software-inventory?orgId=${FOREIGN_ORG}`, { method: 'GET', headers: { Authorization: 'Bearer t' }, }); @@ -358,10 +351,7 @@ describe('issue #620: partner-multi-org orgId pass-through', () => { describe('discovery scan route', () => { it('POST /discovery/scan accepts orgId in body', async () => { - const { discoveryRoutes } = await import('./discovery'); - const app = new Hono().route('/discovery', discoveryRoutes); - - const res = await app.request('/discovery/scan', { + const res = await discoveryApp.request('/discovery/scan', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer t' }, body: JSON.stringify({ @@ -374,10 +364,7 @@ describe('issue #620: partner-multi-org orgId pass-through', () => { }); it('POST /discovery/scan 400s when orgId is missing for partner-multi-org user', async () => { - const { discoveryRoutes } = await import('./discovery'); - const app = new Hono().route('/discovery', discoveryRoutes); - - const res = await app.request('/discovery/scan', { + const res = await discoveryApp.request('/discovery/scan', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer t' }, body: JSON.stringify({ profileId: '99999999-9999-9999-9999-999999999999' }), @@ -391,10 +378,7 @@ describe('issue #620: partner-multi-org orgId pass-through', () => { describe('huntress integration save', () => { it('POST /huntress/integration accepts partner-level credentials without orgId', async () => { - const { huntressRoutes } = await import('./huntress'); - const app = new Hono().route('/huntress', huntressRoutes); - - const res = await app.request('/huntress/integration', { + const res = await huntressApp.request('/huntress/integration', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer t' }, body: JSON.stringify({ name: 'Primary', apiKey: 'k' }), @@ -436,44 +420,45 @@ describe('issue #723: GET /orgs/sites organizationId precedence', () => { // precedence resolves the explicit ORG_A, buggy precedence the ambient // ORG_B. authState.accessibleOrgIds = [ORG_A, ORG_B]; - const { orgRoutes } = await import('./orgs'); - const app = new Hono().route('/orgs', orgRoutes); - await app.request(`/orgs/sites?organizationId=${ORG_A}&orgId=${ORG_B}`, { + const res = await orgApp.request(`/orgs/sites?organizationId=${ORG_A}&orgId=${ORG_B}`, { method: 'GET', headers: { Authorization: 'Bearer t' }, }); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + data: [], + pagination: { page: 1, limit: 50, total: 0 }, + }); expect(canAccessOrgSpy).toHaveBeenCalledWith(ORG_A); expect(canAccessOrgSpy).not.toHaveBeenCalledWith(ORG_B); }); it('does not 403 when the explicit organizationId is accessible but the auto-injected ambient orgId is not', async () => { authState.accessibleOrgIds = [ORG_A]; - const { orgRoutes } = await import('./orgs'); - const app = new Hono().route('/orgs', orgRoutes); // Simulates fetchWithAuth appending &orgId= to a page // request that explicitly asked for organizationId=ORG_A. Buggy precedence // resolves the inaccessible ORG_B -> 403; correct precedence resolves the // explicit, accessible ORG_A and proceeds (must not be denied). - const res = await app.request(`/orgs/sites?organizationId=${ORG_A}&orgId=${ORG_B}`, { + const res = await orgApp.request(`/orgs/sites?organizationId=${ORG_A}&orgId=${ORG_B}`, { method: 'GET', headers: { Authorization: 'Bearer t' }, }); - expect(res.status).not.toBe(403); + expect(res.status).toBe(200); + expect(canAccessOrgSpy).toHaveBeenCalledWith(ORG_A); + expect(canAccessOrgSpy).not.toHaveBeenCalledWith(ORG_B); }); it('honors the explicit organizationId over an accessible ambient orgId (precedence flipped, not fallthrough)', async () => { authState.accessibleOrgIds = [ORG_A]; - const { orgRoutes } = await import('./orgs'); - const app = new Hono().route('/orgs', orgRoutes); // Explicit organizationId points at the inaccessible ORG_B while the // ambient orgId is the accessible ORG_A. The explicit value must win, so // access must be denied. Buggy precedence resolves ORG_A and does NOT 403. - const res = await app.request(`/orgs/sites?organizationId=${ORG_B}&orgId=${ORG_A}`, { + const res = await orgApp.request(`/orgs/sites?organizationId=${ORG_B}&orgId=${ORG_A}`, { method: 'GET', headers: { Authorization: 'Bearer t' }, }); @@ -483,12 +468,10 @@ describe('issue #723: GET /orgs/sites organizationId precedence', () => { it('still scopes by orgId when no organizationId is supplied (fallback unchanged)', async () => { authState.accessibleOrgIds = [ORG_A]; - const { orgRoutes } = await import('./orgs'); - const app = new Hono().route('/orgs', orgRoutes); // Only the ambient orgId is present and it is foreign -> must still be // denied (guards that the orgId fallback is preserved by the fix). - const res = await app.request(`/orgs/sites?orgId=${FOREIGN_ORG}`, { + const res = await orgApp.request(`/orgs/sites?orgId=${FOREIGN_ORG}`, { method: 'GET', headers: { Authorization: 'Bearer t' }, }); diff --git a/apps/api/src/routes/reports/schemas.config.test.ts b/apps/api/src/routes/reports/schemas.config.test.ts index 1ed25197cb..8488ebf0d7 100644 --- a/apps/api/src/routes/reports/schemas.config.test.ts +++ b/apps/api/src/routes/reports/schemas.config.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { createReportSchema, updateReportSchema } from './schemas'; +import { + createReportSchema, + securityCompliancePostureConfigFields, + securityCompliancePostureConfigSchema, + updateReportSchema, +} from './schemas'; const builderConfig = { builderType: 'device_inventory', @@ -76,4 +81,30 @@ describe('report config schema', () => { const updated = updateReportSchema.parse({ config: { schedule: { date: 1 } } }); expect(updated.config?.schedule).toEqual({ date: '1' }); }); + + // The generation schema (with defaults) and the persistence field map are + // maintained by hand. Drift is silent and one-directional: a key added to the + // former but not the latter is stripped on save, then silently reappears at + // generation as its default — the user's setting quietly ignored. + it('keeps the posture persistence fields in sync with the generation schema', () => { + expect(Object.keys(securityCompliancePostureConfigFields).sort()).toEqual( + Object.keys(securityCompliancePostureConfigSchema.shape).sort(), + ); + }); + + it('preserves posture backupRequired on create and update', () => { + const created = createReportSchema.parse({ + name: 'Workstation posture', + type: 'security_compliance_posture', + schedule: 'one_time', + format: 'pdf', + config: { backupRequired: false }, + }); + expect(created.config.backupRequired).toBe(false); + + const updated = updateReportSchema.parse({ + config: { backupRequired: true }, + }); + expect(updated.config?.backupRequired).toBe(true); + }); }); diff --git a/apps/api/src/routes/reports/schemas.security.test.ts b/apps/api/src/routes/reports/schemas.security.test.ts index 793a5128e6..5860dead41 100644 --- a/apps/api/src/routes/reports/schemas.security.test.ts +++ b/apps/api/src/routes/reports/schemas.security.test.ts @@ -16,4 +16,18 @@ describe('security_compliance_posture validation', () => { expect(cfg.minPasswordLength).toBe(8); expect(cfg.maxLocalAdmins).toBe(2); }); + + it('defaults omitted backupRequired to required for legacy reports', () => { + expect(securityCompliancePostureConfigSchema.parse({}).backupRequired).toBe(true); + }); + + it.each([true, false])('accepts backupRequired=%s', (backupRequired) => { + expect(securityCompliancePostureConfigSchema.parse({ backupRequired }).backupRequired) + .toBe(backupRequired); + }); + + it('rejects non-boolean backupRequired', () => { + expect(securityCompliancePostureConfigSchema.safeParse({ backupRequired: 'false' }).success) + .toBe(false); + }); }); diff --git a/apps/api/src/routes/reports/schemas.ts b/apps/api/src/routes/reports/schemas.ts index c1f18c8381..ff5900fce9 100644 --- a/apps/api/src/routes/reports/schemas.ts +++ b/apps/api/src/routes/reports/schemas.ts @@ -24,16 +24,25 @@ export const securityCompliancePostureConfigSchema = z.object({ maxAvDefinitionsAgeDays: z.number().int().min(1).max(365).optional().default(7), // Include the CIS hardening section. Defaults on; renders "Not yet assessed" // until baseline scans exist, or is omitted entirely when set false. - includeCis: z.boolean().optional().default(true) + includeCis: z.boolean().optional().default(true), + backupRequired: z.boolean().optional().default(true) }); -const securityCompliancePostureConfigFields = { +/** + * The same posture keys as `securityCompliancePostureConfigSchema` but without + * its `.default()`s — persistence stores only what the user actually set, and + * generation applies defaults at read time. The two lists are hand-parallel; + * `schemas.config.test.ts` holds them in sync, because a key missing here is + * silently stripped on save and then reappears at generation as its default. + */ +export const securityCompliancePostureConfigFields = { sites: z.array(z.string().guid()).optional(), windowDays: z.number().int().min(1).max(365).optional(), minPasswordLength: z.number().int().min(1).max(64).optional(), maxLocalAdmins: z.number().int().min(0).max(50).optional(), maxAvDefinitionsAgeDays: z.number().int().min(1).max(365).optional(), - includeCis: z.boolean().optional() + includeCis: z.boolean().optional(), + backupRequired: z.boolean().optional() }; /** diff --git a/apps/api/src/services/aiGuardrails.bootstrapParity.test.ts b/apps/api/src/services/aiGuardrails.bootstrapParity.test.ts index 92183168bf..ce060bae41 100644 --- a/apps/api/src/services/aiGuardrails.bootstrapParity.test.ts +++ b/apps/api/src/services/aiGuardrails.bootstrapParity.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; +// This test compares the real bootstrap tool names with the real permission map; +// the independently covered eager 48-tool registry is unrelated to that parity. +vi.mock('./aiTools', () => ({ getToolTier: vi.fn() })); + // Registry parity (MCP-OAUTH-11): every authenticated bootstrap tool MUST // declare a TOOL_PERMISSIONS mapping, so a future bootstrap tool cannot ship // without a product-RBAC gate. `../db` is stubbed only so importing the real diff --git a/apps/api/src/services/encryptedColumnRegistry.test.ts b/apps/api/src/services/encryptedColumnRegistry.test.ts index d58098dfb0..bbba384eb7 100644 --- a/apps/api/src/services/encryptedColumnRegistry.test.ts +++ b/apps/api/src/services/encryptedColumnRegistry.test.ts @@ -1,5 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +vi.mock('../db', () => ({ + db: {}, + withSystemDbAccessContext: vi.fn(), +})); + +import { + reencryptRegisteredSecrets, + transformEncryptedColumnValue, +} from './encryptedColumnRegistry'; +import { decryptSecret, encryptSecret } from './secretCrypto'; + const ENV_KEYS = [ 'APP_ENCRYPTION_KEY', 'APP_ENCRYPTION_KEY_ID', @@ -10,25 +21,19 @@ const ENV_KEYS = [ const originalEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); -async function loadRegistry(env: Partial> = {}) { - vi.resetModules(); +function setEncryptionEnv(env: Partial> = {}) { for (const key of ENV_KEYS) { delete process.env[key]; } Object.assign(process.env, env); - return import('./encryptedColumnRegistry'); } describe('encryptedColumnRegistry', () => { beforeEach(() => { - vi.resetModules(); - for (const key of ENV_KEYS) { - delete process.env[key]; - } + setEncryptionEnv(); }); afterEach(() => { - vi.resetModules(); for (const key of ENV_KEYS) { const value = originalEnv[key]; if (value === undefined) delete process.env[key]; @@ -36,16 +41,15 @@ describe('encryptedColumnRegistry', () => { } }); - it('transforms text columns from legacy ciphertext to the active v2 key id', async () => { - await loadRegistry({ APP_ENCRYPTION_KEY: 'legacy-key-material' }); - const legacyCiphertext = (await import('./secretCrypto')).encryptSecret('legacy-secret'); + it('transforms text columns from legacy ciphertext to the active v2 key id', () => { + setEncryptionEnv({ APP_ENCRYPTION_KEY: 'legacy-key-material' }); + const legacyCiphertext = encryptSecret('legacy-secret'); - const { transformEncryptedColumnValue } = await loadRegistry({ + setEncryptionEnv({ APP_ENCRYPTION_KEY: 'legacy-key-material', APP_ENCRYPTION_KEY_ID: 'current', APP_ENCRYPTION_KEYRING: JSON.stringify({ current: 'current-key-material' }), }); - const currentCrypto = await import('./secretCrypto'); const transformed = transformEncryptedColumnValue({ table: 'sso_providers', @@ -55,25 +59,23 @@ describe('encryptedColumnRegistry', () => { }, legacyCiphertext); expect(transformed).toMatch(/^enc:v2:current:/); - expect(currentCrypto.decryptSecret(transformed as string)).toBe('legacy-secret'); + expect(decryptSecret(transformed as string)).toBe('legacy-secret'); }); - it('recursively rotates encrypted JSON values without changing non-secret plaintext', async () => { - await loadRegistry({ + it('recursively rotates encrypted JSON values without changing non-secret plaintext', () => { + setEncryptionEnv({ APP_ENCRYPTION_KEY: 'old-key-material', APP_ENCRYPTION_KEY_ID: 'old', }); - const { encryptSecret } = await import('./secretCrypto'); const oldCiphertext = encryptSecret('old-token'); - const currentRegistry = await loadRegistry({ + setEncryptionEnv({ APP_ENCRYPTION_KEY: 'current-key-material', APP_ENCRYPTION_KEY_ID: 'current', APP_ENCRYPTION_KEYRING: JSON.stringify({ old: 'old-key-material' }), }); - const currentCrypto = await import('./secretCrypto'); - const transformed = currentRegistry.transformEncryptedColumnValue({ + const transformed = transformEncryptedColumnValue({ table: 'notification_channels', column: 'config', kind: 'json', @@ -85,11 +87,11 @@ describe('encryptedColumnRegistry', () => { expect(transformed.label).toBe('do-not-encrypt'); expect(transformed.nested.authToken).toMatch(/^enc:v2:current:/); - expect(currentCrypto.decryptSecret(transformed.nested.authToken)).toBe('old-token'); + expect(decryptSecret(transformed.nested.authToken)).toBe('old-token'); }); it('supports dry-run batch stats without writing updates', async () => { - const { reencryptRegisteredSecrets } = await loadRegistry({ + setEncryptionEnv({ APP_ENCRYPTION_KEY: 'current-key-material', APP_ENCRYPTION_KEY_ID: 'current', }); diff --git a/apps/api/src/services/securityComplianceReport.test.ts b/apps/api/src/services/securityComplianceReport.test.ts index 65ff43e0af..5967371ef0 100644 --- a/apps/api/src/services/securityComplianceReport.test.ts +++ b/apps/api/src/services/securityComplianceReport.test.ts @@ -1,9 +1,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +const vulnerabilityMocks = vi.hoisted(() => ({ + loadOpenVulnerabilityCounts: vi.fn(), +})); + vi.mock('../db', () => ({ db: { select: vi.fn() } })); vi.mock('./reportGenerationService', () => ({ resolveSiteAllowedDeviceIds: vi.fn(async () => null) })); +vi.mock('./securityComplianceReportVulnerabilities', () => vulnerabilityMocks); import { db } from '../db'; import { generateSecurityCompliancePostureReport } from './securityComplianceReport'; @@ -22,11 +27,11 @@ const ORG = '00000000-0000-0000-0000-000000000001'; /** * The generator issues selects in this fixed order: * 1 organizations 2 devices 3 security_status 4 s1_agents - * 5 huntress_agents 6 device_patches+severity 7 device_vulns - * 8 dns_filter 9 backup_configs 10 c2c_connections 11 m365 - * 12 google 13 pam_org_config 14 pam_rules 15 elevation_requests - * 16 authenticator_policies (only if org has partnerId) 17 latest org posture snapshot - * 18 cis_baseline_results (only if includeCis) 19 device_patches scanned-set (any status) + * 5 huntress_agents 6 device_patches+severity 7 dns_filter + * 8 backup_configs 9 c2c_connections 10 m365 11 google + * 12 pam_org_config 13 pam_rules 14 elevation_requests + * 15 authenticator_policies (only if org has partnerId) 16 latest org posture snapshot + * 17 cis_baseline_results (only if includeCis) 18 device_patches scanned-set (any status) */ function mockGeneratorQueries(over: Partial> = {}, opts: { noPartner?: boolean } = {}) { const seq: any[][] = [ @@ -43,34 +48,55 @@ function mockGeneratorQueries(over: Partial> = {}, opts: { /* 4 s1_agents */ [], /* 5 huntress_agents */ [{ deviceId: 'dev-1' }], /* 6 device_patches */ [{ deviceId: 'dev-2', severity: 'critical' }], - /* 7 device_vulns */ [{ deviceId: 'dev-1', severity: 'high' }], - /* 8 dns_filter */ [{ isActive: true, provider: 'umbrella', lastSyncStatus: 'success' }], - /* 9 backup_configs */ [{ isActive: true, provider: 's3', encryption: true }], - /* 10 c2c */ [], - /* 11 m365 */ [{ status: 'active' }], - /* 12 google */ [], - /* 13 pam_org_config */ [{ uacInterceptionEnabled: true }], - /* 14 pam_rules */ [{ id: 'r1' }, { id: 'r2' }], - /* 15 elevation_requests*/ [{ approvedAt: new Date(), deniedByUserId: null }, { approvedAt: null, deniedByUserId: 'u1' }], - /* 16 authenticator */ [{ requireEnrollment: true, enforceFrom: new Date(Date.now() - 86400000) }], - /* 17 posture snapshot */ [{ overallScore: 82 }] + /* 7 dns_filter */ [{ isActive: true, provider: 'umbrella', lastSyncStatus: 'success' }], + /* 8 backup_configs */ [{ isActive: true, provider: 's3', encryption: true }], + /* 9 c2c */ [], + /* 10 m365 */ [{ status: 'active' }], + /* 11 google */ [], + /* 12 pam_org_config */ [{ uacInterceptionEnabled: true }], + /* 13 pam_rules */ [{ id: 'r1' }, { id: 'r2' }], + /* 14 elevation_requests*/ [{ approvedAt: new Date(), deniedByUserId: null }, { approvedAt: null, deniedByUserId: 'u1' }], + /* 15 authenticator */ [{ requireEnrollment: true, enforceFrom: new Date(Date.now() - 86400000) }], + /* 16 posture snapshot */ [{ overallScore: 82 }] ]; for (const [i, rows] of Object.entries(over)) { if (rows) seq[Number(i) - 1] = rows; } - // No-partner orgs skip the authenticator_policies query (#16), so drop that slot + // No-partner orgs skip the authenticator_policies query (#15), so drop that slot // to keep the remaining queries aligned with the generator's actual call order. - if (opts.noPartner) seq.splice(15, 1); + if (opts.noPartner) seq.splice(14, 1); const m = vi.mocked(db.select); m.mockReset(); - // Fill any sparse holes (e.g. overriding #19 without #18) with [] so every + // Fill any sparse holes (e.g. overriding #18 without #17) with [] so every // queued query resolves to an iterable, not undefined. for (let i = 0; i < seq.length; i++) m.mockReturnValueOnce(selectChain(seq[i] ?? [])); m.mockReturnValue(selectChain([])); } describe('generateSecurityCompliancePostureReport', () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + vulnerabilityMocks.loadOpenVulnerabilityCounts.mockResolvedValue( + new Map([ + ['dev-1', { high: 2, critical: 1 }], + ['dev-2', { high: 1, critical: 0 }], + ]), + ); + }); + + it('places tenant-safe open vulnerability counts into per-device rows', async () => { + mockGeneratorQueries(); + const result = await generateSecurityCompliancePostureReport(ORG, {}); + const rows = Object.fromEntries((result.rows as any[]).map((row) => [row.hostname, row])); + + expect(vulnerabilityMocks.loadOpenVulnerabilityCounts).toHaveBeenCalledWith([ + 'dev-1', + 'dev-2', + 'dev-3', + ]); + expect(rows['pc-1']).toMatchObject({ openVulnHigh: 2, openVulnCritical: 1 }); + expect(rows['pc-2']).toMatchObject({ openVulnHigh: 1, openVulnCritical: 0 }); + }); it('merges managed EDR with native AV and flags unprotected devices', async () => { mockGeneratorQueries(); @@ -112,6 +138,19 @@ describe('generateSecurityCompliancePostureReport', () => { expect(c.passwordComplexityPct).toBe(50); }); + it('carries the backup requirement without changing posture score', async () => { + mockGeneratorQueries(); + const summary = (await generateSecurityCompliancePostureReport(ORG, { + backupRequired: false, + })).summary as any; + expect(summary.controls.backupRequired).toBe(false); + expect(summary.controls.backupConfigured).toBe(true); + expect(summary.securityProducts).toContainEqual( + expect.objectContaining({ category: 'backup' }), + ); + expect(summary.postureScore).toBe(82); + }); + it('summarizes privileged access from PAM tables', async () => { mockGeneratorQueries(); const r = await generateSecurityCompliancePostureReport(ORG, {}); @@ -131,9 +170,9 @@ describe('generateSecurityCompliancePostureReport', () => { }); it('aggregates CIS pass-rate per device when included and scans exist', async () => { - // CIS is query #18 (after posture); provide latest-scan rows per device. + // CIS is query #17 (after posture); provide latest-scan rows per device. mockGeneratorQueries({ - 18: [ + 17: [ { deviceId: 'dev-1', passedChecks: 80, totalChecks: 100 }, { deviceId: 'dev-2', passedChecks: 60, totalChecks: 100 } ] @@ -155,7 +194,7 @@ describe('generateSecurityCompliancePostureReport', () => { }); it('reports identity-connected (not MFA) and AV-definitions currency, with patch unknowns', async () => { - mockGeneratorQueries(); // patch-scanned (#19) defaults empty → nothing patch-assessed + mockGeneratorQueries(); // patch-scanned (#18) defaults empty → nothing patch-assessed const c = (await generateSecurityCompliancePostureReport(ORG, {})).summary!.controls as any; // C2: the control says only what's proven — identity connected, NOT MFA enforced. expect(c.identityProviderConnected).toBe(true); @@ -170,8 +209,8 @@ describe('generateSecurityCompliancePostureReport', () => { }); it('computes patch currency only over patch-scanned devices (H1)', async () => { - // #6 pending = dev-2 critical; #19 scanned-set = dev-1 + dev-2 (dev-3 never scanned) - mockGeneratorQueries({ 19: [{ deviceId: 'dev-1' }, { deviceId: 'dev-2' }] }); + // #6 pending = dev-2 critical; #18 scanned-set = dev-1 + dev-2 (dev-3 never scanned) + mockGeneratorQueries({ 18: [{ deviceId: 'dev-1' }, { deviceId: 'dev-2' }] }); const c = (await generateSecurityCompliancePostureReport(ORG, {})).summary!.controls as any; expect(c.patchCurrentPct).toBe(50); // dev-1 current, dev-2 has critical pending expect(c.patchUnknownCount).toBe(1); // dev-3 unscanned @@ -195,7 +234,7 @@ describe('generateSecurityCompliancePostureReport', () => { }); it('marks a failing-sync DNS integration as degraded, not active (H3)', async () => { - mockGeneratorQueries({ 8: [{ isActive: true, provider: 'umbrella', lastSyncStatus: 'error' }] }); + mockGeneratorQueries({ 7: [{ isActive: true, provider: 'umbrella', lastSyncStatus: 'error' }] }); const summary = (await generateSecurityCompliancePostureReport(ORG, {})).summary as any; expect(summary.controls.dnsFilteringActive).toBe(false); expect(summary.controls.dnsFilteringSyncStatus).toBe('error'); @@ -204,7 +243,7 @@ describe('generateSecurityCompliancePostureReport', () => { }); it('reports CIS coverage (assessed count) alongside the average (H4)', async () => { - mockGeneratorQueries({ 18: [{ deviceId: 'dev-1', passedChecks: 90, totalChecks: 100 }] }); + mockGeneratorQueries({ 17: [{ deviceId: 'dev-1', passedChecks: 90, totalChecks: 100 }] }); const c = (await generateSecurityCompliancePostureReport(ORG, {})).summary!.controls as any; expect(c.cisAvgPassRate).toBe(90); expect(c.cisAssessedCount).toBe(1); // 1 of 3 devices scanned — coverage is visible @@ -271,14 +310,62 @@ describe('generateSecurityCompliancePostureReport', () => { expect(names.join(' ')).toMatch(/umbrella|dns/); }); + it('keeps the unidentified "other" provider off a client-facing inventory', async () => { + // The base fixture has a provider:'other' row. There is no pretty name for + // it, so dropping the guard in the generator would ship a green-dotted + // product literally called "other" on an insurance attestation PDF. + mockGeneratorQueries(); + const r = await generateSecurityCompliancePostureReport(ORG, {}); + const names = (r.summary as any).securityProducts.map((p: any) => p.product.toLowerCase()); + expect(names).not.toContain('other'); + }); + + it('lists native and managed products once with unique scoped coverage', async () => { + mockGeneratorQueries({ + 2: [ + { id: 'dev-1', hostname: 'pc-1', osType: 'windows', siteName: 'HQ' }, + { id: 'dev-2', hostname: 'pc-2', osType: 'windows', siteName: 'HQ' }, + { id: 'dev-3', hostname: 'pc-3', osType: 'windows', siteName: 'Remote' }, + { id: 'dev-4', hostname: 'pc-4', osType: 'windows', siteName: 'Remote' }, + { id: 'dev-5', hostname: 'pc-5', osType: 'windows', siteName: 'HQ' }, + { id: 'dev-6', hostname: 'pc-6', osType: 'windows', siteName: 'Remote' }, + ], + 3: [ + { deviceId: 'dev-1', provider: 'windows_defender', realTimeProtection: true, definitionsDate: new Date(), encryptionStatus: 'encrypted', firewallEnabled: true, passwordPolicySummary: null, localAdminSummary: null }, + { deviceId: 'dev-2', provider: 'windows_defender', realTimeProtection: true, definitionsDate: new Date(), encryptionStatus: 'encrypted', firewallEnabled: true, passwordPolicySummary: null, localAdminSummary: null }, + { deviceId: 'dev-3', provider: 'windows_defender', realTimeProtection: true, definitionsDate: new Date(), encryptionStatus: 'encrypted', firewallEnabled: true, passwordPolicySummary: null, localAdminSummary: null }, + { deviceId: 'dev-4', provider: 'windows_defender', realTimeProtection: true, definitionsDate: new Date(), encryptionStatus: 'encrypted', firewallEnabled: true, passwordPolicySummary: null, localAdminSummary: null }, + { deviceId: 'dev-5', provider: 'sentinelone', realTimeProtection: true, definitionsDate: new Date(), encryptionStatus: 'encrypted', firewallEnabled: true, passwordPolicySummary: null, localAdminSummary: null }, + { deviceId: 'dev-6', provider: 'sentinelone', realTimeProtection: false, definitionsDate: new Date(), encryptionStatus: 'encrypted', firewallEnabled: true, passwordPolicySummary: null, localAdminSummary: null }, + ], + 4: [{ deviceId: 'dev-5' }, { deviceId: 'dev-6' }], + }); + + const summary = (await generateSecurityCompliancePostureReport(ORG, {})).summary as any; + expect(summary.securityProducts.filter((p: any) => p.product === 'Defender')).toEqual([ + expect.objectContaining({ category: 'antivirus', deviceCoverage: 4, active: true }), + ]); + expect(summary.securityProducts.filter((p: any) => p.product === 'SentinelOne')).toEqual([ + expect.objectContaining({ category: 'edr', deviceCoverage: 2, active: true }), + ]); + }); + it('returns empty rows but a valid summary when no devices in scope', async () => { const svc = await import('./reportGenerationService'); vi.mocked(svc.resolveSiteAllowedDeviceIds).mockResolvedValueOnce([]); mockGeneratorQueries(); - const r = await generateSecurityCompliancePostureReport(ORG, {}); + const r = await generateSecurityCompliancePostureReport(ORG, { backupRequired: false }); expect(r.rows).toEqual([]); expect((r.summary as any).deviceCount).toBe(0); + expect((r.summary as any).controls.backupRequired).toBe(false); // No devices assessed → null ("N/A"), never a misleading 0%. expect((r.summary as any).controls.edrCoveragePct).toBeNull(); }); + + it('carries backupRequired when the device query returns no rows', async () => { + mockGeneratorQueries({ 2: [] }); + const r = await generateSecurityCompliancePostureReport(ORG, { backupRequired: false }); + expect(r.rows).toEqual([]); + expect((r.summary as any).controls.backupRequired).toBe(false); + }); }); diff --git a/apps/api/src/services/securityComplianceReport.ts b/apps/api/src/services/securityComplianceReport.ts index df861682a1..8d775fb08e 100644 --- a/apps/api/src/services/securityComplianceReport.ts +++ b/apps/api/src/services/securityComplianceReport.ts @@ -5,7 +5,6 @@ import { backupConfigs, c2cConnections, devicePatches, - deviceVulnerabilities, devices, dnsFilterIntegrations, elevationRequests, @@ -21,13 +20,19 @@ import { s1Agents, securityPostureOrgSnapshots, securityStatus, - sites, - vulnerabilities + sites } from '../db/schema'; import { securityCompliancePostureConfigSchema } from '../routes/reports/schemas'; -import type { PostureSummary, PostureProduct } from '@breeze/shared'; +import type { PostureSummary } from '@breeze/shared'; import { canAccessSite, type UserPermissions } from './permissions'; import { resolveSiteAllowedDeviceIds, type ReportResult } from './reportGenerationService'; +import { + buildSecurityProductInventory, + categoryForEndpointProvider, + prettySecurityProvider, + type SecurityProductEvidence +} from './securityComplianceReportProducts'; +import { loadOpenVulnerabilityCounts } from './securityComplianceReportVulnerabilities'; const pct = (num: number, denom: number): number => denom === 0 ? 0 : Math.round((num / denom) * 100); @@ -53,28 +58,13 @@ function protectionLabel(opts: { }): string { const parts = [...opts.managed]; if (opts.nativeProvider && opts.nativeProvider !== 'other') { - parts.push(prettyProvider(opts.nativeProvider)); + parts.push(prettySecurityProvider(opts.nativeProvider)); } if (parts.length === 0) return 'None detected'; const rtp = opts.rtp === true ? ' (RTP on)' : opts.rtp === false ? ' (RTP off)' : ''; return parts.join(' + ') + rtp; } -function prettyProvider(p: string): string { - const map: Record = { - windows_defender: 'Defender', - sentinelone: 'SentinelOne', - crowdstrike: 'CrowdStrike', - bitdefender: 'Bitdefender', - sophos: 'Sophos', - malwarebytes: 'Malwarebytes', - eset: 'ESET', - kaspersky: 'Kaspersky', - elastic_defend: 'Elastic Defend' - }; - return map[p] ?? p; -} - /** * Tri-state password-policy evaluation: `null` when the device reported no usable * policy object (unknown — exclude from the denominator), otherwise pass/fail. @@ -101,7 +91,8 @@ function localAdminCount(summary: unknown): number | null { function emptySummary( orgRow: { id: string; name: string } | undefined, generatedAt: string, - includeCis = true + includeCis = true, + backupRequired = true ) { return { org: { id: orgRow?.id ?? '', name: orgRow?.name ?? 'Unknown' }, @@ -124,6 +115,7 @@ function emptySummary( cisIncluded: includeCis, cisAssessedCount: 0, identityProviderConnected: false, + backupRequired, backupConfigured: false, backupEncrypted: null, dnsFilteringActive: false, @@ -180,7 +172,7 @@ export async function generateSecurityCompliancePostureReport( rows: [], rowCount: 0, generatedAt, - summary: emptySummary(orgRow, generatedAt, cfg.includeCis) + summary: emptySummary(orgRow, generatedAt, cfg.includeCis, cfg.backupRequired) }; } @@ -209,7 +201,7 @@ export async function generateSecurityCompliancePostureReport( rows: [], rowCount: 0, generatedAt, - summary: emptySummary(orgRow, generatedAt, cfg.includeCis) + summary: emptySummary(orgRow, generatedAt, cfg.includeCis, cfg.backupRequired) }; } @@ -258,24 +250,7 @@ export async function generateSecurityCompliancePostureReport( pendingByDevice.set(p.deviceId, e); } - const vulnRows = await db - .select({ deviceId: deviceVulnerabilities.deviceId, severity: vulnerabilities.severity }) - .from(deviceVulnerabilities) - .innerJoin(vulnerabilities, eq(deviceVulnerabilities.vulnerabilityId, vulnerabilities.id)) - .where( - and( - eq(deviceVulnerabilities.orgId, orgId), - inArray(deviceVulnerabilities.deviceId, deviceIds), - eq(deviceVulnerabilities.status, 'open') - ) - ); - const vulnByDevice = new Map(); - for (const v of vulnRows) { - const e = vulnByDevice.get(v.deviceId) ?? { critical: 0, high: 0 }; - if (v.severity === 'critical') e.critical += 1; - else if (v.severity === 'high') e.high += 1; - vulnByDevice.set(v.deviceId, e); - } + const vulnByDevice = await loadOpenVulnerabilityCounts(deviceIds); const [dns] = await db .select({ isActive: dnsFilterIntegrations.isActive, provider: dnsFilterIntegrations.provider, lastSyncStatus: dnsFilterIntegrations.lastSyncStatus }) @@ -473,14 +448,41 @@ export async function generateSecurityCompliancePostureReport( const dnsSyncStatus = dns?.lastSyncStatus ?? null; const dnsActive = Boolean(dns) && dnsSyncStatus !== 'error'; - const securityProducts: PostureProduct[] = []; - if (huntressDevices.size > 0) securityProducts.push({ product: 'Huntress', category: 'mdr', active: true, lastSyncStatus: null, deviceCoverage: huntressDevices.size }); - if (s1Devices.size > 0) securityProducts.push({ product: 'SentinelOne', category: 'edr', active: true, lastSyncStatus: null, deviceCoverage: s1Devices.size }); - if (dns) securityProducts.push({ product: prettyDnsProvider(dns.provider), category: 'dns_filtering', active: dnsActive, lastSyncStatus: dnsSyncStatus, deviceCoverage: null }); - if (backup) securityProducts.push({ product: `Backup (${backup.provider})`, category: 'backup', active: true, lastSyncStatus: null, deviceCoverage: null }); - if (c2c) securityProducts.push({ product: `SaaS backup (${c2c.provider})`, category: 'backup', active: true, lastSyncStatus: null, deviceCoverage: null }); - if (m365) securityProducts.push({ product: 'Microsoft 365', category: 'identity', active: true, lastSyncStatus: null, deviceCoverage: null }); - if (google) securityProducts.push({ product: 'Google Workspace', category: 'identity', active: true, lastSyncStatus: null, deviceCoverage: null }); + const productEvidence: SecurityProductEvidence[] = []; + if (huntressDevices.size > 0) { + productEvidence.push({ + product: 'Huntress', + category: 'mdr', + active: true, + lastSyncStatus: null, + deviceIds: huntressDevices + }); + } + if (s1Devices.size > 0) { + productEvidence.push({ + product: 'SentinelOne', + category: 'edr', + active: true, + lastSyncStatus: null, + deviceIds: s1Devices + }); + } + for (const row of ssRows) { + if (row.provider === 'other') continue; + productEvidence.push({ + product: prettySecurityProvider(row.provider), + category: categoryForEndpointProvider(row.provider), + active: row.realTimeProtection === true, + lastSyncStatus: null, + deviceIds: [row.deviceId] + }); + } + if (dns) productEvidence.push({ product: prettyDnsProvider(dns.provider), category: 'dns_filtering', active: dnsActive, lastSyncStatus: dnsSyncStatus }); + if (backup) productEvidence.push({ product: `Backup (${backup.provider})`, category: 'backup', active: true, lastSyncStatus: null }); + if (c2c) productEvidence.push({ product: `SaaS backup (${c2c.provider})`, category: 'backup', active: true, lastSyncStatus: null }); + if (m365) productEvidence.push({ product: 'Microsoft 365', category: 'identity', active: true, lastSyncStatus: null }); + if (google) productEvidence.push({ product: 'Google Workspace', category: 'identity', active: true, lastSyncStatus: null }); + const securityProducts = buildSecurityProductInventory(productEvidence); const summary = { org: { id: orgRow?.id ?? orgId, name: orgRow?.name ?? 'Unknown' }, @@ -505,6 +507,7 @@ export async function generateSecurityCompliancePostureReport( // Proves an identity provider is CONNECTED, not that MFA is enforced. // Real MFA enforcement is privilegedAccess.mfaStepUpEnforced. identityProviderConnected: Boolean(m365 || google), + backupRequired: cfg.backupRequired, backupConfigured: Boolean(backup || c2c), backupEncrypted: backup ? Boolean(backup.encryption) : null, dnsFilteringActive: dnsActive, diff --git a/apps/api/src/services/securityComplianceReportProducts.test.ts b/apps/api/src/services/securityComplianceReportProducts.test.ts new file mode 100644 index 0000000000..49bd8b64c7 --- /dev/null +++ b/apps/api/src/services/securityComplianceReportProducts.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { buildSecurityProductInventory } from './securityComplianceReportProducts'; + +describe('buildSecurityProductInventory', () => { + it('includes native Defender and endpoint-only SentinelOne', () => { + const result = buildSecurityProductInventory([ + { product: 'Defender', category: 'antivirus', active: true, deviceIds: ['d1', 'd2'] }, + { product: 'SentinelOne', category: 'edr', active: true, deviceIds: ['d3'] }, + ]); + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ product: 'Defender', category: 'antivirus', deviceCoverage: 2 }), + expect.objectContaining({ product: 'SentinelOne', category: 'edr', deviceCoverage: 1 }), + ]), + ); + }); + + it('deduplicates managed and endpoint evidence by unique device id', () => { + const [sentinelOne] = buildSecurityProductInventory([ + { product: 'SentinelOne', category: 'edr', active: true, deviceIds: ['d1', 'd2'] }, + { product: 'sentinel one', category: 'edr', active: false, deviceIds: ['d2', 'd3'] }, + ]); + + expect(sentinelOne).toMatchObject({ + product: 'SentinelOne', + active: true, + deviceCoverage: 3, + }); + }); + + it('keeps RTP-off endpoint evidence visible as inactive', () => { + expect( + buildSecurityProductInventory([ + { product: 'Defender', category: 'antivirus', active: false, deviceIds: ['d1'] }, + ]), + ).toEqual([ + expect.objectContaining({ product: 'Defender', active: false, deviceCoverage: 1 }), + ]); + }); +}); diff --git a/apps/api/src/services/securityComplianceReportProducts.ts b/apps/api/src/services/securityComplianceReportProducts.ts new file mode 100644 index 0000000000..b3b54d2ffb --- /dev/null +++ b/apps/api/src/services/securityComplianceReportProducts.ts @@ -0,0 +1,91 @@ +import type { PostureProduct, PostureProductCategory } from '@breeze/shared'; + +export type SecurityProductEvidence = Omit & { + deviceIds?: Iterable; +}; + +const PROVIDER_NAMES: Record = { + windows_defender: 'Defender', + sentinelone: 'SentinelOne', + crowdstrike: 'CrowdStrike', + bitdefender: 'Bitdefender', + sophos: 'Sophos', + malwarebytes: 'Malwarebytes', + eset: 'ESET', + kaspersky: 'Kaspersky', + elastic_defend: 'Elastic Defend', +}; + +const EDR_PROVIDERS = new Set(['sentinelone', 'crowdstrike', 'elastic_defend']); +// Keyed by the union rather than an array: indexOf() on a missing member returns +// -1 and silently sorts it to the front, whereas a new PostureProductCategory +// that isn't ranked here fails the build. +const CATEGORY_ORDER: Record = { + mdr: 0, + edr: 1, + antivirus: 2, + dns_filtering: 3, + backup: 4, + identity: 5, +}; + +export function prettySecurityProvider(provider: string): string { + return PROVIDER_NAMES[provider] ?? provider; +} + +export function categoryForEndpointProvider(provider: string): 'edr' | 'antivirus' { + return EDR_PROVIDERS.has(provider) ? 'edr' : 'antivirus'; +} + +const productKey = (name: string): string => name.toLowerCase().replace(/[^a-z0-9]/g, ''); + +export function buildSecurityProductInventory( + evidence: SecurityProductEvidence[], +): PostureProduct[] { + const merged = new Map< + string, + { + product: string; + category: PostureProductCategory; + active: boolean; + lastSyncStatus: string | null; + deviceIds: Set | null; + } + >(); + + for (const item of evidence) { + const key = productKey(item.product); + const current = merged.get(key); + const ids = item.deviceIds ? new Set(item.deviceIds) : null; + if (!current) { + merged.set(key, { + product: item.product, + category: item.category, + active: item.active, + lastSyncStatus: item.lastSyncStatus ?? null, + deviceIds: ids, + }); + continue; + } + current.active ||= item.active; + current.lastSyncStatus ??= item.lastSyncStatus ?? null; + if (ids) { + current.deviceIds ??= new Set(); + for (const id of ids) current.deviceIds.add(id); + } + } + + return [...merged.values()] + .map((item) => ({ + product: item.product, + category: item.category, + active: item.active, + lastSyncStatus: item.lastSyncStatus, + deviceCoverage: item.deviceIds?.size ?? null, + })) + .sort( + (a, b) => + CATEGORY_ORDER[a.category] - CATEGORY_ORDER[b.category] || + a.product.localeCompare(b.product), + ); +} diff --git a/apps/api/src/services/securityComplianceReportVulnerabilities.test.ts b/apps/api/src/services/securityComplianceReportVulnerabilities.test.ts new file mode 100644 index 0000000000..66c431310f --- /dev/null +++ b/apps/api/src/services/securityComplianceReportVulnerabilities.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const selectMock = vi.fn(); + +vi.mock('../db', () => ({ + db: { select: (...args: unknown[]) => selectMock(...args) }, + runOutsideDbContext: vi.fn((fn: () => unknown) => fn()), + withSystemDbAccessContext: vi.fn((fn: () => unknown) => fn()), +})); + +vi.mock('../db/schema', () => ({ + deviceVulnerabilities: { + table: 'deviceVulnerabilities', + deviceId: 'deviceVulnerabilities.deviceId', + vulnerabilityId: 'deviceVulnerabilities.vulnerabilityId', + status: 'deviceVulnerabilities.status', + }, + vulnerabilities: { + table: 'vulnerabilities', + id: 'vulnerabilities.id', + severity: 'vulnerabilities.severity', + }, +})); + +vi.mock('drizzle-orm', () => ({ + and: (...conditions: unknown[]) => ({ op: 'and', conditions }), + eq: (column: unknown, value: unknown) => ({ op: 'eq', column, value }), + inArray: (column: unknown, values: unknown[]) => ({ op: 'inArray', column, values }), +})); + +import { runOutsideDbContext, withSystemDbAccessContext } from '../db'; +import { deviceVulnerabilities, vulnerabilities } from '../db/schema'; +import { + aggregateVulnerabilityCounts, + loadOpenVulnerabilityCounts, +} from './securityComplianceReportVulnerabilities'; + +beforeEach(() => vi.clearAllMocks()); + +describe('aggregateVulnerabilityCounts', () => { + it('normalizes source severity casing and counts findings per device', () => { + const counts = aggregateVulnerabilityCounts( + [ + { deviceId: 'd1', vulnerabilityId: 'v1' }, + { deviceId: 'd1', vulnerabilityId: 'v2' }, + { deviceId: 'd2', vulnerabilityId: 'v3' }, + { deviceId: 'd2', vulnerabilityId: 'v4' }, + ], + [ + { id: 'v1', severity: 'HIGH' }, + { id: 'v2', severity: 'Critical' }, + { id: 'v3', severity: 'High' }, + { id: 'v4', severity: 'CRITICAL' }, + ], + ); + + expect(counts.get('d1')).toEqual({ high: 1, critical: 1 }); + expect(counts.get('d2')).toEqual({ high: 1, critical: 1 }); + }); + + it('fails instead of publishing zeroes when referenced catalog rows are missing', () => { + expect(() => + aggregateVulnerabilityCounts( + [{ deviceId: 'd1', vulnerabilityId: 'missing' }], + [], + ), + ).toThrow('Vulnerability catalog lookup incomplete'); + }); +}); + +describe('loadOpenVulnerabilityCounts', () => { + it('reads an over-batch catalog in bounded selects within one system context', async () => { + const vulnerabilityIds = Array.from( + { length: 10_001 }, + (_, index) => `vulnerability-${index}`, + ); + const findings = vulnerabilityIds.map((vulnerabilityId) => ({ + deviceId: 'device-1', + vulnerabilityId, + })); + const catalogBatches: unknown[][] = []; + + selectMock.mockImplementation(() => ({ + from: (table: unknown) => ({ + where: (predicate: { values?: unknown[] }) => { + if (table === deviceVulnerabilities) return Promise.resolve(findings); + expect(table).toBe(vulnerabilities); + const batch = predicate.values ?? []; + catalogBatches.push(batch); + return Promise.resolve( + batch.map((id) => ({ id: String(id), severity: 'HIGH' })), + ); + }, + }), + })); + + const counts = await loadOpenVulnerabilityCounts(['device-1']); + + expect(catalogBatches).toHaveLength(2); + expect(catalogBatches.map((batch) => batch.length)).toEqual([10_000, 1]); + expect(runOutsideDbContext).toHaveBeenCalledTimes(1); + expect(withSystemDbAccessContext).toHaveBeenCalledTimes(1); + expect(counts.get('device-1')).toEqual({ high: 10_001, critical: 0 }); + }); + + it('returns an empty map without querying or changing context for empty input', async () => { + await expect(loadOpenVulnerabilityCounts([])).resolves.toEqual(new Map()); + expect(selectMock).not.toHaveBeenCalled(); + expect(runOutsideDbContext).not.toHaveBeenCalled(); + expect(withSystemDbAccessContext).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/services/securityComplianceReportVulnerabilities.ts b/apps/api/src/services/securityComplianceReportVulnerabilities.ts new file mode 100644 index 0000000000..9b4ac9f89e --- /dev/null +++ b/apps/api/src/services/securityComplianceReportVulnerabilities.ts @@ -0,0 +1,90 @@ +import { and, eq, inArray } from 'drizzle-orm'; + +import { db, runOutsideDbContext, withSystemDbAccessContext } from '../db'; +import { deviceVulnerabilities, vulnerabilities } from '../db/schema'; + +export type DeviceVulnerabilityCounts = { critical: number; high: number }; + +type FindingRow = { deviceId: string; vulnerabilityId: string }; +type CatalogRow = { id: string; severity: string | null }; + +const VULNERABILITY_CATALOG_BATCH_SIZE = 10_000; + +export function aggregateVulnerabilityCounts( + findings: FindingRow[], + catalogRows: CatalogRow[], +): Map { + const catalogById = new Map(catalogRows.map((row) => [row.id, row])); + const missingIds = [ + ...new Set( + findings + .map((finding) => finding.vulnerabilityId) + .filter((id) => !catalogById.has(id)), + ), + ]; + if (missingIds.length > 0) { + throw new Error( + `Vulnerability catalog lookup incomplete: ${missingIds.length} referenced record(s) missing`, + ); + } + + const counts = new Map(); + for (const finding of findings) { + const severity = catalogById + .get(finding.vulnerabilityId) + ?.severity?.toLowerCase(); + if (severity !== 'critical' && severity !== 'high') continue; + const current = counts.get(finding.deviceId) ?? { critical: 0, high: 0 }; + current[severity] += 1; + counts.set(finding.deviceId, current); + } + return counts; +} + +export async function loadOpenVulnerabilityCounts( + deviceIds: string[], +): Promise> { + if (deviceIds.length === 0) return new Map(); + + const findings = await db + .select({ + deviceId: deviceVulnerabilities.deviceId, + vulnerabilityId: deviceVulnerabilities.vulnerabilityId, + }) + .from(deviceVulnerabilities) + .where( + and( + inArray(deviceVulnerabilities.deviceId, deviceIds), + eq(deviceVulnerabilities.status, 'open'), + ), + ); + + const vulnerabilityIds = [ + ...new Set(findings.map((row) => row.vulnerabilityId)), + ]; + if (vulnerabilityIds.length === 0) return new Map(); + + const catalogRows = await runOutsideDbContext(() => + withSystemDbAccessContext(async () => { + const rows: CatalogRow[] = []; + for ( + let offset = 0; + offset < vulnerabilityIds.length; + offset += VULNERABILITY_CATALOG_BATCH_SIZE + ) { + const batch = vulnerabilityIds.slice( + offset, + offset + VULNERABILITY_CATALOG_BATCH_SIZE, + ); + const batchRows = await db + .select({ id: vulnerabilities.id, severity: vulnerabilities.severity }) + .from(vulnerabilities) + .where(inArray(vulnerabilities.id, batch)); + rows.push(...batchRows); + } + return rows; + }), + ); + + return aggregateVulnerabilityCounts(findings, catalogRows); +} diff --git a/apps/api/src/services/sentinelOne/metrics.test.ts b/apps/api/src/services/sentinelOne/metrics.test.ts new file mode 100644 index 0000000000..e48988b66c --- /dev/null +++ b/apps/api/src/services/sentinelOne/metrics.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { + getS1MetricsSnapshot, + recordS1ActionDispatch, + recordS1ActionPollTransition, + recordS1SyncRun, + resetS1MetricsForTesting, +} from './metrics'; + +describe('SentinelOne metrics state', () => { + it('clears populated snapshots during a test reset', () => { + recordS1SyncRun('sync-integration', 'success', 25); + recordS1ActionDispatch('isolate', 'accepted'); + recordS1ActionPollTransition('completed'); + + expect(getS1MetricsSnapshot()).toEqual({ + syncRuns: [{ labels: { job: 'sync-integration', outcome: 'success' }, value: 1 }], + actionDispatches: [{ labels: { action: 'isolate', outcome: 'accepted' }, value: 1 }], + actionPollTransitions: [{ labels: { status: 'completed' }, value: 1 }], + }); + + resetS1MetricsForTesting(); + + expect(getS1MetricsSnapshot()).toEqual({ + syncRuns: [], + actionDispatches: [], + actionPollTransitions: [], + }); + }); +}); diff --git a/apps/api/src/services/sentinelOne/metrics.ts b/apps/api/src/services/sentinelOne/metrics.ts index 235a0b8d7b..3ad577c179 100644 --- a/apps/api/src/services/sentinelOne/metrics.ts +++ b/apps/api/src/services/sentinelOne/metrics.ts @@ -38,6 +38,13 @@ export function setS1MetricsRecorder(next: S1MetricsRecorder | null): void { recorder = next; } +export function resetS1MetricsForTesting(): void { + s1SyncRunState.clear(); + s1ActionDispatchState.clear(); + s1ActionPollTransitionState.clear(); + recorder = null; +} + export function recordS1SyncRun( job: S1SyncJob, outcome: S1SyncOutcome, diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index c4a21571b8..de8a454d4e 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'vitest/config'; +import { availableParallelism } from 'node:os'; import path from 'path'; export default defineConfig({ @@ -11,6 +12,7 @@ export default defineConfig({ test: { globals: true, environment: 'node', + maxWorkers: Math.max(1, Math.min(4, Math.floor(availableParallelism() / 2))), include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'], exclude: [ 'src/__tests__/integration/**', diff --git a/apps/docs/src/content/docs/features/reports.mdx b/apps/docs/src/content/docs/features/reports.mdx index c735149117..fd669121fb 100644 --- a/apps/docs/src/content/docs/features/reports.mdx +++ b/apps/docs/src/content/docs/features/reports.mdx @@ -233,9 +233,13 @@ It is scoped to a single organization (with an optional site filter) and is hone The PDF also includes a **Recommended actions** section generated from whichever controls are currently failing, and a plain-language **glossary** so a non-technical reader -- an insurance underwriter, a customer's compliance contact -- can follow it without help. When a prior run exists to compare against, the scorecard shows how the score has moved since then (for example "+5 since Jun 1"). The cover carries your logo and name if [partner branding](/features/branding/) is configured; otherwise it uses the standard Breeze mark. --- diff --git a/apps/web/src/components/reports/PostureReportOptionsForm.tsx b/apps/web/src/components/reports/PostureReportOptionsForm.tsx new file mode 100644 index 0000000000..6ef06b8755 --- /dev/null +++ b/apps/web/src/components/reports/PostureReportOptionsForm.tsx @@ -0,0 +1,75 @@ +import { useTranslation } from 'react-i18next'; + +type FieldProps = { + backupRequired: boolean; + onBackupRequiredChange: (value: boolean) => void; +}; + +type Props = FieldProps & { + busy?: boolean; + submitLabel: string; + onSubmit: () => void; + onCancel: () => void; +}; + +/** + * The posture-only option on its own, for composing alongside another form's + * submit controls (the edit page pairs it with ReportBuilder). + */ +export function PostureBackupRequiredField({ backupRequired, onBackupRequiredChange }: FieldProps) { + const { t } = useTranslation('reports'); + + return ( + + ); +} + +export function PostureReportOptionsForm({ + backupRequired, + busy = false, + submitLabel, + onBackupRequiredChange, + onSubmit, + onCancel, +}: Props) { + const { t } = useTranslation('reports'); + + return ( +
+ +
+ + +
+
+ ); +} diff --git a/apps/web/src/components/reports/ReportBuilder.test.tsx b/apps/web/src/components/reports/ReportBuilder.test.tsx index 6ccea81f84..4e543bd4c0 100644 --- a/apps/web/src/components/reports/ReportBuilder.test.tsx +++ b/apps/web/src/components/reports/ReportBuilder.test.tsx @@ -107,3 +107,69 @@ describe('ReportBuilder live preview', () => { expect(screen.queryByText('Triage Backlog')).not.toBeNull(); }); }); + +describe('ReportBuilder config preservation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const putBody = () => { + const call = fetchWithAuthMock.mock.calls.find( + ([url, init]) => url === '/reports/report-1' && (init as RequestInit | undefined)?.method === 'PUT' + ); + expect(call).toBeDefined(); + return JSON.parse(String((call![1] as RequestInit).body)) as { + config: Record; + }; + }; + + it('preserves config keys it does not own through an edit save', async () => { + fetchWithAuthMock.mockResolvedValue(makeJsonResponse({ data: {} })); + + render( + + ); + + fireEvent.click(await screen.findByTestId('report-builder-submit')); + + await waitFor(() => { + const { config } = putBody(); + expect(config.backupRequired).toBe(false); + expect(config.includeCis).toBe(false); + expect(config.maxLocalAdmins).toBe(4); + expect(config.sites).toEqual(['11111111-1111-4111-8111-111111111111']); + }); + }); + + it('lets current builder state win over stale baseConfig keys', async () => { + fetchWithAuthMock.mockResolvedValue(makeJsonResponse({ data: {} })); + + render( + + ); + + fireEvent.click(await screen.findByTestId('report-builder-submit')); + + await waitFor(() => { + const { config } = putBody(); + // posture key survives, but the builder's own key reflects live state + expect(config.backupRequired).toBe(true); + expect(config.builderType).toBe('compliance'); + }); + }); +}); diff --git a/apps/web/src/components/reports/ReportBuilder.tsx b/apps/web/src/components/reports/ReportBuilder.tsx index c8c3f8c56e..70cc5f99d8 100644 --- a/apps/web/src/components/reports/ReportBuilder.tsx +++ b/apps/web/src/components/reports/ReportBuilder.tsx @@ -92,6 +92,14 @@ type ReportBuilderProps = { mode?: 'create' | 'edit' | 'adhoc' | 'builder'; defaultValues?: Partial; reportId?: string; + /** + * The report's stored `config`. PUT /reports/:id replaces `config` wholesale, + * so anything the builder doesn't reconstruct is dropped on save. Report types + * that carry their own config — `security_compliance_posture`, + * `executive_summary` — must pass it here or their settings are silently reset + * to schema defaults on the next edit. + */ + baseConfig?: Record; onSubmit?: (values: ReportBuilderFormValues) => void | Promise; onPreview?: (values: ReportBuilderFormValues) => void | Promise; onCancel?: () => void; @@ -680,6 +688,7 @@ export default function ReportBuilder({ mode = 'create', defaultValues, reportId, + baseConfig, onSubmit, onPreview, onCancel @@ -1243,6 +1252,9 @@ export default function ReportBuilder({ format: primaryFormat, ...(currentOrgId ? { orgId: currentOrgId } : {}), config: { + // Keys the builder doesn't own (posture thresholds, executive-summary + // settings) come first so live builder state below still wins. + ...baseConfig, builderType, dataSource, columns: selectedFields, @@ -2083,6 +2095,7 @@ export default function ReportBuilder({ )} + ); + }, +})); + +const showToast = vi.fn(); +vi.mock('../shared/Toast', () => ({ showToast: (...args: unknown[]) => showToast(...args) })); + +import ReportEditPage from './ReportEditPage'; + +const report = { + id: 'report-1', + name: 'Workstation posture', + type: 'security_compliance_posture', + schedule: 'one_time', + format: 'pdf', + config: { backupRequired: false, includeCis: false, maxLocalAdmins: 4 }, + lastGeneratedAt: null, + createdAt: '2026-07-14T00:00:00.000Z', + updatedAt: '2026-07-14T00:00:00.000Z', +}; + +let loadedReport: Omit & { config: Record } = report; + +describe('ReportEditPage posture options', () => { + beforeEach(() => { + vi.clearAllMocks(); + loadedReport = report; + fetchWithAuth.mockImplementation((url: string, init?: RequestInit) => { + if (url === '/reports/report-1' && !init?.method) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(loadedReport) }); + } + if (url === '/reports/report-1' && init?.method === 'PUT') { + return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({}) }); + } + return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) }); + }); + }); + + const lastBaseConfig = () => + (builderProps.mock.calls.at(-1)![0] as { baseConfig: Record }).baseConfig; + + it('offers the generic builder alongside the posture option', async () => { + render(); + + await screen.findByTestId('posture-backup-required'); + // The regression this guards: posture reports were editable only via a + // single checkbox, with no way to change name, schedule or recipients. + await waitFor(() => expect(builderProps).toHaveBeenCalled()); + expect(screen.getByTestId('report-builder-submit')).toBeInTheDocument(); + }); + + it('hands the builder the stored posture config so a save cannot drop it', async () => { + render(); + + await screen.findByTestId('posture-backup-required'); + await waitFor(() => + expect(lastBaseConfig()).toEqual({ + backupRequired: false, + includeCis: false, + maxLocalAdmins: 4, + }), + ); + }); + + it('carries a toggled backup option into the builder payload', async () => { + render(); + const user = userEvent.setup(); + + const checkbox = await screen.findByTestId('posture-backup-required'); + expect(checkbox).not.toBeChecked(); + await user.click(checkbox); + await user.click(screen.getByTestId('report-builder-submit')); + + await waitFor(() => { + const putCall = fetchWithAuth.mock.calls.find( + ([url, init]) => + url === '/reports/report-1' && (init as RequestInit | undefined)?.method === 'PUT', + ); + expect(putCall).toBeDefined(); + expect(JSON.parse(String((putCall![1] as RequestInit).body))).toEqual({ + config: { backupRequired: true, includeCis: false, maxLocalAdmins: 4 }, + }); + }); + }); + + it('treats a missing legacy backupRequired value as required', async () => { + loadedReport = { ...report, config: { includeCis: true, maxLocalAdmins: 2 } }; + + render(); + + expect(await screen.findByTestId('posture-backup-required')).toBeChecked(); + await waitFor(() => expect(lastBaseConfig().backupRequired).toBe(true)); + }); + + it('leaves non-posture reports without the posture option but still config-safe', async () => { + loadedReport = { + ...report, + type: 'executive_summary', + config: { execSetting: 'keep-me' }, + }; + + render(); + + await waitFor(() => expect(builderProps).toHaveBeenCalled()); + expect(screen.queryByTestId('posture-backup-required')).toBeNull(); + // executive_summary carries its own config and hit the same wipe. + expect(lastBaseConfig()).toEqual({ execSetting: 'keep-me' }); + }); +}); diff --git a/apps/web/src/components/reports/ReportEditPage.tsx b/apps/web/src/components/reports/ReportEditPage.tsx index a6a6695245..de837742cc 100644 --- a/apps/web/src/components/reports/ReportEditPage.tsx +++ b/apps/web/src/components/reports/ReportEditPage.tsx @@ -5,6 +5,7 @@ import type { Report, ReportType } from './ReportsList'; import { fetchWithAuth } from '../../stores/auth'; import { navigateTo } from '@/lib/navigation'; import Breadcrumbs from '../layout/Breadcrumbs'; +import { PostureBackupRequiredField } from './PostureReportOptionsForm'; import { useTranslation } from 'react-i18next'; // Initializes the shared i18next singleton. Islands hydrate independently, so // an island that hydrates before whichever other island happens to pull i18n in @@ -20,6 +21,7 @@ export default function ReportEditPage({ reportId }: ReportEditPageProps) { const [report, setReport] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(); + const [backupRequired, setBackupRequired] = useState(true); const fetchReport = useCallback(async () => { try { @@ -29,8 +31,10 @@ export default function ReportEditPage({ reportId }: ReportEditPageProps) { if (!response.ok) { throw new Error(t('reports.reportEditPage.errors.fetchReport')); } - const data = await response.json(); + const data = await response.json() as Report; setReport(data); + const config = data.config as Record; + setBackupRequired(config.backupRequired !== false); } catch (err) { setError(err instanceof Error ? err.message : t('reports.reportEditPage.errors.generic')); } finally { @@ -90,6 +94,7 @@ export default function ReportEditPage({ reportId }: ReportEditPageProps) { // Convert report config to form values const config = report.config as Record; + const isPosture = report.type === 'security_compliance_posture'; const defaultValues: Partial = { name: report.name, type: report.type as ReportType, @@ -122,10 +127,20 @@ export default function ReportEditPage({ reportId }: ReportEditPageProps) { + {isPosture && ( +
+ +
+ )} + diff --git a/apps/web/src/components/reports/ReportTemplates.posture.test.tsx b/apps/web/src/components/reports/ReportTemplates.posture.test.tsx index 42588be276..92862952cc 100644 --- a/apps/web/src/components/reports/ReportTemplates.posture.test.tsx +++ b/apps/web/src/components/reports/ReportTemplates.posture.test.tsx @@ -56,6 +56,7 @@ describe('ReportTemplates — Security & Compliance Posture card', () => { render(); await clickUseTemplate('Security & Compliance Posture (Insurance)'); + await userEvent.setup().click(screen.getByTestId('posture-options-submit')); await waitFor(() => { expect(fetchWithAuth).toHaveBeenCalledWith('/reports', expect.objectContaining({ method: 'POST' })); @@ -67,7 +68,7 @@ describe('ReportTemplates — Security & Compliance Posture card', () => { expect(body.format).toBe('pdf'); // Must NOT open the freeform builder (which would downgrade to "compliance"). - expect(screen.queryByText(/Use Security & Compliance Posture/i)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/Report name/i)).not.toBeInTheDocument(); await waitFor(() => expect(navigateTo).toHaveBeenCalledWith('/reports')); expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })); }); @@ -77,6 +78,7 @@ describe('ReportTemplates — Security & Compliance Posture card', () => { render(); const card = await clickUseTemplate('Security & Compliance Posture (Insurance)'); + await userEvent.setup().click(screen.getByTestId('posture-options-submit')); await waitFor(() => expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))); expect(navigateTo).not.toHaveBeenCalledWith('/reports'); @@ -92,10 +94,39 @@ describe('ReportTemplates — Security & Compliance Posture card', () => { render(); await clickUseTemplate('Security & Compliance Posture (Insurance)'); + await userEvent.setup().click(screen.getByTestId('posture-options-submit')); await waitFor(() => expect(postCallBody()).toBeDefined()); expect(postCallBody()).not.toHaveProperty('orgId'); }); + + it('opens posture options and creates backup-optional by default', async () => { + mockTemplatesFetch(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ data: { id: 'rep-9' } }) })); + render(); + + await clickUseTemplate('Security & Compliance Posture (Insurance)'); + await userEvent.setup().click(screen.getByTestId('posture-options-submit')); + + expect(postCallBody()).toMatchObject({ + type: 'security_compliance_posture', + config: { backupRequired: false }, + }); + }); + + it('posts backupRequired true when the user opts in', async () => { + mockTemplatesFetch(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ data: { id: 'rep-9' } }) })); + render(); + + await clickUseTemplate('Security & Compliance Posture (Insurance)'); + const user = userEvent.setup(); + await user.click(screen.getByTestId('posture-backup-required')); + await user.click(screen.getByTestId('posture-options-submit')); + + expect(postCallBody()).toMatchObject({ + type: 'security_compliance_posture', + config: { backupRequired: true }, + }); + }); }); describe('ReportTemplates — builder-representable templates', () => { diff --git a/apps/web/src/components/reports/ReportTemplates.tsx b/apps/web/src/components/reports/ReportTemplates.tsx index d91ed92a8a..0ea80339f1 100644 --- a/apps/web/src/components/reports/ReportTemplates.tsx +++ b/apps/web/src/components/reports/ReportTemplates.tsx @@ -14,6 +14,7 @@ import { } from 'lucide-react'; import { cn } from '@/lib/utils'; import ReportBuilder, { reportTypeSurvivesBuilder, type ReportBuilderFormValues } from './ReportBuilder'; +import { PostureReportOptionsForm } from './PostureReportOptionsForm'; import type { ReportFormat, ReportSchedule } from './ReportsList'; import { fetchWithAuth } from '../../stores/auth'; import { useOrgStore } from '../../stores/orgStore'; @@ -344,6 +345,8 @@ export default function ReportTemplates() { const [loading, setLoading] = useState(false); const [error, setError] = useState(); const [activeTemplate, setActiveTemplate] = useState(null); + const [postureTemplate, setPostureTemplate] = useState(null); + const [backupRequired, setBackupRequired] = useState(false); const [builderOpen, setBuilderOpen] = useState(false); const [creatingId, setCreatingId] = useState(null); @@ -379,7 +382,7 @@ export default function ReportTemplates() { // Reports whose type the freeform builder would downgrade are saved directly // with their true type instead of being routed through it. const handleCreateDirect = useCallback( - async (template: ReportTemplate) => { + async (template: ReportTemplate, postureConfig: Record = {}) => { setCreatingId(template.id); try { await runAction({ @@ -393,7 +396,8 @@ export default function ReportTemplates() { format: template.defaults.format ?? 'pdf', ...(currentOrgId ? { orgId: currentOrgId } : {}), config: { - dateRange: template.defaults.dateRange ?? { preset: 'last_30_days' } + dateRange: template.defaults.dateRange ?? { preset: 'last_30_days' }, + ...postureConfig } }) }), @@ -419,6 +423,11 @@ export default function ReportTemplates() { // would silently downgrade them) are created directly; everything the // builder round-trips losslessly goes through the builder for tailoring. const type = template.defaults.type; + if (type === 'security_compliance_posture') { + setBackupRequired(false); + setPostureTemplate(template); + return; + } if (type && !reportTypeSurvivesBuilder(type)) { void handleCreateDirect(template); return; @@ -594,6 +603,30 @@ export default function ReportTemplates() { )} + + {postureTemplate && ( +
+
+

+ {t('reports.reportTemplates.useTemplateTitle', { + name: getTemplateDisplayName(postureTemplate), + })} +

+
+ setPostureTemplate(null)} + onSubmit={() => { + void handleCreateDirect(postureTemplate, { backupRequired }); + }} + /> +
+
+
+ )} ); } diff --git a/apps/web/src/locales/de-DE/reports.json b/apps/web/src/locales/de-DE/reports.json index be10bdd7a8..e7e91e9905 100644 --- a/apps/web/src/locales/de-DE/reports.json +++ b/apps/web/src/locales/de-DE/reports.json @@ -206,6 +206,12 @@ "topMemoryUsage": "Höchste Arbeitsspeicherauslastung", "totalDevices": "Gesamtzahl der Geräte" }, + "postureOptions": { + "requireBackupCoverage": "Backup-Abdeckung voraussetzen", + "requireBackupCoverageHelp": "Wenn Backups optional sind, bleiben sie als neutrale Nachweise sichtbar und erzeugen keine Empfehlung.", + "cancel": "Abbrechen", + "createReport": "Bericht erstellen" + }, "reportBuilderPage": { "configurationTitle": "Berichtskonfiguration", "description": "Erstellen Sie einen einmaligen Bericht, ohne ihn zu speichern.", diff --git a/apps/web/src/locales/en/reports.json b/apps/web/src/locales/en/reports.json index f44aba737d..73bcf4d462 100644 --- a/apps/web/src/locales/en/reports.json +++ b/apps/web/src/locales/en/reports.json @@ -206,6 +206,12 @@ "topMemoryUsage": "Top Memory Usage", "totalDevices": "Total Devices" }, + "postureOptions": { + "requireBackupCoverage": "Require backup coverage", + "requireBackupCoverageHelp": "When optional, backup remains visible as neutral evidence and does not create a recommendation.", + "cancel": "Cancel", + "createReport": "Create report" + }, "reportBuilderPage": { "configurationTitle": "Report Configuration", "description": "Generate a one-time report without saving it.", diff --git a/apps/web/src/locales/es-419/reports.json b/apps/web/src/locales/es-419/reports.json index b46e698395..672e516327 100644 --- a/apps/web/src/locales/es-419/reports.json +++ b/apps/web/src/locales/es-419/reports.json @@ -206,6 +206,12 @@ "topMemoryUsage": "Uso de memoria principal", "totalDevices": "Dispositivos totales" }, + "postureOptions": { + "requireBackupCoverage": "Exigir cobertura de respaldo", + "requireBackupCoverageHelp": "Cuando es opcional, el respaldo permanece visible como evidencia neutral y no genera una recomendación.", + "cancel": "Cancelar", + "createReport": "Crear informe" + }, "reportBuilderPage": { "configurationTitle": "Configuración de informes", "description": "Genere un informe único sin guardarlo.", diff --git a/apps/web/src/locales/fr-FR/reports.json b/apps/web/src/locales/fr-FR/reports.json index cd2b0c226f..c07e8cf4f9 100644 --- a/apps/web/src/locales/fr-FR/reports.json +++ b/apps/web/src/locales/fr-FR/reports.json @@ -206,6 +206,12 @@ "topMemoryUsage": "Utilisation principale de la mémoire", "totalDevices": "Nombre total d’appareils" }, + "postureOptions": { + "requireBackupCoverage": "Exiger une couverture de sauvegarde", + "requireBackupCoverageHelp": "Lorsqu'elle est facultative, la sauvegarde reste visible comme preuve neutre et ne génère aucune recommandation.", + "cancel": "Annuler", + "createReport": "Créer le rapport" + }, "reportBuilderPage": { "configurationTitle": "Configuration du rapport", "description": "Générez un rapport unique sans le enregistrer.", diff --git a/apps/web/src/locales/pt-BR/reports.json b/apps/web/src/locales/pt-BR/reports.json index 1c2ae5d49f..7cadbf3184 100644 --- a/apps/web/src/locales/pt-BR/reports.json +++ b/apps/web/src/locales/pt-BR/reports.json @@ -206,6 +206,12 @@ "topMemoryUsage": "Maior Uso de Memoria", "totalDevices": "Total de Dispositivos" }, + "postureOptions": { + "requireBackupCoverage": "Exigir cobertura de backup", + "requireBackupCoverageHelp": "Quando opcional, o backup permanece visível como evidência neutra e não gera uma recomendação.", + "cancel": "Cancelar", + "createReport": "Criar relatório" + }, "reportBuilderPage": { "configurationTitle": "Configuracao do Relatório", "description": "Gere um relatório unico sem salva-lo.", diff --git a/docs/superpowers/plans/2026-07-14-api-test-stability.md b/docs/superpowers/plans/2026-07-14-api-test-stability.md new file mode 100644 index 0000000000..9862bd89ce --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-api-test-stability.md @@ -0,0 +1,604 @@ +# API Test Stability Implementation Plan + +> **For Codex:** Execute this plan task-by-task with test-driven development. Keep these changes in commits separate from the Security & Compliance Posture report commits so they can be split into a follow-up if desired. + +**Goal:** Eliminate the seven API-suite timeout failures by removing repeated heavy module initialization and a filesystem-wide synchronous scan from the affected tests. + +**Architecture:** Preserve production behavior while making dependency boundaries explicit. Move the backup threshold to a dependency-free constants module, enforce the validator import rule through ESLint, and make route tests import each route graph once with hoisted mutable mock state instead of resetting Vitest's module registry for every case. Keep the OAuth-disabled mount assertion in an isolated test module so its environment configuration remains deterministic. + +**Tech Stack:** TypeScript, Vitest, Hono, ESLint flat config, pnpm. + +--- + +## Global constraints + +- Do not increase test timeouts as the fix. +- Do not weaken assertions or replace the real guardrail/RBAC units under test with mocks. +- Mock only unrelated heavy leaves such as the schema barrel, AI tool registry, Sentry logging, database client, and authentication middleware. +- Reset mutable mock state and call history between tests; do not call `vi.resetModules()` in the stabilized files. +- Keep all report implementation commits intact; create separate stabilization commits. + +### Task 1: Replace the synchronous validator import scan with ESLint + +**Files:** +- Modify: `apps/api/eslint.config.js` +- Delete: `apps/api/src/lib/validation.imports.test.ts` + +**Step 1: Demonstrate the missing lint enforcement** + +Run: + +```bash +printf '%s\n' "import { zValidator } from '@hono/zod-validator';" | pnpm --filter @breeze/api exec eslint --stdin --stdin-filename src/routes/__validator_guard_probe.ts +``` + +Expected before the change: exit 0, proving ESLint does not yet enforce the invariant. + +**Step 2: Add the flat-config rule** + +Add a production-source config entry after the parser entry: + +```js +{ + files: ['src/**/*.ts'], + ignores: ['src/**/*.test.ts', 'src/lib/validation.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: '@hono/zod-validator', + message: 'Import zValidator from src/lib/validation instead.', + }, + ], + }, + ], + }, +}, +``` + +Delete the recursive Vitest scanner. The required lint job now performs the same invariant check while it is already parsing source files. + +**Step 3: Verify forbidden and allowed cases** + +Run the forbidden import and re-export probes and expect non-zero exits: + +```bash +printf '%s\n' "import { zValidator } from '@hono/zod-validator';" | pnpm --filter @breeze/api exec eslint --stdin --stdin-filename src/routes/__validator_guard_probe.ts +printf '%s\n' "export { zValidator } from '@hono/zod-validator';" | pnpm --filter @breeze/api exec eslint --stdin --stdin-filename src/routes/__validator_guard_probe.ts +``` + +Run the wrapper and test exemptions and expect exit 0: + +```bash +printf '%s\n' "import { zValidator } from '@hono/zod-validator';" | pnpm --filter @breeze/api exec eslint --stdin --stdin-filename src/lib/validation.ts +printf '%s\n' "import { zValidator } from '@hono/zod-validator';" | pnpm --filter @breeze/api exec eslint --stdin --stdin-filename src/routes/__validator_guard_probe.test.ts +``` + +Then run: + +```bash +pnpm --filter @breeze/api lint +``` + +**Step 4: Commit** + +```bash +git add apps/api/eslint.config.js apps/api/src/lib/validation.imports.test.ts +git commit -m "test(api): move validator import guard to eslint" +``` + +### Task 2: Decouple metrics from backup verification startup + +**Files:** +- Create: `apps/api/src/routes/backup/constants.ts` +- Modify: `apps/api/src/routes/backup/verificationService.ts` +- Modify: `apps/api/src/routes/metrics.ts` +- Modify: `apps/api/src/routes/metrics.test.ts` + +**Step 1: Capture the current timeout** + +Run: + +```bash +pnpm --filter @breeze/api exec vitest run src/routes/metrics.test.ts --fileParallelism=false --maxWorkers=1 +``` + +Expected before the change: the first hooks exceed their 10-second limit because importing `metrics.ts` loads `verificationService.ts` and its command/remote-session graph. + +**Step 2: Extract the shared constant** + +Create `constants.ts`: + +```ts +export const BACKUP_LOW_READINESS_THRESHOLD = 70; +``` + +Import and re-export that symbol from `verificationService.ts` so existing consumers keep working: + +```ts +import { BACKUP_LOW_READINESS_THRESHOLD } from './constants'; +export { BACKUP_LOW_READINESS_THRESHOLD } from './constants'; +``` + +Change `metrics.ts` to import the threshold directly from `./backup/constants`. + +**Step 3: Give the DB mock a benign default select chain** + +In `metrics.test.ts`, use a stable `selectMock` reference and, after `vi.clearAllMocks()`, restore a default query builder that resolves to `[{ count: 0 }]`. Per-test `mockReturnValueOnce` trend builders must continue to take precedence. This keeps the metrics scrape's backup-gauge refresh from throwing and printing caught stack traces. + +**Step 4: Verify** + +Run: + +```bash +pnpm --filter @breeze/api exec vitest run src/routes/metrics.test.ts --fileParallelism=false --maxWorkers=1 +pnpm --filter @breeze/api exec vitest run src/routes/backup/verificationService.test.ts --fileParallelism=false --maxWorkers=1 +``` + +Expected: both files pass without hook timeouts or `undefined.from` backup-gauge errors. + +**Step 5: Commit** + +```bash +git add apps/api/src/routes/backup/constants.ts apps/api/src/routes/backup/verificationService.ts apps/api/src/routes/metrics.ts apps/api/src/routes/metrics.test.ts +git commit -m "test(api): lighten metrics route initialization" +``` + +### Task 3: Import the OAuth interaction route once + +**Files:** +- Modify: `apps/api/src/routes/oauthInteraction.test.ts` +- Create: `apps/api/src/routes/oauthInteraction.disabled.test.ts` + +**Step 1: Capture the current timeout** + +Run: + +```bash +pnpm --filter @breeze/api exec vitest run src/routes/oauthInteraction.test.ts --fileParallelism=false --maxWorkers=1 +``` + +Expected before the change: initial tests time out while `loadApp()` resets and reimports the full OAuth graph; the abandoned import later consumes one-shot DB mocks. + +**Step 2: Stabilize the enabled-route test module** + +- Mock `../oauth/log` with inert `logOAuthEvent` / error logging functions so the route test does not load Sentry. +- Mock `../config/env` with the real exports plus deterministic OAuth values and a live `BILLING_URL` getter backed by mutable hoisted state. +- Import `oauthInteractionRoutes` once after the mocks. +- Make `loadApp()` synchronously construct the Hono app from that imported route; remove `vi.resetModules()` and its `enabled` parameter. +- Reset the mutable billing URL and all queued DB implementations in `beforeEach`. + +**Step 3: Isolate disabled configuration** + +Move the “does not mount routes when MCP_OAUTH_ENABLED is false” assertion to `oauthInteraction.disabled.test.ts`. In that file, mock `../config/env` with `MCP_OAUTH_ENABLED: false`, mock unrelated heavy leaves, import the route once, and assert the route is absent. This avoids switching import-time configuration inside a shared module cache. + +**Step 4: Verify** + +Run: + +```bash +pnpm --filter @breeze/api exec vitest run src/routes/oauthInteraction.test.ts src/routes/oauthInteraction.disabled.test.ts --fileParallelism=false --maxWorkers=1 +``` + +Expected: all cases pass under their existing timeouts and no later test observes a DB queue consumed by an abandoned import. + +**Step 5: Commit** + +```bash +git add apps/api/src/routes/oauthInteraction.test.ts apps/api/src/routes/oauthInteraction.disabled.test.ts +git commit -m "test(api): stabilize oauth interaction route setup" +``` + +### Task 4: Stabilize MCP bootstrap lifecycle tests + +**Files:** +- Modify: `apps/api/src/routes/mcpServer.bootstrapLifecycle.test.ts` + +**Step 1: Capture the current timeout** + +Run: + +```bash +pnpm --filter @breeze/api exec vitest run src/routes/mcpServer.bootstrapLifecycle.test.ts --fileParallelism=false --maxWorkers=1 +``` + +Expected before the change: the first request times out during a fresh `mcpServer` module import. + +**Step 2: Replace per-test module mocks with mutable state** + +- Add a static `../db/schema` mock containing the table fields imported by `mcpServer.ts`, matching the lightweight pattern in `mcpServer.test.ts`. +- Replace `mockDb`, `mockApiKey`, and `mockPerms` `vi.doMock` factories with hoisted state objects consumed by static `vi.mock` factories. +- Keep `getUserPermissions` from the real permissions module except for its mutable test return value, preserving real guardrail permission mapping. +- Import `mcpServerRoutes` and `__loadMcpBootstrapForTests` once. +- In `beforeEach`, reset scope, permission, role, billing-email, handler, ledger, and audit state. Do not reset the module registry. +- In `callBootstrap`, set state, call `__loadMcpBootstrapForTests()`, and issue the request through the cached route. + +**Step 3: Verify** + +Run: + +```bash +pnpm --filter @breeze/api exec vitest run src/routes/mcpServer.bootstrapLifecycle.test.ts --fileParallelism=false --maxWorkers=1 +``` + +Expected: all 14 cases pass under the existing 5-second limits. + +**Step 4: Commit** + +```bash +git add apps/api/src/routes/mcpServer.bootstrapLifecycle.test.ts +git commit -m "test(api): reuse mcp bootstrap route graph" +``` + +### Task 5: Stabilize MCP effective-tier and resource-RBAC tests + +**Files:** +- Modify: `apps/api/src/routes/mcpServer.effectiveTier.test.ts` +- Modify: `apps/api/src/routes/mcpServer.resourceRbac.test.ts` + +**Step 1: Capture both current failures** + +Run: + +```bash +pnpm --filter @breeze/api exec vitest run src/routes/mcpServer.effectiveTier.test.ts src/routes/mcpServer.resourceRbac.test.ts --fileParallelism=false --maxWorkers=1 +``` + +Expected before the change: first-use imports exceed existing 5- or 15-second limits. + +**Step 2: Use one statically imported route per file** + +For both files: + +- Mock `../db/schema` with only the table-shaped exports consumed by `mcpServer.ts`. +- Mock `../middleware/apiKeyAuth`, `../db`, and `../services/permissions` statically. Route their behavior through mutable per-test state. +- Import `mcpServerRoutes` once after all static mocks. +- Remove `vi.resetModules()`, `vi.doMock`, and `vi.doUnmock`; reset state and spies in `beforeEach`. + +For `mcpServer.effectiveTier.test.ts` specifically: + +- Keep the real `checkGuardrails` implementation and the real site-scope helpers. +- Use a mutable AI-tools delegate for `getToolTier`, `executeTool`, and tool definitions so the two existing describe blocks can select their behavior without reimporting the route. +- Use mutable permission results for unrestricted and site-restricted cases. + +For `mcpServer.resourceRbac.test.ts` specifically: + +- Add an inert static `../services/aiTools` mock because this file tests resource RBAC, not the eager 47-tool registry. +- Keep real `checkPermissionRequirement` behavior through the real guardrails module. +- Use mutable DB rows and permission grants; assert denial still occurs before `db.select`. + +**Step 3: Verify** + +Run: + +```bash +pnpm --filter @breeze/api exec vitest run src/routes/mcpServer.effectiveTier.test.ts src/routes/mcpServer.resourceRbac.test.ts --fileParallelism=false --maxWorkers=1 +``` + +Expected: all cases pass under their existing timeouts while retaining the real tier escalation, resource permission, and site narrowing assertions. + +**Step 4: Commit** + +```bash +git add apps/api/src/routes/mcpServer.effectiveTier.test.ts apps/api/src/routes/mcpServer.resourceRbac.test.ts +git commit -m "test(api): reuse mcp authorization route graphs" +``` + +### Task 6: Regression and full-suite verification + +**Files:** +- Verify all modified files + +**Step 1: Run the focused failure set together** + +```bash +pnpm --filter @breeze/api exec vitest run \ + src/routes/oauthInteraction.test.ts \ + src/routes/oauthInteraction.disabled.test.ts \ + src/routes/metrics.test.ts \ + src/routes/mcpServer.bootstrapLifecycle.test.ts \ + src/routes/mcpServer.effectiveTier.test.ts \ + src/routes/mcpServer.resourceRbac.test.ts \ + --fileParallelism=false --maxWorkers=1 +``` + +Expected: zero failures and no timeout retries. + +**Step 2: Run static checks** + +```bash +pnpm --filter @breeze/api lint +pnpm --filter @breeze/api typecheck +``` + +**Step 3: Run the full API package suite** + +```bash +pnpm test --filter=@breeze/api +``` + +Expected: all API tests pass, including the seven formerly timing-out cases. + +**Step 4: Inspect branch scope** + +```bash +git status --short +git log --oneline --decorate -10 +``` + +Confirm the pre-existing local hook files remain untracked and untouched, report commits remain unchanged, and stabilization work is represented by separate commits. + +--- + +## Second stabilization wave discovered by the normal full suite + +The first wave fixes the original seven timeout failures and passes its 91-test focused set. Running the normal API suite twice exposed a smaller, overlapping set of older test harnesses with the same cold-import/module-reset failure mode. The following tasks extend the plan; the global constraints above continue to apply. + +### Task 7: Stabilize connected-app and OAuth route setup + +**Files:** +- Modify: `apps/api/src/routes/connectedApps.test.ts` +- Create: `apps/api/src/routes/connectedApps.disabled.test.ts` +- Modify: `apps/api/src/routes/oauth.test.ts` +- Create: `apps/api/src/routes/oauth.disabled.test.ts` + +**Step 1: Preserve the full-suite failures as RED evidence** + +Run the exact connected-app denial and OAuth-disabled cases under their existing default timeout. Confirm the cold import times out or remains close to the five-second boundary; use an extended timeout only diagnostically to prove finite completion. + +**Step 2: Cache the connected-app enabled route** + +- Add a minimal static `../db/schema` mock containing the `oauthClients` and `oauthClientPartnerGrants` columns used by the route. +- Import `connectedAppsRoutes` once with OAuth enabled and make `loadApp()` synchronous. +- In `beforeEach`, call `mockReset()` on `select`, `update`, `delete`, and `revokeClientFamilies`, then restore the revocation service's default resolved value. This clears abandoned `mockImplementationOnce` queues. +- Move the disabled-mount assertion to `connectedApps.disabled.test.ts`, with a static disabled `../config/env` mock and lightweight direct dependencies. + +**Step 3: Isolate OAuth import-time configurations** + +- Move the disabled catch-all assertion to `oauth.disabled.test.ts`. Mock `../oauth/provider` before importing the route and assert `getProvider` is never called. +- In `oauth.test.ts`, statically mock the provider, Redis, and rate limiter, set enabled configuration through a stable config mock, and import the enabled route once for the provider-deferred and resource-alias cases. +- Reset only mutable provider/rate-limit state and request fixtures between tests; remove `vi.resetModules()`, `vi.doMock()`, and `vi.doUnmock()` from both stabilized files. + +**Step 4: Verify and commit** + +```bash +pnpm --filter @breeze/api exec vitest run \ + src/routes/connectedApps.test.ts src/routes/connectedApps.disabled.test.ts \ + src/routes/oauth.test.ts src/routes/oauth.disabled.test.ts \ + src/routes/oauth.revocation.test.ts src/routes/oauth.rate-limit.test.ts \ + --fileParallelism=false --maxWorkers=1 +pnpm --filter @breeze/api exec tsc --noEmit +git add apps/api/src/routes/connectedApps.test.ts apps/api/src/routes/connectedApps.disabled.test.ts apps/api/src/routes/oauth.test.ts apps/api/src/routes/oauth.disabled.test.ts +git commit -m "test(api): stabilize oauth route setup" +``` + +### Task 8: Stabilize MCP bearer, streamable, and bootstrap transport setup + +**Files:** +- Modify: `apps/api/src/routes/mcpServer.bearer.test.ts` +- Modify: `apps/api/src/routes/mcpServer.streamable.test.ts` +- Modify: `apps/api/src/routes/mcpServer.test.ts` +- Optionally create narrowly scoped `mcpServer.*.test.ts` files when import-time configurations cannot share one harness + +**Step 1: Preserve the repeated timeout/cascade failures as RED evidence** + +Run the three exact full-suite cases at the existing default timeout. Confirm that extended diagnostic timeouts complete with the expected assertions and that request bodies execute in milliseconds after import. + +**Step 2: Reuse route graphs in bearer and streamable files** + +- Mock `../config/env` with live `MCP_OAUTH_ENABLED` / issuer getters backed by hoisted state where tests vary the OAuth flag. +- Import `mcpServerRoutes` once per file and construct Hono apps synchronously. +- Reset middleware implementations, tool delegates, session stores, and environment-backed state in `beforeEach`; do not reset the module registry. +- Preserve real auth selection, partner/org auth building, session minting/ownership, byte limits, scope gates, and JSON-RPC dispatch. + +**Step 3: Remove the timed cold import from `mcpServer.test.ts`** + +- Correct the stale bootstrap comment: the route no longer reads `IS_HOSTED` or starts bootstrap loading at import. +- Move the bootstrap carve-out block to a stable static-mock harness (a dedicated test file is preferred if the rest of the large test requires distinct import-time configuration). +- Use hoisted mutable API-key/bootstrap/DB/ledger delegates, import the route once, and keep real Zod-to-JSON-Schema conversion plus all handler/list/401 assertions. +- For remaining dynamic-configuration blocks in `mcpServer.test.ts`, either convert them to live mutable delegates or split only the genuinely different import-time configurations into dedicated files. No timed test may perform a first cold import of the full route graph. + +**Step 4: Verify and commit** + +```bash +pnpm --filter @breeze/api exec vitest run \ + src/routes/mcpServer.bearer.test.ts \ + src/routes/mcpServer.streamable.test.ts \ + src/routes/mcpServer.test.ts \ + --fileParallelism=false --maxWorkers=1 +pnpm --filter @breeze/api exec tsc --noEmit +git add apps/api/src/routes/mcpServer.bearer.test.ts apps/api/src/routes/mcpServer.streamable.test.ts apps/api/src/routes/mcpServer.test.ts apps/api/src/routes/mcpServer.*.test.ts +git commit -m "test(api): stabilize mcp transport route setup" +``` + +### Task 9: Remove database startup from encrypted-column unit tests + +**Files:** +- Modify: `apps/api/src/services/encryptedColumnRegistry.test.ts` +- Modify or create focused tests beside `apps/api/src/services/sentinelOne/metrics.ts` only if needed by review + +**Step 1: Capture the current import-heavy behavior** + +Run the exact v1-to-v2 transform test at the existing timeout and record its cold duration. The existing prefix and decrypt assertions are the behavioral RED/GREEN contract; do not add a brittle duration assertion. + +**Step 2: Use static real registry/crypto imports** + +- Add a narrow `../db` mock so this unit file does not initialize dotenv, the schema barrel, Postgres, or Drizzle. The transform cases never call DB; the batch case injects its own executor. +- Statically import the real `transformEncryptedColumnValue`, `reencryptRegisteredSecrets`, `encryptSecret`, and `decryptSecret`. +- Remove `loadRegistry()`, every `vi.resetModules()`, and every dynamic registry/crypto import. +- Continue clearing and restoring the encryption environment around each test. The crypto module reads active key ID/keyring per call; the current cases use one legacy v1 primary key, so no test-only production cache reset is required. +- Preserve real encryption/decryption, JSON recursion, dry-run statistics, and executor call-count assertions. + +**Step 3: Verify and commit** + +```bash +pnpm --filter @breeze/api exec vitest run src/services/encryptedColumnRegistry.test.ts --fileParallelism=false --maxWorkers=1 +pnpm --filter @breeze/api exec tsc --noEmit +git add apps/api/src/services/encryptedColumnRegistry.test.ts +git commit -m "test(api): stabilize encrypted column registry setup" +``` + +### Task 10: Repeat the full verification gate + +**Step 1: Run both focused waves together** + +Run all first-wave files plus the connected-app, OAuth, MCP transport, and encrypted-column files under one worker. Expect zero failures, timeouts, or cascade mock errors. + +**Step 2: Run static checks** + +```bash +pnpm --filter @breeze/api lint +pnpm --filter @breeze/api exec tsc --noEmit +``` + +**Step 3: Run the normal full suite twice** + +```bash +pnpm test --filter=@breeze/api +pnpm test --filter=@breeze/api +``` + +Both runs must pass under the repository's normal parallel configuration. Do not substitute serial execution or a higher global timeout. + +**Step 4: Review scope** + +Confirm the report commits remain unchanged, both stabilization waves are separately committed, and only the four pre-existing local hook files remain untracked. + +--- + +## Final residual stabilization + +The second verification wave reduced the normal suite from 24 failures to three timeout-only failures, and the next identical full run passed. Two OAuth harnesses still reload their route/crypto graphs per test, and the migration-ordering repository contract shares a worker with the DB-heavy auto-migration module. The following tasks remove those last avoidable timing hazards. + +### Task 11: Stabilize OAuth rate-limit and revocation harnesses + +**Files:** +- Modify: `apps/api/src/routes/oauth.rate-limit.test.ts` +- Create: `apps/api/src/routes/oauth.rate-limit.disabled.test.ts` +- Modify: `apps/api/src/routes/oauth.revocation.test.ts` + +**Requirements:** + +- Split the import-time disabled OAuth topology into `oauth.rate-limit.disabled.test.ts` with static `MCP_OAUTH_ENABLED: false`, static provider/Redis/rate mocks, one route import, and both 404/no-rate-limiter assertions. +- In the main rate-limit file, use static enabled configuration and a live mutable `OAUTH_DCR_ENABLED` getter, static provider/Redis/rate delegates, and one route import. Reset delegate implementations, call history, DCR state, `NODE_ENV`, and proxy-related environment in `beforeEach). +- Keep real Hono routing, client-IP resolution, redirect policy, body-size parsing, resource normalization, and rate-limit key construction. +- In revocation tests, use one static enabled route graph and stable provider/cache/rate/Redis delegates. Generate one legitimate Ed25519 keypair in `beforeAll`, expose its private JWKS through live config before the first request, and reuse it. Generate only the foreign key required by the forged-token assertion. +- Keep real JWKS loading, JOSE signing/verification, client binding, body parsing, status handling, and Hono middleware. +- Remove `vi.resetModules()`, `vi.doMock()`, `vi.doUnmock()`, and dynamic OAuth route imports. Do not add timeouts or production cache reset hooks. + +**Verification and commit:** + +```bash +pnpm --filter @breeze/api exec vitest run \ + src/routes/oauth.rate-limit.test.ts \ + src/routes/oauth.rate-limit.disabled.test.ts \ + src/routes/oauth.revocation.test.ts \ + --cache=false +pnpm --filter @breeze/api exec tsc --noEmit +git add apps/api/src/routes/oauth.rate-limit.test.ts apps/api/src/routes/oauth.rate-limit.disabled.test.ts apps/api/src/routes/oauth.revocation.test.ts +git commit -m "test(api): stabilize oauth security harnesses" +``` + +### Task 12: Isolate the migration-ordering repository contract + +**Files:** +- Modify: `apps/api/src/db/autoMigrate.test.ts` +- Create: `apps/api/src/db/migrationOrdering.test.ts` + +**Requirements:** + +- Move the entire `migration ordering` describe unchanged into the adjacent lightweight test file. +- The new file may import only Vitest and Node filesystem/path modules. It must continue scanning the live repository's core SQL migrations, sorting by filename, extracting created tables, enforcing same-file-before-reference ordering, honoring the same system-table exclusions, and asserting the complete violations array is empty. +- Leave the pure runner and extension-migration tests in `autoMigrate.test.ts`. +- Do not move the scan outside the timed test, mock migration contents, weaken it to filename checks, raise the timeout, or change production migration logic. + +**Verification and commit:** + +```bash +pnpm --filter @breeze/api exec vitest run src/db/autoMigrate.test.ts src/db/migrationOrdering.test.ts --reporter=verbose +pnpm --filter @breeze/api exec vitest run -t "every referenced table is created in the same file or an earlier one" --reporter=verbose +pnpm --filter @breeze/api exec tsc --noEmit +git add apps/api/src/db/autoMigrate.test.ts apps/api/src/db/migrationOrdering.test.ts +git commit -m "test(api): isolate migration ordering contract" +``` + +### Task 13: Final all-green gate + +Run the expanded focused set, API lint, direct TypeScript compilation, and then the normal full API suite twice. Both normal runs must pass without a global timeout increase or serial override. Confirm only the pre-existing local hook files remain untracked, then perform the final whole-branch review. + +### Task 14: Bound API test worker parallelism + +**Files:** +- Modify: `apps/api/vitest.config.ts` + +**Context:** Vitest 4 defaults to `availableParallelism() - 1` fork workers. This host reports 14, so a normal run launches 13 workers; changing unrelated timeout victims, a 240/240 green focused gate, and high aggregate import time demonstrate worker starvation. The public 4-vCPU CI runner similarly benefits from reducing its default three workers to two. + +**Requirements:** + +- Import `availableParallelism` from `node:os`. +- Set `maxWorkers` to: + +```ts +Math.max(1, Math.min(4, Math.floor(availableParallelism() / 2))) +``` + +- Keep `fileParallelism: true`, the default fork pool, isolation, reporters, and all test/hook timeouts unchanged. +- Add a focused config test only if the repository has an existing config-test pattern; otherwise TypeScript compilation plus full-suite evidence is the contract. + +**Verification and commit:** + +```bash +pnpm --filter @breeze/api exec tsc --noEmit +pnpm test --filter=@breeze/api +git add apps/api/vitest.config.ts +git commit -m "test(api): bound vitest worker parallelism" +``` + +### Task 15: Final consecutive green verification + +Run the expanded focused set, API lint, direct TypeScript compilation, and two consecutive normal `pnpm test --filter=@breeze/api` commands with no CLI worker override. Both full runs must pass. Record worker count and durations, confirm timeouts remain unchanged, inspect branch scope, and then perform the final whole-branch review. + +### Task 16: Remove the final two timed cold paths + +**Files:** +- Modify: `apps/api/src/db/migrationOrdering.test.ts` +- Modify: `apps/api/src/routes/partner_multi_org_orgid.test.ts` + +**Requirements:** + +- In `migrationOrdering.test.ts`, import `readdir` and `readFile` from `node:fs/promises`, make the assertion async, read the sorted migration list with `Promise.all`, and then process the returned `{ file, sql }` array sequentially in the same sorted order. Preserve every filter, regex, exclusion, same-file ordering rule, and violation assertion. Keep all work inside the timed test. +- In `partner_multi_org_orgid.test.ts`, statically import `orgRoutes` once and use one Hono app instead of four per-test dynamic imports. +- Add the missing minimal `sites` schema mock and a resettable DB select delegate that models the allowed `/sites` query chain. +- Strengthen successful precedence cases to assert HTTP 200 (and the relevant scoped-query condition/body where available) instead of merely asserting “not 403,” which previously allowed a 500 to pass. +- Preserve the 403 denial cases, hoisted auth state, and real organizationId precedence logic. Do not add timeouts or production changes. + +**Verification and commit:** + +```bash +pnpm --filter @breeze/api exec vitest run \ + src/db/migrationOrdering.test.ts \ + src/routes/partner_multi_org_orgid.test.ts \ + --maxWorkers=4 --cache=false --reporter=verbose +pnpm --filter @breeze/api exec tsc --noEmit +git add apps/api/src/db/migrationOrdering.test.ts apps/api/src/routes/partner_multi_org_orgid.test.ts +git commit -m "test(api): stabilize final cold-path harnesses" +``` + +### Task 17: Final handoff gate + +Run the expanded focused set, lint, direct TypeScript compilation, and two consecutive normal API-suite runs with the committed worker cap and no CLI overrides. Both runs must pass. Then complete final whole-branch review and address all Critical/Important findings plus any recorded stale-comment/mock-export cleanup. + +### Task 18: Decouple bootstrap permission parity from the AI tool registry + +**Files:** +- Modify: `apps/api/src/services/aiGuardrails.bootstrapParity.test.ts` + +Add a static `./aiTools` mock exporting `getToolTier` because this test exercises the real bootstrap tool names against the real `TOOL_PERMISSIONS` map, not the independently covered eager 48-tool registry. Keep real `initMcpBootstrap`, real `aiGuardrails.TOOL_PERMISSIONS`, the non-empty assertion, and exact empty-missing assertion. Do not change its timeout or production code. + +Verify the bootstrap parity test under `--no-cache`, then run it with `aiGuardrails.test.ts`, `aiToolsRegistryParity.test.ts`, and the two bootstrap handler suites. Run TypeScript, commit as `test(api): isolate bootstrap permission parity`. + +### Task 19: Definitive all-green verification and review + +Run the focused gate, lint, TypeScript, and two forced-fresh normal API suites. Both must pass with zero timeout messages. Then perform final whole-branch review and cleanup. diff --git a/docs/superpowers/plans/2026-07-14-security-compliance-posture-report-corrections.md b/docs/superpowers/plans/2026-07-14-security-compliance-posture-report-corrections.md new file mode 100644 index 0000000000..7d817635ae --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-security-compliance-posture-report-corrections.md @@ -0,0 +1,1610 @@ +# Security & Compliance Posture Report Corrections Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make posture-report vulnerability counts tenant-safe and accurate, make backup an explicit optional requirement, and render a complete deduplicated security-product inventory. + +**Architecture:** Split the global CVE catalog read from the tenant finding read, then merge and normalize severity in a focused API helper. Build product inventory through a pure evidence aggregator, carry `backupRequired` through API/shared/web layers, and return cover-page product overflow to a continuation-page renderer before per-device detail. + +**Tech Stack:** TypeScript, Hono, Drizzle ORM/PostgreSQL RLS, Zod, React, Vitest, jsPDF, pnpm/Turbo. + +## Global Constraints + +- Preserve the request-path tenant context for every tenant-scoped read; only the global `vulnerabilities` catalog may use `runOutsideDbContext` plus `withSystemDbAccessContext`. +- Do not change the posture score or its weights. +- New posture-template reports explicitly default `backupRequired` to `false`; missing persisted values mean required for backward compatibility. +- Optional backup remains visible as neutral evidence and never creates a recommendation. +- Product coverage is the union of unique in-scope device IDs; duplicate sources must not inflate it. +- Every summary product must appear in the PDF on the cover or a continuation page. +- No database migration is required; report config and summaries are JSON. +- Keep the template-duplication and Technician Activity mapping issues out of this change. +- Follow test-driven development: add one failing behavior test, verify the expected failure, implement the minimum change, and rerun before proceeding. + +--- + +## File structure + +**Create** + +- `apps/api/src/services/securityComplianceReportVulnerabilities.ts` - tenant/system split loader plus severity aggregation. +- `apps/api/src/services/securityComplianceReportVulnerabilities.test.ts` - mixed-case, lifecycle, and incomplete-catalog unit coverage. +- `apps/api/src/services/securityComplianceReportProducts.ts` - pure product normalization, categorization, deduplication, and coverage aggregation. +- `apps/api/src/services/securityComplianceReportProducts.test.ts` - endpoint/managed product evidence coverage. +- `apps/api/src/__tests__/integration/securityComplianceReport.integration.test.ts` - real-Postgres tenant isolation and report-count regression. +- `apps/web/src/components/reports/PostureReportOptionsForm.tsx` - shared backup-requirement control for create and edit paths. +- `apps/web/src/components/reports/ReportEditPage.posture.test.tsx` - posture config preservation regression. + +**Modify** + +- `apps/api/src/services/securityComplianceReport.ts` - consume both helpers and emit `backupRequired`. +- `apps/api/src/services/securityComplianceReport.test.ts` - generator contract, query sequence, backup, and inventory assertions. +- `apps/api/src/routes/reports/schemas.ts` - accept and default `backupRequired` correctly. +- `apps/api/src/routes/reports/schemas.security.test.ts` - config validation/defaults. +- `apps/api/src/routes/reports/schemas.config.test.ts` - create/update preservation. +- `packages/shared/src/types/postureReport.ts` - persisted control field and `antivirus` category. +- `packages/shared/src/reportPdf/reportPdf.ts` - optional-backup presentation and non-truncating product pagination. +- `packages/shared/src/reportPdf/reportPdf.test.ts` - recommendation and product continuation assertions. +- `apps/web/src/components/reports/ReportTemplates.tsx` - posture-options step and explicit create config. +- `apps/web/src/components/reports/ReportTemplates.posture.test.tsx` - default/opt-in create payloads. +- `apps/web/src/components/reports/ReportEditPage.tsx` - posture-specific edit path that merges existing config. +- `apps/web/src/locales/en/reports.json` +- `apps/web/src/locales/es-419/reports.json` +- `apps/web/src/locales/de-DE/reports.json` +- `apps/web/src/locales/fr-FR/reports.json` +- `apps/web/src/locales/pt-BR/reports.json` +- `apps/docs/src/content/docs/features/reports.mdx` - document the backup requirement and compatibility behavior. + +--- + +### Task 1: Tenant-safe vulnerability count loader + +**Files:** + +- Create: `apps/api/src/services/securityComplianceReportVulnerabilities.ts` +- Create: `apps/api/src/services/securityComplianceReportVulnerabilities.test.ts` + +**Interfaces:** + +- Produces: `loadOpenVulnerabilityCounts(deviceIds: string[]): Promise>` +- Produces: `aggregateVulnerabilityCounts(findings, catalogRows): Map` +- `DeviceVulnerabilityCounts` is `{ critical: number; high: number }`. + +- [ ] **Step 1: Write failing pure aggregation tests** + +Create the test with actual source casing and an incomplete catalog case: + +```ts +import { describe, expect, it } from 'vitest'; +import { aggregateVulnerabilityCounts } from './securityComplianceReportVulnerabilities'; + +describe('aggregateVulnerabilityCounts', () => { + it('normalizes source severity casing and counts findings per device', () => { + const counts = aggregateVulnerabilityCounts( + [ + { deviceId: 'd1', vulnerabilityId: 'v1' }, + { deviceId: 'd1', vulnerabilityId: 'v2' }, + { deviceId: 'd2', vulnerabilityId: 'v3' }, + { deviceId: 'd2', vulnerabilityId: 'v4' }, + ], + [ + { id: 'v1', severity: 'HIGH' }, + { id: 'v2', severity: 'Critical' }, + { id: 'v3', severity: 'High' }, + { id: 'v4', severity: 'CRITICAL' }, + ], + ); + + expect(counts.get('d1')).toEqual({ high: 1, critical: 1 }); + expect(counts.get('d2')).toEqual({ high: 1, critical: 1 }); + }); + + it('fails instead of publishing zeroes when referenced catalog rows are missing', () => { + expect(() => + aggregateVulnerabilityCounts( + [{ deviceId: 'd1', vulnerabilityId: 'missing' }], + [], + ), + ).toThrow('Vulnerability catalog lookup incomplete'); + }); +}); +``` + +- [ ] **Step 2: Run the new unit test and verify RED** + +Run: + +```bash +pnpm --filter @breeze/api exec vitest run src/services/securityComplianceReportVulnerabilities.test.ts +``` + +Expected: FAIL because `securityComplianceReportVulnerabilities.ts` does not exist. + +- [ ] **Step 3: Implement the pure aggregation and two-phase loader** + +Create `securityComplianceReportVulnerabilities.ts` with these exact public contracts and query boundaries: + +```ts +import { and, eq, inArray } from 'drizzle-orm'; + +import { db, runOutsideDbContext, withSystemDbAccessContext } from '../db'; +import { deviceVulnerabilities, vulnerabilities } from '../db/schema'; + +export type DeviceVulnerabilityCounts = { critical: number; high: number }; + +type FindingRow = { deviceId: string; vulnerabilityId: string }; +type CatalogRow = { id: string; severity: string | null }; + +export function aggregateVulnerabilityCounts( + findings: FindingRow[], + catalogRows: CatalogRow[], +): Map { + const catalogById = new Map(catalogRows.map((row) => [row.id, row])); + const missingIds = [...new Set( + findings + .map((finding) => finding.vulnerabilityId) + .filter((id) => !catalogById.has(id)), + )]; + if (missingIds.length > 0) { + throw new Error( + `Vulnerability catalog lookup incomplete: ${missingIds.length} referenced record(s) missing`, + ); + } + + const counts = new Map(); + for (const finding of findings) { + const severity = catalogById.get(finding.vulnerabilityId)?.severity?.toLowerCase(); + if (severity !== 'critical' && severity !== 'high') continue; + const current = counts.get(finding.deviceId) ?? { critical: 0, high: 0 }; + current[severity] += 1; + counts.set(finding.deviceId, current); + } + return counts; +} + +export async function loadOpenVulnerabilityCounts( + deviceIds: string[], +): Promise> { + if (deviceIds.length === 0) return new Map(); + + const findings = await db + .select({ + deviceId: deviceVulnerabilities.deviceId, + vulnerabilityId: deviceVulnerabilities.vulnerabilityId, + }) + .from(deviceVulnerabilities) + .where(and( + inArray(deviceVulnerabilities.deviceId, deviceIds), + eq(deviceVulnerabilities.status, 'open'), + )); + + const vulnerabilityIds = [...new Set(findings.map((row) => row.vulnerabilityId))]; + if (vulnerabilityIds.length === 0) return new Map(); + + const catalogRows = await runOutsideDbContext(() => + withSystemDbAccessContext(() => + db + .select({ id: vulnerabilities.id, severity: vulnerabilities.severity }) + .from(vulnerabilities) + .where(inArray(vulnerabilities.id, vulnerabilityIds)), + ), + ); + + return aggregateVulnerabilityCounts(findings, catalogRows); +} +``` + +- [ ] **Step 4: Rerun the focused test and verify GREEN** + +Run the same Vitest command. Expected: 2 tests PASS. + +- [ ] **Step 5: Commit the isolated loader** + +```bash +git add apps/api/src/services/securityComplianceReportVulnerabilities.ts apps/api/src/services/securityComplianceReportVulnerabilities.test.ts +git commit -m "fix(reports): load vulnerability counts across RLS contexts" +``` + +--- + +### Task 2: Integrate vulnerability counts and prove tenant isolation + +**Files:** + +- Modify: `apps/api/src/services/securityComplianceReport.ts:1-527` +- Modify: `apps/api/src/services/securityComplianceReport.test.ts:1-300` +- Create: `apps/api/src/__tests__/integration/securityComplianceReport.integration.test.ts` + +**Interfaces:** + +- Consumes: `loadOpenVulnerabilityCounts(deviceIds)` from Task 1. +- Produces: unchanged report row fields `openVulnCritical` and `openVulnHigh`, now populated from the tenant-safe loader. + +- [ ] **Step 1: Add a failing generator contract test** + +Use a hoisted mock so the report unit test no longer relies on an impossible tenant/system join: + +```ts +const vulnerabilityMocks = vi.hoisted(() => ({ + loadOpenVulnerabilityCounts: vi.fn(), +})); + +vi.mock('./securityComplianceReportVulnerabilities', () => vulnerabilityMocks); +``` + +In `beforeEach`, set: + +```ts +vulnerabilityMocks.loadOpenVulnerabilityCounts.mockResolvedValue( + new Map([ + ['dev-1', { high: 2, critical: 1 }], + ['dev-2', { high: 1, critical: 0 }], + ]), +); +``` + +Add: + +```ts +it('places tenant-safe open vulnerability counts into per-device rows', async () => { + mockGeneratorQueries(); + const result = await generateSecurityCompliancePostureReport(ORG, {}); + const rows = Object.fromEntries((result.rows as any[]).map((row) => [row.hostname, row])); + + expect(vulnerabilityMocks.loadOpenVulnerabilityCounts).toHaveBeenCalledWith([ + 'dev-1', + 'dev-2', + 'dev-3', + ]); + expect(rows['pc-1']).toMatchObject({ openVulnHigh: 2, openVulnCritical: 1 }); + expect(rows['pc-2']).toMatchObject({ openVulnHigh: 1, openVulnCritical: 0 }); +}); +``` + +Remove the old `device_vulns` result from `mockGeneratorQueries` and renumber its documented select sequence so the remaining Drizzle mocks stay aligned. + +- [ ] **Step 2: Verify the generator test fails for the expected reason** + +Run: + +```bash +pnpm --filter @breeze/api exec vitest run src/services/securityComplianceReport.test.ts +``` + +Expected: the new test FAILS because the generator still performs the direct join and never calls the helper. + +- [ ] **Step 3: Replace the direct join with the loader** + +In `securityComplianceReport.ts`: + +```ts +import { loadOpenVulnerabilityCounts } from './securityComplianceReportVulnerabilities'; +``` + +Delete the direct `deviceVulnerabilities`/`vulnerabilities` joined select and its counting loop. After `deviceIds` is known, use: + +```ts +const vulnByDevice = await loadOpenVulnerabilityCounts(deviceIds); +``` + +Remove the now-unused schema imports. Keep the row mapping unchanged: + +```ts +const vuln = vulnByDevice.get(d.id) ?? { critical: 0, high: 0 }; +``` + +- [ ] **Step 4: Rerun the generator unit suite** + +Expected: all `securityComplianceReport.test.ts` tests PASS with the corrected select sequence. + +- [ ] **Step 5: Write the real-Postgres cross-tenant regression test** + +Create `apps/api/src/__tests__/integration/securityComplianceReport.integration.test.ts`. Use `setupTestEnvironment`, insert one device and one open finding in each of two organizations, and store catalog severities with different casing. Run the report inside organization A's context: + +```ts +import './setup'; + +import { describe, expect, it } from 'vitest'; +import { db, withDbAccessContext, withSystemDbAccessContext } from '../../db'; +import { devices, deviceVulnerabilities, vulnerabilities } from '../../db/schema'; +import { generateSecurityCompliancePostureReport } from '../../services/securityComplianceReport'; +import { setupTestEnvironment } from './db-utils'; + +const runDb = it.runIf(!!process.env.DATABASE_URL); + +describe('security compliance report vulnerability isolation', () => { + runDb('counts its own open catalog findings and excludes another organization', async () => { + const envA = await setupTestEnvironment({ scope: 'organization' }); + const envB = await setupTestEnvironment({ scope: 'organization' }); + + const seeded = await withSystemDbAccessContext(async () => { + const [deviceA, deviceB] = await db.insert(devices).values([ + { + orgId: envA.organization.id, + siteId: envA.site.id, + agentId: `posture-a-${Date.now()}`, + hostname: 'posture-a', + osType: 'windows', + osVersion: '11', + architecture: 'x86_64', + agentVersion: 'test', + status: 'offline', + }, + { + orgId: envB.organization.id, + siteId: envB.site.id, + agentId: `posture-b-${Date.now()}`, + hostname: 'posture-b', + osType: 'windows', + osVersion: '11', + architecture: 'x86_64', + agentVersion: 'test', + status: 'offline', + }, + ]).returning({ id: devices.id, orgId: devices.orgId }); + + const [catalogA, catalogB, patchedCatalog, mitigatedCatalog, acceptedCatalog] = await db.insert(vulnerabilities).values([ + { cveId: 'CVE-2026-71001', source: 'nvd', severity: 'HIGH', rawPayload: {} }, + { cveId: 'CVE-2026-71002', source: 'msrc', severity: 'Critical', rawPayload: {} }, + { cveId: 'CVE-2026-71003', source: 'nvd', severity: 'CRITICAL', rawPayload: {} }, + { cveId: 'CVE-2026-71004', source: 'nvd', severity: 'CRITICAL', rawPayload: {} }, + { cveId: 'CVE-2026-71005', source: 'nvd', severity: 'CRITICAL', rawPayload: {} }, + ]).returning({ id: vulnerabilities.id }); + + await db.insert(deviceVulnerabilities).values([ + { + orgId: envA.organization.id, + deviceId: deviceA!.id, + vulnerabilityId: catalogA!.id, + status: 'open', + detectedAt: new Date(), + }, + { + orgId: envB.organization.id, + deviceId: deviceB!.id, + vulnerabilityId: catalogB!.id, + status: 'open', + detectedAt: new Date(), + }, + { + orgId: envA.organization.id, + deviceId: deviceA!.id, + vulnerabilityId: patchedCatalog!.id, + status: 'patched', + detectedAt: new Date(), + }, + { + orgId: envA.organization.id, + deviceId: deviceA!.id, + vulnerabilityId: mitigatedCatalog!.id, + status: 'mitigated', + detectedAt: new Date(), + }, + { + orgId: envA.organization.id, + deviceId: deviceA!.id, + vulnerabilityId: acceptedCatalog!.id, + status: 'accepted', + detectedAt: new Date(), + }, + ]); + + return { deviceA: deviceA!.id }; + }); + + const result = await withDbAccessContext( + { + scope: 'organization', + orgId: envA.organization.id, + accessibleOrgIds: [envA.organization.id], + userId: envA.user.id, + }, + () => generateSecurityCompliancePostureReport(envA.organization.id, { includeCis: false }), + ); + + expect(result.rows).toEqual(expect.arrayContaining([ + expect.objectContaining({ + hostname: 'posture-a', + openVulnHigh: 1, + openVulnCritical: 0, + }), + ])); + expect(result.rows).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ hostname: 'posture-b' }), + ])); + expect(seeded.deviceA).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 6: Run the integration test** + +Run: + +```bash +pnpm --filter @breeze/api exec vitest run --config vitest.integration.config.ts src/__tests__/integration/securityComplianceReport.integration.test.ts +``` + +Expected: PASS when `DATABASE_URL` is available; otherwise the test is explicitly skipped. + +- [ ] **Step 7: Commit the generator integration** + +```bash +git add apps/api/src/services/securityComplianceReport.ts apps/api/src/services/securityComplianceReport.test.ts apps/api/src/__tests__/integration/securityComplianceReport.integration.test.ts +git commit -m "fix(reports): count posture vulnerabilities accurately" +``` + +--- + +### Task 3: Canonical security-product aggregation + +**Files:** + +- Create: `apps/api/src/services/securityComplianceReportProducts.ts` +- Create: `apps/api/src/services/securityComplianceReportProducts.test.ts` +- Modify: `apps/api/src/services/securityComplianceReport.ts:49-524` +- Modify: `apps/api/src/services/securityComplianceReport.test.ts:200-290` +- Modify: `packages/shared/src/types/postureReport.ts:16-24` + +**Interfaces:** + +- Produces: `SecurityProductEvidence` with optional `deviceIds`. +- Produces: `buildSecurityProductInventory(evidence): PostureProduct[]`. +- Produces: `prettySecurityProvider(provider)` and `categoryForEndpointProvider(provider)`. + +- [ ] **Step 1: Write failing product aggregation tests** + +```ts +import { describe, expect, it } from 'vitest'; +import { buildSecurityProductInventory } from './securityComplianceReportProducts'; + +describe('buildSecurityProductInventory', () => { + it('includes native Defender and endpoint-only SentinelOne', () => { + const result = buildSecurityProductInventory([ + { product: 'Defender', category: 'antivirus', active: true, deviceIds: ['d1', 'd2'] }, + { product: 'SentinelOne', category: 'edr', active: true, deviceIds: ['d3'] }, + ]); + expect(result).toEqual(expect.arrayContaining([ + expect.objectContaining({ product: 'Defender', category: 'antivirus', deviceCoverage: 2 }), + expect.objectContaining({ product: 'SentinelOne', category: 'edr', deviceCoverage: 1 }), + ])); + }); + + it('deduplicates managed and endpoint evidence by unique device id', () => { + const [sentinelOne] = buildSecurityProductInventory([ + { product: 'SentinelOne', category: 'edr', active: true, deviceIds: ['d1', 'd2'] }, + { product: 'sentinel one', category: 'edr', active: false, deviceIds: ['d2', 'd3'] }, + ]); + expect(sentinelOne).toMatchObject({ + product: 'SentinelOne', + active: true, + deviceCoverage: 3, + }); + }); + + it('keeps RTP-off endpoint evidence visible as inactive', () => { + expect(buildSecurityProductInventory([ + { product: 'Defender', category: 'antivirus', active: false, deviceIds: ['d1'] }, + ])).toEqual([ + expect.objectContaining({ product: 'Defender', active: false, deviceCoverage: 1 }), + ]); + }); +}); +``` + +- [ ] **Step 2: Run the product test and verify RED** + +```bash +pnpm --filter @breeze/api exec vitest run src/services/securityComplianceReportProducts.test.ts +``` + +Expected: FAIL because the product module and `antivirus` category do not exist. + +- [ ] **Step 3: Add the shared category and implement the pure aggregator** + +Change the shared union: + +```ts +export type PostureProductCategory = + | 'antivirus' + | 'edr' + | 'mdr' + | 'dns_filtering' + | 'backup' + | 'identity'; +``` + +Create the API module with deterministic ordering and union coverage: + +```ts +import type { PostureProduct, PostureProductCategory } from '@breeze/shared'; + +export type SecurityProductEvidence = Omit & { + deviceIds?: Iterable; +}; + +const PROVIDER_NAMES: Record = { + windows_defender: 'Defender', + sentinelone: 'SentinelOne', + crowdstrike: 'CrowdStrike', + bitdefender: 'Bitdefender', + sophos: 'Sophos', + malwarebytes: 'Malwarebytes', + eset: 'ESET', + kaspersky: 'Kaspersky', + elastic_defend: 'Elastic Defend', +}; + +const EDR_PROVIDERS = new Set(['sentinelone', 'crowdstrike', 'elastic_defend']); +const CATEGORY_ORDER: PostureProductCategory[] = [ + 'mdr', + 'edr', + 'antivirus', + 'dns_filtering', + 'backup', + 'identity', +]; + +export function prettySecurityProvider(provider: string): string { + return PROVIDER_NAMES[provider] ?? provider; +} + +export function categoryForEndpointProvider(provider: string): 'edr' | 'antivirus' { + return EDR_PROVIDERS.has(provider) ? 'edr' : 'antivirus'; +} + +const productKey = (name: string): string => name.toLowerCase().replace(/[^a-z0-9]/g, ''); + +export function buildSecurityProductInventory( + evidence: SecurityProductEvidence[], +): PostureProduct[] { + const merged = new Map | null; + }>(); + + for (const item of evidence) { + const key = productKey(item.product); + const current = merged.get(key); + const ids = item.deviceIds ? new Set(item.deviceIds) : null; + if (!current) { + merged.set(key, { + product: item.product, + category: item.category, + active: item.active, + lastSyncStatus: item.lastSyncStatus ?? null, + deviceIds: ids, + }); + continue; + } + current.active ||= item.active; + current.lastSyncStatus ??= item.lastSyncStatus ?? null; + if (ids) { + current.deviceIds ??= new Set(); + for (const id of ids) current.deviceIds.add(id); + } + } + + return [...merged.values()] + .map((item) => ({ + product: item.product, + category: item.category, + active: item.active, + lastSyncStatus: item.lastSyncStatus, + deviceCoverage: item.deviceIds?.size ?? null, + })) + .sort((a, b) => + CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category) + || a.product.localeCompare(b.product), + ); +} +``` + +- [ ] **Step 4: Rerun the product tests** + +Expected: all 3 tests PASS. + +- [ ] **Step 5: Add a failing generator inventory test** + +Extend the existing generator fixture so Defender is observed on four scoped devices, SentinelOne is both managed and observed on overlapping devices, and Huntress is managed. Assert exact unique coverage and one row per normalized product: + +```ts +it('lists native and managed products once with unique scoped coverage', async () => { + mockGeneratorQueries({ + 3: [ + { deviceId: 'dev-1', provider: 'windows_defender', realTimeProtection: true, definitionsDate: new Date(), encryptionStatus: 'encrypted', firewallEnabled: true, passwordPolicySummary: null, localAdminSummary: null }, + { deviceId: 'dev-2', provider: 'sentinelone', realTimeProtection: true, definitionsDate: new Date(), encryptionStatus: 'encrypted', firewallEnabled: true, passwordPolicySummary: null, localAdminSummary: null }, + { deviceId: 'dev-3', provider: 'sentinelone', realTimeProtection: false, definitionsDate: new Date(), encryptionStatus: 'encrypted', firewallEnabled: true, passwordPolicySummary: null, localAdminSummary: null }, + ], + 4: [{ deviceId: 'dev-2' }, { deviceId: 'dev-3' }], + }); + + const summary = (await generateSecurityCompliancePostureReport(ORG, {})).summary as any; + expect(summary.securityProducts.filter((p: any) => p.product === 'SentinelOne')).toEqual([ + expect.objectContaining({ category: 'edr', deviceCoverage: 2, active: true }), + ]); + expect(summary.securityProducts).toContainEqual( + expect.objectContaining({ product: 'Defender', category: 'antivirus', deviceCoverage: 1 }), + ); +}); +``` + +- [ ] **Step 6: Build evidence in the generator and use the aggregator** + +Move provider display/category logic to the product module. Build `SecurityProductEvidence[]` in this order: Huntress managed evidence, SentinelOne managed evidence, every scoped non-`other` security-status row, then org integrations. Managed evidence is inserted first so its canonical name/category wins when later endpoint evidence merges into the same key. Then call: + +```ts +const securityProducts = buildSecurityProductInventory(productEvidence); +``` + +For native rows, use: + +```ts +productEvidence.push({ + product: prettySecurityProvider(row.provider), + category: categoryForEndpointProvider(row.provider), + active: row.realTimeProtection === true, + lastSyncStatus: null, + deviceIds: [row.deviceId], +}); +``` + +Keep provider `other` out. Use the same `prettySecurityProvider` function in `protectionLabel` so row labels and inventory names cannot diverge. + +- [ ] **Step 7: Run API and shared typechecks/tests** + +```bash +pnpm --filter @breeze/api exec vitest run src/services/securityComplianceReportProducts.test.ts src/services/securityComplianceReport.test.ts +pnpm --filter @breeze/shared typecheck +``` + +Expected: PASS. + +- [ ] **Step 8: Commit product aggregation** + +```bash +git add apps/api/src/services/securityComplianceReportProducts.ts apps/api/src/services/securityComplianceReportProducts.test.ts apps/api/src/services/securityComplianceReport.ts apps/api/src/services/securityComplianceReport.test.ts packages/shared/src/types/postureReport.ts +git commit -m "fix(reports): include all detected security products" +``` + +--- + +### Task 4: Backup requirement contract and neutral PDF behavior + +**Files:** + +- Modify: `apps/api/src/routes/reports/schemas.ts:15-132` +- Modify: `apps/api/src/routes/reports/schemas.security.test.ts` +- Modify: `apps/api/src/routes/reports/schemas.config.test.ts` +- Modify: `apps/api/src/services/securityComplianceReport.ts:101-527` +- Modify: `apps/api/src/services/securityComplianceReport.test.ts` +- Modify: `packages/shared/src/types/postureReport.ts:26-48` +- Modify: `packages/shared/src/reportPdf/reportPdf.ts:576-704` +- Modify: `packages/shared/src/reportPdf/reportPdf.test.ts` + +**Interfaces:** + +- Produces config: `backupRequired: boolean`, generation default `true`. +- Produces persisted summary: `controls.backupRequired?: boolean`. +- PDF compatibility rule: `controls.backupRequired !== false` means required. + +- [ ] **Step 1: Add failing schema tests** + +```ts +it('defaults omitted backupRequired to required for legacy reports', () => { + expect(securityCompliancePostureConfigSchema.parse({}).backupRequired).toBe(true); +}); + +it.each([true, false])('accepts backupRequired=%s', (backupRequired) => { + expect(securityCompliancePostureConfigSchema.parse({ backupRequired }).backupRequired) + .toBe(backupRequired); +}); + +it('rejects non-boolean backupRequired', () => { + expect(securityCompliancePostureConfigSchema.safeParse({ backupRequired: 'false' }).success) + .toBe(false); +}); +``` + +Add this create/update schema case in `schemas.config.test.ts`: + +```ts +it('preserves posture backupRequired on create and update', () => { + const created = createReportSchema.parse({ + name: 'Workstation posture', + type: 'security_compliance_posture', + schedule: 'one_time', + format: 'pdf', + config: { backupRequired: false }, + }); + expect(created.config.backupRequired).toBe(false); + + const updated = updateReportSchema.parse({ + config: { backupRequired: true }, + }); + expect(updated.config?.backupRequired).toBe(true); +}); +``` + +- [ ] **Step 2: Run schema tests and verify RED** + +```bash +pnpm --filter @breeze/api exec vitest run src/routes/reports/schemas.security.test.ts src/routes/reports/schemas.config.test.ts +``` + +Expected: FAIL because `backupRequired` is stripped or missing. + +- [ ] **Step 3: Add the config field to both schema paths** + +In `securityCompliancePostureConfigSchema`: + +```ts +backupRequired: z.boolean().optional().default(true), +``` + +In `securityCompliancePostureConfigFields`: + +```ts +backupRequired: z.boolean().optional(), +``` + +Do not default the create/update field map: new template creation posts an explicit value, while stored legacy config may remain absent. + +- [ ] **Step 4: Add failing summary and PDF tests** + +Add `backupRequired?: boolean` to test fixture inputs and a helper to inspect jsPDF command text: + +```ts +function pdfCommandText(doc: ReturnType): string { + return ((doc.internal as unknown as { pages: string[][] }).pages ?? []) + .flat() + .join('\n'); +} + +it('renders optional missing backup neutrally and omits the backup recommendation', () => { + const summary: PostureSummary = { + ...postureSummary, + controls: { + ...postureSummary.controls, + backupRequired: false, + backupConfigured: false, + }, + }; + const text = pdfCommandText(buildReportPdf(postureRows, { + ...opts, + reportType: 'security_compliance_posture', + summary, + })); + expect(text).toContain('Not required'); + expect(text).not.toContain('Configure backups'); +}); + +it('keeps missing backup required for legacy summaries', () => { + const summary: PostureSummary = { + ...postureSummary, + controls: { ...postureSummary.controls, backupConfigured: false }, + }; + expect(pdfCommandText(buildReportPdf(postureRows, { + ...opts, + reportType: 'security_compliance_posture', + summary, + }))).toContain('Configure backups'); +}); +``` + +Add a generator assertion: + +```ts +it('carries the backup requirement without changing posture score', async () => { + mockGeneratorQueries(); + const summary = (await generateSecurityCompliancePostureReport(ORG, { + backupRequired: false, + })).summary as any; + expect(summary.controls.backupRequired).toBe(false); + expect(summary.postureScore).toBe(82); +}); +``` + +- [ ] **Step 5: Verify RED for summary/PDF behavior** + +```bash +pnpm --filter @breeze/api exec vitest run src/services/securityComplianceReport.test.ts +pnpm --filter @breeze/shared exec vitest run src/reportPdf/reportPdf.test.ts +``` + +Expected: FAIL because the summary omits the policy and the PDF still renders backup as failed. + +- [ ] **Step 6: Carry the field through empty and populated summaries** + +Extend `emptySummary` to accept `backupRequired = true`, pass `cfg.backupRequired` at both empty-return sites, and emit: + +```ts +backupRequired: cfg.backupRequired, +backupConfigured: Boolean(backup || c2c), +``` + +Add to `PostureControls`: + +```ts +backupRequired?: boolean; +``` + +- [ ] **Step 7: Render optional backup neutrally** + +Build the backup metric as: + +```ts +const backupRequired = c.backupRequired !== false; +const backupValue = backupRequired + ? `${yesNo(c.backupConfigured)}${c.backupConfigured && c.backupEncrypted ? ' (encrypted)' : ''}` + : c.backupConfigured + ? 'Optional; configured' + : 'Not required'; + +const backupMetric: Metric = { + label: 'Backup', + value: backupValue, + status: backupRequired ? boolStatus(c.backupConfigured) : 'neutral', +}; +``` + +Change the recommendation condition to: + +```ts +if (c.backupRequired !== false && c.backupConfigured === false) { + recs.push({ + severity: 'bad', + text: 'Configure backups - no backup solution is currently detected for this organization.', + }); +} +``` + +Use the repository's ASCII-hyphen PDF convention in new text. + +- [ ] **Step 8: Rerun all focused schema, API, and PDF tests** + +Expected: PASS. + +- [ ] **Step 9: Commit the backup contract** + +```bash +git add apps/api/src/routes/reports/schemas.ts apps/api/src/routes/reports/schemas.security.test.ts apps/api/src/routes/reports/schemas.config.test.ts apps/api/src/services/securityComplianceReport.ts apps/api/src/services/securityComplianceReport.test.ts packages/shared/src/types/postureReport.ts packages/shared/src/reportPdf/reportPdf.ts packages/shared/src/reportPdf/reportPdf.test.ts +git commit -m "feat(reports): make backup a posture report requirement" +``` + +--- + +### Task 5: Posture create/edit options and locale coverage + +**Files:** + +- Create: `apps/web/src/components/reports/PostureReportOptionsForm.tsx` +- Modify: `apps/web/src/components/reports/ReportTemplates.tsx:33-599` +- Modify: `apps/web/src/components/reports/ReportTemplates.posture.test.tsx` +- Modify: `apps/web/src/components/reports/ReportEditPage.tsx:18-134` +- Create: `apps/web/src/components/reports/ReportEditPage.posture.test.tsx` +- Modify: `apps/web/src/locales/en/reports.json` +- Modify: `apps/web/src/locales/es-419/reports.json` +- Modify: `apps/web/src/locales/de-DE/reports.json` +- Modify: `apps/web/src/locales/fr-FR/reports.json` +- Modify: `apps/web/src/locales/pt-BR/reports.json` + +**Interfaces:** + +- Produces: `PostureReportOptionsForm` props `{ backupRequired, busy, submitLabel, onBackupRequiredChange, onSubmit, onCancel }`. +- Create payload: `config.backupRequired` is always explicit. +- Edit payload: merges `{ ...existingConfig, backupRequired }` and sends only `config` in PUT. + +- [ ] **Step 1: Write failing template creation tests** + +Extend `ReportTemplates.posture.test.tsx`: + +```tsx +it('opens posture options and creates backup-optional by default', async () => { + mockTemplatesFetch(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ data: { id: 'rep-9' } }) })); + render(); + await clickUseTemplate('Security & Compliance Posture (Insurance)'); + await userEvent.setup().click(screen.getByTestId('posture-options-submit')); + + expect(postCallBody()).toMatchObject({ + type: 'security_compliance_posture', + config: { backupRequired: false }, + }); +}); + +it('posts backupRequired true when the user opts in', async () => { + mockTemplatesFetch(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ data: { id: 'rep-9' } }) })); + render(); + await clickUseTemplate('Security & Compliance Posture (Insurance)'); + const user = userEvent.setup(); + await user.click(screen.getByTestId('posture-backup-required')); + await user.click(screen.getByTestId('posture-options-submit')); + + expect(postCallBody()).toMatchObject({ + type: 'security_compliance_posture', + config: { backupRequired: true }, + }); +}); +``` + +- [ ] **Step 2: Run the template test and verify RED** + +```bash +pnpm --filter @breeze/web exec vitest run src/components/reports/ReportTemplates.posture.test.tsx +``` + +Expected: FAIL because direct creation happens immediately and no options controls exist. + +- [ ] **Step 3: Create the reusable options component** + +```tsx +import { useTranslation } from 'react-i18next'; + +type Props = { + backupRequired: boolean; + busy?: boolean; + submitLabel: string; + onBackupRequiredChange: (value: boolean) => void; + onSubmit: () => void; + onCancel: () => void; +}; + +export function PostureReportOptionsForm({ + backupRequired, + busy = false, + submitLabel, + onBackupRequiredChange, + onSubmit, + onCancel, +}: Props) { + const { t } = useTranslation('reports'); + return ( +
+ +
+ + +
+
+ ); +} +``` + +- [ ] **Step 4: Add the create options state and preserve the true report type** + +In `ReportTemplates`, add the state and posture branch: + +```ts +const [postureTemplate, setPostureTemplate] = useState(null); +const [backupRequired, setBackupRequired] = useState(false); + +const handleUseTemplate = useCallback((template: ReportTemplate) => { + const type = template.defaults.type; + if (type === 'security_compliance_posture') { + setBackupRequired(false); + setPostureTemplate(template); + return; + } + if (type && !reportTypeSurvivesBuilder(type)) { + void handleCreateDirect(template); + return; + } + handleOpenBuilder(template); +}, [handleCreateDirect, handleOpenBuilder]); +``` + +Change the direct-create signature to: + +```ts +const handleCreateDirect = useCallback(async ( + template: ReportTemplate, + postureConfig: Record = {}, +) => { + setCreatingId(template.id); + try { + await runAction({ + request: () => fetchWithAuth('/reports', { + method: 'POST', + body: JSON.stringify({ + name: template.defaults.name ?? template.name, + type: template.defaults.type, + schedule: template.defaults.schedule ?? 'one_time', + format: template.defaults.format ?? 'pdf', + ...(currentOrgId ? { orgId: currentOrgId } : {}), + config: { + dateRange: template.defaults.dateRange ?? { preset: 'last_30_days' }, + ...postureConfig, + }, + }), + }), + errorFallback: t('reports.reportTemplates.errors.createReport'), + successMessage: t('reports.reportTemplates.success.created', { + name: template.defaults.name ?? template.name, + }), + onUnauthorized: () => { + void navigateTo('/login', { replace: true }); + }, + }); + void navigateTo('/reports'); + } catch { + // runAction has already surfaced the error or redirected a 401. + } finally { + setCreatingId(null); + } +}, [currentOrgId, t]); +``` + +Build config as: + +```ts +config: { + dateRange: template.defaults.dateRange ?? { preset: 'last_30_days' }, + ...postureConfig, +}, +``` + +Submit posture creation with: + +```tsx +{postureTemplate && ( +
+
+

+ {t('reports.reportTemplates.useTemplateTitle', { + name: getTemplateDisplayName(postureTemplate), + })} +

+
+ setPostureTemplate(null)} + onSubmit={() => { + void handleCreateDirect(postureTemplate, { backupRequired }); + }} + /> +
+
+
+)} +``` + +Update the three existing posture tests so each calls `screen.getByTestId('posture-options-submit')` after `clickUseTemplate(...)` before waiting for the POST, toast, or navigation assertion. Replace the old assertion that the posture modal is absent with an assertion that the generic builder's custom fields are absent; the posture options modal is now expected. + +- [ ] **Step 5: Rerun the template tests** + +Expected: both new tests and the existing report-type preservation test PASS. + +- [ ] **Step 6: Write the failing posture edit test** + +Create `ReportEditPage.posture.test.tsx`: + +```tsx +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const fetchWithAuth = vi.fn(); +vi.mock('../../stores/auth', () => ({ + fetchWithAuth: (...args: unknown[]) => fetchWithAuth(...args), +})); + +const navigateTo = vi.fn(); +vi.mock('@/lib/navigation', () => ({ + navigateTo: (...args: unknown[]) => navigateTo(...args), +})); + +const genericBuilder = vi.fn(); +vi.mock('./ReportBuilder', () => ({ + default: () => { + genericBuilder(); + return null; + }, +})); + +vi.mock('@/lib/runAction', () => ({ + runAction: async ({ request }: { request: () => Promise }) => request(), +})); + +import ReportEditPage from './ReportEditPage'; + +const report = { + id: 'report-1', + name: 'Workstation posture', + type: 'security_compliance_posture', + schedule: 'one_time', + format: 'pdf', + config: { backupRequired: false, includeCis: false, maxLocalAdmins: 4 }, + lastGeneratedAt: null, + createdAt: '2026-07-14T00:00:00.000Z', + updatedAt: '2026-07-14T00:00:00.000Z', +}; + +describe('ReportEditPage posture options', () => { + beforeEach(() => { + vi.clearAllMocks(); + fetchWithAuth.mockImplementation((url: string, init?: RequestInit) => { + if (url === '/reports/report-1' && !init?.method) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(report) }); + } + if (url === '/reports/report-1' && init?.method === 'PUT') { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ data: report }) }); + } + return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) }); + }); + }); + + it('preserves posture config and never renders the generic builder', async () => { + render(); + const user = userEvent.setup(); + const checkbox = await screen.findByTestId('posture-backup-required'); + expect(checkbox).not.toBeChecked(); + expect(genericBuilder).not.toHaveBeenCalled(); + + await user.click(checkbox); + await user.click(screen.getByTestId('posture-options-submit')); + + await waitFor(() => { + const putCall = fetchWithAuth.mock.calls.find( + ([url, init]) => url === '/reports/report-1' && (init as RequestInit | undefined)?.method === 'PUT', + ); + expect(putCall).toBeDefined(); + expect(JSON.parse(String((putCall![1] as RequestInit).body))).toEqual({ + config: { + backupRequired: true, + includeCis: false, + maxLocalAdmins: 4, + }, + }); + }); + }); +}); +``` + +- [ ] **Step 7: Run the edit test and verify RED** + +```bash +pnpm --filter @breeze/web exec vitest run src/components/reports/ReportEditPage.posture.test.tsx +``` + +Expected: FAIL because posture edit still routes through `ReportBuilder` and reconstructs generic config. + +- [ ] **Step 8: Add the posture-specific edit branch** + +When `report.type === 'security_compliance_posture'`, render `PostureReportOptionsForm` with the persisted value interpreted as: + +```ts +const initialBackupRequired = config.backupRequired !== false; +``` + +On save, use `runAction` with: + +```ts +request: () => fetchWithAuth(`/reports/${reportId}`, { + method: 'PUT', + body: JSON.stringify({ + config: { ...config, backupRequired }, + }), +}), +``` + +Keep the generic `ReportBuilder` branch unchanged for every other type. + +- [ ] **Step 9: Add translation keys in all five locale files** + +Add the same four keys under `reports.postureOptions` with these exact translations: + +```json +// en/reports.json +"postureOptions": { + "requireBackupCoverage": "Require backup coverage", + "requireBackupCoverageHelp": "When optional, backup remains visible as neutral evidence and does not create a recommendation.", + "cancel": "Cancel", + "createReport": "Create report" +} + +// es-419/reports.json +"postureOptions": { + "requireBackupCoverage": "Exigir cobertura de respaldo", + "requireBackupCoverageHelp": "Cuando es opcional, el respaldo permanece visible como evidencia neutral y no genera una recomendación.", + "cancel": "Cancelar", + "createReport": "Crear informe" +} + +// de-DE/reports.json +"postureOptions": { + "requireBackupCoverage": "Backup-Abdeckung voraussetzen", + "requireBackupCoverageHelp": "Wenn Backups optional sind, bleiben sie als neutrale Nachweise sichtbar und erzeugen keine Empfehlung.", + "cancel": "Abbrechen", + "createReport": "Bericht erstellen" +} + +// fr-FR/reports.json +"postureOptions": { + "requireBackupCoverage": "Exiger une couverture de sauvegarde", + "requireBackupCoverageHelp": "Lorsqu'elle est facultative, la sauvegarde reste visible comme preuve neutre et ne génère aucune recommandation.", + "cancel": "Annuler", + "createReport": "Créer le rapport" +} + +// pt-BR/reports.json +"postureOptions": { + "requireBackupCoverage": "Exigir cobertura de backup", + "requireBackupCoverageHelp": "Quando opcional, o backup permanece visível como evidência neutra e não gera uma recomendação.", + "cancel": "Cancelar", + "createReport": "Criar relatório" +} +``` + +- [ ] **Step 10: Run web and translation coverage tests** + +```bash +pnpm --filter @breeze/web exec vitest run src/components/reports/ReportTemplates.posture.test.tsx src/components/reports/ReportEditPage.posture.test.tsx src/lib/i18n/translationCoverage.test.ts +``` + +Expected: PASS. + +- [ ] **Step 11: Commit the web options flow** + +```bash +git add apps/web/src/components/reports/PostureReportOptionsForm.tsx apps/web/src/components/reports/ReportTemplates.tsx apps/web/src/components/reports/ReportTemplates.posture.test.tsx apps/web/src/components/reports/ReportEditPage.tsx apps/web/src/components/reports/ReportEditPage.posture.test.tsx apps/web/src/locales/en/reports.json apps/web/src/locales/es-419/reports.json apps/web/src/locales/de-DE/reports.json apps/web/src/locales/fr-FR/reports.json apps/web/src/locales/pt-BR/reports.json +git commit -m "feat(reports): configure posture backup requirement" +``` + +--- + +### Task 6: Non-truncating product inventory PDF + +**Files:** + +- Modify: `packages/shared/src/reportPdf/reportPdf.ts:502-650,1194-1235` +- Modify: `packages/shared/src/reportPdf/reportPdf.test.ts` + +**Interfaces:** + +- Changes: `renderPostureCover(...): PostureProduct[]` returns products not rendered on the cover. +- Produces: `renderPostureProductContinuation(doc, products, opts): void`. +- Consumes: `antivirus` category from Task 3. + +- [ ] **Step 1: Write crowded and continuation PDF tests** + +```ts +it('renders Huntress, SentinelOne, and Defender from the reference-like inventory', () => { + const summary: PostureSummary = { + ...postureSummary, + securityProducts: [ + { product: 'Huntress', category: 'mdr', active: true, deviceCoverage: 6 }, + { product: 'SentinelOne', category: 'edr', active: true, deviceCoverage: 4 }, + { product: 'Defender', category: 'antivirus', active: true, deviceCoverage: 4 }, + ], + }; + const text = pdfCommandText(buildReportPdf(postureRows, { + ...opts, + reportType: 'security_compliance_posture', + summary, + })); + expect(text).toContain('Huntress'); + expect(text).toContain('SentinelOne'); + expect(text).toContain('Defender'); +}); + +it('continues a large product inventory without dropping names', () => { + const products = Array.from({ length: 24 }, (_, index) => ({ + product: `Security Product ${index + 1}`, + category: 'antivirus' as const, + active: true, + deviceCoverage: index + 1, + })); + const doc = buildReportPdf([], { + ...opts, + reportType: 'security_compliance_posture', + summary: { ...postureSummary, securityProducts: products }, + }); + const text = pdfCommandText(doc); + for (const product of products) expect(text).toContain(product.product); + expect(doc.getNumberOfPages()).toBeGreaterThan(1); + expect(text).toContain('continued'); +}); +``` + +- [ ] **Step 2: Run the shared PDF test and verify RED** + +```bash +pnpm --filter @breeze/shared exec vitest run src/reportPdf/reportPdf.test.ts +``` + +Expected: the crowded fixture drops later product names and the continuation assertion FAILS. + +- [ ] **Step 3: Extract one product-row renderer** + +Move the existing category/coverage/sync/degraded formatting into: + +```ts +function drawPostureProductRow(doc: jsPDF, product: PostureProduct, y: number): number { + const catLabel = PRODUCT_CATEGORY_LABELS[product.category] ?? product.category; + const cat = product.product.toLowerCase().includes(catLabel.toLowerCase()) + ? '' + : ` (${catLabel})`; + const coverage = product.deviceCoverage != null ? ` - ${product.deviceCoverage} devices` : ''; + const syncOk = !product.lastSyncStatus || /^(ok|success|succeeded)$/i.test(product.lastSyncStatus); + const sync = syncOk ? '' : ` - sync ${product.lastSyncStatus}`; + const degraded = product.active === false ? ' - not reporting' : ''; + set.fill(doc, product.active === false ? C.warning : C.success); + doc.circle(PAGE.mx + 1.4, y - 1.2, 1.2, 'F'); + set.text(doc, C.ink); + doc.text(`${product.product}${cat}${coverage}`, PAGE.mx + 5, y); + if (sync || degraded) { + const baseW = doc.getTextWidth(`${product.product}${cat}${coverage}`); + set.text(doc, C.warning); + doc.text(`${sync}${degraded}`, PAGE.mx + 5 + baseW, y); + } + return y + 5.2; +} +``` + +Add `antivirus: 'Antivirus'` to `PRODUCT_CATEGORY_LABELS`. + +- [ ] **Step 4: Return cover overflow explicitly** + +Change `renderPostureCover` to return `PostureProduct[]`. Compute the number of product rows that fit above `PAGE.footY - 9`, reserving 4 mm for a continuation line when needed. Render the fitting slice through `drawPostureProductRow`. If products remain, draw: + +```ts +doc.text(`+ ${overflow.length} product${overflow.length === 1 ? '' : 's'} continued on the next page`, PAGE.mx + 5, y); +``` + +Return `overflow`; return `[]` when all products fit. + +- [ ] **Step 5: Render continuation pages before device detail** + +Add a function that paginates until the overflow is empty: + +```ts +function renderPostureProductContinuation( + doc: jsPDF, + products: PostureProduct[], + opts: BuildOpts, +): void { + let remaining = [...products]; + while (remaining.length > 0) { + doc.addPage('a4', 'landscape'); + let y = drawTitleBlock( + doc, + 'Security products in use', + '', + 'Continued from the posture summary', + PAGE.bandH + 8, + ); + const rowsPerPage = Math.max(1, Math.floor((PAGE.footY - 12 - y) / 5.2)); + const pageProducts = remaining.slice(0, rowsPerPage); + for (const product of pageProducts) y = drawPostureProductRow(doc, product, y); + remaining = remaining.slice(pageProducts.length); + drawHeaderBand(doc, opts); + drawFooter(doc, opts); + } +} +``` + +In `buildReportPdf`: + +```ts +const overflowProducts = renderPostureCover(doc, opts.summary as PostureSummary, opts, agg); +drawHeaderBand(doc, opts); +drawFooter(doc, opts); +renderPostureProductContinuation(doc, overflowProducts, opts); +if (records.length > 0) renderPostureTable(doc, records, opts); +``` + +- [ ] **Step 6: Rerun shared PDF tests** + +Expected: all tests PASS and every generated product name is present in the jsPDF command stream. + +- [ ] **Step 7: Commit product pagination** + +```bash +git add packages/shared/src/reportPdf/reportPdf.ts packages/shared/src/reportPdf/reportPdf.test.ts +git commit -m "fix(reports): continue posture products across PDF pages" +``` + +--- + +### Task 7: Documentation, full verification, and visual inspection + +**Files:** + +- Modify: `apps/docs/src/content/docs/features/reports.mdx:230-245` +- Verify: all files changed in Tasks 1-6. + +**Interfaces:** + +- Documents: `backupRequired`, legacy-required behavior, product continuation, and accurate tenant-safe vulnerability counts. + +- [ ] **Step 1: Update the report documentation** + +Replace the posture-config paragraph with explicit behavior: + +```md +The posture template asks whether backup coverage is required. New template-created +reports default this option off for workstation-oriented assessments. When backup +is optional, the PDF still shows detected backup products and labels missing backup +as `Not required`; it does not create a remediation recommendation. Existing reports +that predate this option retain required-backup behavior. + +API callers can set `config.backupRequired` together with `sites`, `windowDays`, +`minPasswordLength`, `maxLocalAdmins`, `maxAvDefinitionsAgeDays`, and `includeCis`. +Open vulnerability totals are scoped through tenant findings and the global CVE +catalog, and the PDF continues long security-product inventories onto another page. +``` + +- [ ] **Step 2: Run focused test suites together** + +```bash +pnpm --filter @breeze/api exec vitest run src/services/securityComplianceReportVulnerabilities.test.ts src/services/securityComplianceReportProducts.test.ts src/services/securityComplianceReport.test.ts src/routes/reports/schemas.security.test.ts src/routes/reports/schemas.config.test.ts +pnpm --filter @breeze/shared exec vitest run src/reportPdf/reportPdf.test.ts +pnpm --filter @breeze/web exec vitest run src/components/reports/ReportTemplates.posture.test.tsx src/components/reports/ReportEditPage.posture.test.tsx src/lib/i18n/translationCoverage.test.ts +``` + +Expected: all focused tests PASS with no warnings or unhandled rejections. + +- [ ] **Step 3: Run the real-DB integration test** + +```bash +pnpm --filter @breeze/api exec vitest run --config vitest.integration.config.ts src/__tests__/integration/securityComplianceReport.integration.test.ts +``` + +Expected: PASS when the test database is available; record an explicit skip when `DATABASE_URL` is unavailable. + +- [ ] **Step 4: Run package-level regression checks** + +```bash +pnpm --filter @breeze/shared typecheck +pnpm --filter @breeze/api test:run +pnpm --filter @breeze/web exec vitest run +pnpm --filter @breeze/shared exec vitest run +``` + +Expected: all commands exit 0. If an unrelated pre-existing failure occurs, capture its exact test name and verify it also fails on the task's base commit before excluding it from the completion claim. + +- [ ] **Step 5: Generate and inspect a reference-like PDF** + +Create `tmp/pdfs/render-posture-corrections.ts` with `apply_patch` using this complete temporary fixture: + +```ts +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { buildReportPdf } from '../../packages/shared/src/reportPdf/reportPdf'; +import type { PostureProduct, PostureSummary } from '../../packages/shared/src/types/postureReport'; + +const baseProducts: PostureProduct[] = [ + { product: 'Huntress', category: 'mdr', active: true, deviceCoverage: 6 }, + { product: 'SentinelOne', category: 'edr', active: true, deviceCoverage: 4 }, + { product: 'Defender', category: 'antivirus', active: true, deviceCoverage: 4 }, +]; +const overflowProducts: PostureProduct[] = Array.from({ length: 12 }, (_, index) => ({ + product: `Reference Product ${index + 1}`, + category: 'antivirus', + active: index % 4 !== 0, + deviceCoverage: index + 1, +})); +const summary: PostureSummary = { + org: { id: 'reference-org', name: 'Reference Organization' }, + deviceCount: 8, + postureScore: 77, + controls: { + edrCoveragePct: 75, + anyAvCoveragePct: 100, + unprotectedCount: 0, + avDefinitionsCurrentPct: 88, + encryptionPct: 100, + firewallPct: 100, + patchCurrentPct: 100, + localAdminExposurePct: 86, + backupRequired: false, + backupConfigured: false, + }, + privilegedAccess: { + uacInterceptionEnabled: false, + activePamRules: 0, + windowDays: 30, + elevationsInWindow: 0, + elevationsApproved: 0, + elevationsDenied: 0, + mfaStepUpEnforced: false, + }, + securityProducts: [...baseProducts, ...overflowProducts], +}; +const rows = Array.from({ length: 8 }, (_, index) => ({ + hostname: `WORKSTATION-${index + 1}`, + os: 'windows', + site: 'Main', + protection: index < 4 ? 'Huntress + Defender (RTP on)' : 'SentinelOne (RTP on)', + protectionManaged: true, + avDefinitionsAgeDays: index + 1, + encryption: 'encrypted', + firewall: true, + localAdmins: index % 3, + patchAssessed: true, + pendingPatches: index + 1, + criticalPatches: 0, + openVulnHigh: index % 2, + openVulnCritical: index === 0 ? 1 : 0, +})); + +const outputPath = fileURLToPath(new URL('./posture-corrections.pdf', import.meta.url)); +await mkdir(dirname(outputPath), { recursive: true }); +const doc = buildReportPdf(rows, { + generatedAt: 'Jul 14, 2026, 8:44 AM', + timezone: 'America/Denver', + reportType: 'security_compliance_posture', + summary, + branding: { name: 'Reference MSP', logoDataUrl: null, logoAspect: null }, +}); +await writeFile(outputPath, Buffer.from(doc.output('arraybuffer'))); +``` + +Generate and render it with: + +```bash +mkdir -p tmp/pdfs/posture-corrections +pnpm exec tsx tmp/pdfs/render-posture-corrections.ts +pdftoppm -png -r 144 tmp/pdfs/posture-corrections.pdf tmp/pdfs/posture-corrections/page +``` + +Inspect every PNG and confirm: + +- cover metrics and recommendations do not overlap; +- optional backup is neutral and says `Not required`; +- the cover has an explicit product-continuation message; +- continuation pages list every supplied product; +- page headers, footers, and totals remain correct; +- per-device vulnerability counts are legible and aligned. + +Delete only `tmp/pdfs/render-posture-corrections.ts`, `tmp/pdfs/posture-corrections.pdf`, and `tmp/pdfs/posture-corrections/` after inspection; do not touch the user's reference PDF. + +- [ ] **Step 6: Check the final diff and working tree** + +```bash +git diff --check +git status --short +git diff --stat c84fe2913..HEAD +``` + +Expected: no whitespace errors; only planned source/tests/docs are changed or committed. Preserve the user's pre-existing untracked `.githooks/*` files. + +- [ ] **Step 7: Commit documentation if it was not included earlier** + +```bash +git add apps/docs/src/content/docs/features/reports.mdx +git commit -m "docs(reports): explain posture backup requirements" +``` diff --git a/docs/superpowers/specs/2026-07-14-posture-backup-evidence-design.md b/docs/superpowers/specs/2026-07-14-posture-backup-evidence-design.md new file mode 100644 index 0000000000..58b8ef7b3a --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-posture-backup-evidence-design.md @@ -0,0 +1,215 @@ +# Posture report: third-party backup evidence + +**Status: PARKED — design only, not approved for implementation.** + +Parked on 2026-07-14 on a scope objection (see [Why this is parked](#why-this-is-parked)). +Do not implement from this document without settling that question first. + +## Problem + +The Security & Compliance Posture report reports backup as a control, but it can +only see Breeze's own backup: + +- `backup_configs.provider` is `local | s3 | azure_blob | google_cloud` — storage + *targets* for Breeze's own backup, not vendors. +- `c2c_connections.provider` is `microsoft_365 | google_workspace` — the *source + Breeze backs up*, not the backup vendor. + +There is no representation of "a third-party product protects this customer". +A repo-wide grep for Cove, Dropsuite, Veeam, Acronis, Datto, Axcient and N-able +returns nothing. + +So `backupConfigured: Boolean(backup || c2c)` answers *"is Breeze backing this +customer up?"*, not *"is this customer backed up?"*. For a customer protected by +Cove, the report renders **Backup: No** in red and fires *"Configure backups — no +backup solution is currently detected for this organization."* That is a false +negative on a document whose stated purpose is filling out cyber-insurance +applications. + +### This explains the `backupRequired: false` default + +The posture template card defaults `backupRequired` to `false` +(`ReportTemplates.tsx:349`, re-forced at `:427`), and it is the only UI path that +can create a posture report — `handleUseTemplate` intercepts the type before the +builder sees it, because the builder cannot round-trip it. + +`docs/features/reports.mdx` justifies the default as suiting "workstation-oriented +assessments". The report cannot express that: the device query filters on `org_id`, +optional `sites`, and the permission set — there is no OS or device-type filter +(`securityComplianceReport.ts:180-196`). You can scope to a *site*, not to +workstations-versus-servers. + +The real reason is this gap. For any MSP not using Breeze's own backup the control +is permanently red, so defaulting it off was the only sane move. **The default is a +workaround for missing evidence, not a product preference** — which is why +"just flip the default to `true`" is wrong: it converts a misleading +"Not required" into a flatly incorrect "No" for every Cove customer. + +### Secondary flaw + +`Boolean(backup || c2c)` collapses endpoint and SaaS backup into one boolean. A +customer with SaaS backup but no endpoint backup renders **Backup: Yes**, and the +endpoint gap disappears. The Cove/Dropsuite split is exactly this distinction and +the current model cannot hold it. + +## Why this is parked + +This design turns the posture report into a reporting plane for software Breeze +does not manage. Breeze would be storing, rendering and standing behind claims +about third-party products it has no visibility into and no relationship with. + +That is a product-direction decision, not a technical one, and it generalises +immediately: if backup is attested, why not EDR, DNS filtering, or MFA? The same +false-negative argument applies to every control where an MSP uses a tool Breeze +doesn't integrate. Answering it for backup alone sets the precedent for all of +them without anyone deciding to. + +**Settle before implementing:** is "record and render evidence about tools we do +not manage" something Breeze does at all? If yes, it wants a deliberate, +report-wide answer rather than a backup-shaped one. If no, the gap needs a +different fix — most likely a real Cove/Dropsuite integration, or accepting that +the posture report only speaks to what Breeze observes and saying so plainly on +the document. + +## Decisions taken before parking + +Recorded so the thread isn't lost, not as settled scope. + +| Question | Decision | Reasoning | +|---|---|---| +| How does Breeze learn Cove/Dropsuite protects a customer? | Attestation | Covers the long tail no integration roadmap finishes. Ships without vendor APIs. | +| Who owns an attestation? | **Org-only** | See justification below — required by CLAUDE.md for a non-dual-axis config table. | +| How is "not applicable" decided? | Derived | Org has devices → endpoint in scope. Org has M365/Google → SaaS in scope. The report already runs all three queries, so it costs nothing and needs no per-org config. | +| What happens to `backupRequired`? | Escape hatch, default **on** — *after* attestation ships | Derivation answers scope; the toggle becomes a rare "exclude backup from this assessment". | + +### Org-only ownership justification (CLAUDE.md requirement) + +CLAUDE.md requires an explicit justification whenever a new config table is +org-owned rather than `org_id` XOR `partner_id`. + +**An attestation is not a policy.** Partner-wide-first (epic #2135) exists for +rules an MSP authors once and applies broadly — "require backup", "approve these +patches". "Cove protects Acme Ltd" is a *fact about one customer*: evidence, not a +reusable rule. Evidence being org-scoped is correct. That Cove is the MSP's house +standard is a data-entry convenience, not a policy relationship. This is the same +reasoning that makes `backup_configs` org-owned. + +The cost was accepted knowingly: the vendor is re-entered per customer. + +Note the split this exposes — the *attestation* is org-scoped evidence, but +`backupRequired` (is-backup-in-scope) is policy-shaped and may belong at partner +level. Unresolved. + +## Design sketch + +### Data model + +``` +backup_attestations + id uuid pk + org_id uuid not null → organizations(id) -- shape 1 RLS + kind enum('endpoint','saas') not null + vendor varchar(80) not null -- 'Cove', 'Dropsuite', free text + note text null + attested_by uuid not null → users(id) + attested_at timestamptz not null default now() + unique (org_id, kind) +``` + +A table rather than `organizations.settings` because: + +- **Provenance is the product.** "Cove — attested by todd@lanternops.io, 14 Jul + 2026" is materially stronger evidence than "Cove". An unsigned, undated claim is + near worthless to an underwriter; a named person and a date is something someone + stands behind. That needs real columns. +- **Staleness becomes visible.** `attested_at` lets the PDF show the date and lets + the report flag an attestation gone stale rather than presenting a two-year-old + claim as current fact. +- `organizations.settings` has a recorded `z.any()` validation gap on its PATCH + path; insurance evidence should not sit behind it. + +`vendor` is free text, not an enum — the point is the long tail, and an enum means +a migration per new vendor. Cost: no curated list, inconsistent naming. + +RLS: direct `org_id` column → shape 1, `breeze_has_org_access(org_id)`, +auto-discovered by the coverage contract test. Policies ship in the creating +migration. + +### Evaluation + +Backup splits into two independent controls: + +| | In scope when | Evidence precedence | +|---|---|---| +| Endpoint/server | org has devices | Breeze `backup_configs` → attestation → none | +| SaaS/cloud | org has M365 or Google connection | Breeze `c2c_connections` → attestation → none | + +Both gated on `backupRequired`. Out-of-scope rows are omitted, not rendered N/A. +Breeze-native evidence beats an attestation for the same kind: if Breeze runs the +backup that is strictly better evidence, and it stops a stale attestation +contradicting live data. + +Three states: + +- **Verified** — Breeze ran it. Green. Can report `backupEncrypted`. +- **Attested** — declared. Green, labeled `Cove — attested by , `. +- **Absent, in scope** — red; "Configure backups" recommendation fires. + +Attested renders green rather than neutral, deliberately. The report's ethos is +honesty about gaps — an unassessed control reads N/A, never a favorable score. An +attestation isn't something Breeze assessed, but it isn't a gap either: it's a +named person putting their name to a claim. Rendering an accurate attestation as +neutral punishes the truth and pushes the MSP back to unticking the box. The label +carries the caveat, not the colour. + +`backupEncrypted` stays `null` → N/A for attested evidence. Cove's encryption +posture is unknown and must not be implied. + +**The score does not move.** `postureScore` is read verbatim from the precomputed +`security_posture_org_snapshots` weighting, where backup was never a term. This +changes what the document shows, not what it scores, keeping the change off the +scoring path. + +### Persisted shape + +`PostureControls` lives in `report_runs.result` and its header mandates that legacy +snapshots still render. Additive only: + +```ts +backupEvidence?: { + endpoint?: { inScope: boolean; source: 'breeze'|'attested'|'none'; + vendor?: string; encrypted?: boolean|null; + attestedBy?: string; attestedAt?: string }; + saas?: { inScope: boolean; source: 'breeze'|'attested'|'none'; + vendor?: string; attestedBy?: string; attestedAt?: string }; +}; +// legacy — retained, still written, read only as fallback +backupRequired?: boolean; +backupConfigured?: boolean; +backupEncrypted?: boolean | null; +``` + +The PDF branches on `backupEvidence` presence. Existing report runs keep rendering +through today's `buildPostureBackupMetric` unchanged. + +### Not designed yet + +Parked before reaching: PDF layout for the two rows, the org-settings UI, i18n +keys, and the test plan. + +## Sequencing, if this ever proceeds + +1. `backupRequired` **stays default-off** until attestation exists. Flipping it + first makes every Cove customer read "Backup: No". +2. The attestation change flips the default to on in the same PR that makes on + truthful. +3. `docs/features/reports.mdx` currently justifies the off-default as suiting + "workstation-oriented assessments", which is not a thing the report can do. The + honest interim sentence is that Breeze currently detects only its own backup. + That correction is independent of this design and can land on its own. + +## Related + +- `docs/superpowers/specs/2026-07-14-security-compliance-posture-report-corrections-design.md` +- CLAUDE.md → "Partner-Wide First (config/policy tables) — epic #2135" +- CLAUDE.md → "Tenant Isolation / RLS", shape 1 diff --git a/docs/superpowers/specs/2026-07-14-security-compliance-posture-report-corrections-design.md b/docs/superpowers/specs/2026-07-14-security-compliance-posture-report-corrections-design.md new file mode 100644 index 0000000000..da0b7589fd --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-security-compliance-posture-report-corrections-design.md @@ -0,0 +1,208 @@ +# Security & Compliance Posture Report Corrections - Design + +**Status:** Approved design, pending implementation plan +**Date:** 2026-07-14 +**Reference artifact:** `/Users/toddhebebrand/Downloads/security_compliance_posture-report-2026-07-14.pdf` + +## Problem + +The Security & Compliance Posture report currently publishes misleading or incomplete evidence in three areas: + +1. Open vulnerability counts can appear as zero even when affected devices have findings. +2. Backup is treated as a mandatory failed control even for workstation-oriented reports where backup is intentionally not required. +3. The security-product inventory omits endpoint-reported products and silently clips later products when the PDF cover runs out of space. + +The supplied PDF demonstrates the product problem directly: device rows show Huntress, Defender, and SentinelOne, while the cover lists only Huntress. It also shows every high and critical vulnerability count as zero. + +This work corrects the evidence and presentation paths without changing the numeric posture-score model. The separately reported template-gallery duplication and mislabeled Technician Activity template are explicitly deferred to follow-up work. + +## Goals + +- Count open high and critical vulnerabilities accurately for ad-hoc and scheduled reports. +- Preserve tenant isolation while reading the system-only CVE catalog. +- Make backup a report-level requirement choice. +- Keep optional backup evidence visible and neutral rather than hiding it. +- Include all detected security products with deduplicated device coverage. +- Never silently truncate security-product evidence in the PDF. +- Preserve existing saved reports and historical report snapshots. + +## Non-goals + +- Changing the security posture score or its weights. +- Adding new endpoint telemetry or per-device backup coverage. +- Classifying devices as servers versus workstations. +- Canonicalizing or migrating the entire vulnerability catalog as part of this report fix. +- Fixing template duplication or the Technician Activity template mapping. + +## Selected approach + +Use targeted end-to-end corrections at the existing report boundaries: + +- Mirror the established two-phase vulnerability read used by the fleet vulnerability feature. +- Add an explicit `backupRequired` report option and carry it into the persisted summary. +- Build a canonical product inventory from managed integrations and endpoint observations. +- Continue product inventory onto a dedicated PDF page when the cover cannot contain every item. + +This is preferred over either loosening the report's database context or undertaking a broad reporting-engine refactor. It fixes the root causes while keeping the change isolated to posture-report configuration, generation, shared types, UI options, and PDF rendering. + +## Architecture and data flow + +### Vulnerability counts + +The current report directly joins `device_vulnerabilities`, which is tenant-readable, to `vulnerabilities`, which is a forced-RLS system-only catalog. Under an organization or partner request context, PostgreSQL removes the catalog side of the join and the report receives no rows. Real catalog severity values also arrive in inconsistent casing such as `HIGH`, `High`, `CRITICAL`, and `Critical`, while the report compares only exact lowercase values. + +The corrected flow is: + +1. Under the ambient tenant request context, read open `device_vulnerabilities` rows for the report's already-scoped device IDs. +2. Collect the distinct referenced vulnerability IDs. +3. Escape the request database context with `runOutsideDbContext` and read only those catalog IDs inside `withSystemDbAccessContext`. +4. Merge the tenant findings and catalog records in memory. +5. Normalize severity with lowercase comparison and produce per-device high and critical counts. + +The tenant-scoped read remains the authorization boundary. The system read contains only global reference data for IDs that survived that boundary. The report must not run its entire generation path under system context as a shortcut. + +If tenant findings exist but their referenced catalog evidence cannot be loaded completely, generation fails with a clear error. It must not publish zeros for an incomplete lookup. The catalog foreign key should make missing referenced rows abnormal, so fail-closed behavior is appropriate for an insurance-oriented report. + +### Backup requirement + +Add `backupRequired: boolean` to the posture-report configuration and `backupRequired?: boolean` to `PostureControls` for persisted-summary compatibility. + +Behavior is: + +- New reports created from the posture template explicitly default `backupRequired` to `false`. +- A report-creation option labeled `Require backup coverage` lets the user opt in. +- Existing reports and historical summaries that omit `backupRequired` retain the current required behavior. +- When backup is required, the existing pass/fail control and missing-backup recommendation remain. +- When backup is optional and no solution is detected, the PDF renders a neutral `Backup - Not required` result and emits no recommendation. +- When backup is optional and configured, the PDF renders a neutral `Backup - Optional; configured` result. +- Detected endpoint or SaaS backup products remain in the product inventory regardless of requirement policy. + +Backup remains excluded from the numeric posture score. This change affects only the compliance judgment and recommendation. + +### Security-product inventory + +Build one deterministic inventory from: + +- Huntress-managed device IDs. +- SentinelOne-managed device IDs. +- Each in-scope `security_status.provider` other than `other`. +- Existing DNS filtering, endpoint backup, SaaS backup, and identity integrations. + +For device-based products, aggregation uses normalized product identity and a set of device IDs. If SentinelOne is detected through both `s1_agents` and `security_status`, the inventory contains one SentinelOne row whose coverage is the union of those IDs. + +Add `antivirus` to `PostureProductCategory`. Endpoint observations are categorized consistently: + +- Defender, Bitdefender, Sophos, Malwarebytes, ESET, and Kaspersky are antivirus. +- SentinelOne, CrowdStrike, and Elastic Defend are EDR. +- A managed-product source takes precedence over an endpoint-only classification when the same normalized product is present in both sources. + +Endpoint providers are inventory evidence even when real-time protection is off. The product remains listed, but its activity state reflects whether it is protected or corroborated by a managed integration. Device coverage is the unique count of detected installations, not the count of only RTP-enabled devices. + +### PDF rendering + +The product inventory is compliance evidence and must be complete. + +The cover renders as many product rows as safely fit without overlapping the glossary or footer. If every product does not fit: + +1. The cover displays an explicit continuation note. +2. A dedicated `Security products in use` continuation page renders the complete remaining inventory. +3. Per-device detail follows afterward. + +The renderer never exits a product loop silently. Product ordering is deterministic for stable snapshots and tests, but ordering does not determine which evidence survives. + +## Component boundaries + +### API report service + +Introduce a focused vulnerability-loading boundary that owns the tenant-to-system context transition and returns per-device counts. Keep the main generator responsible for report orchestration, not RLS mechanics. + +Introduce a pure product-inventory aggregator that accepts scoped device observations and integration sets, then returns `PostureProduct[]`. Its normalization, deduplication, category, activity, and coverage rules are independently testable. + +### Report schemas and shared types + +- Add `backupRequired` to both posture configuration schema definitions used by create, update, and ad-hoc generation. +- Default parsed legacy configuration to required at the API boundary. +- Add the optional persisted-summary field and the `antivirus` category to shared posture types. + +No database migration is required because report configuration and result summaries are JSON. + +### Web report creation and editing + +The posture template currently bypasses the generic report builder to avoid downgrading its report type. Preserve that behavior, but add a lightweight posture-options step before direct creation. It must post the true `security_compliance_posture` type and an explicit `backupRequired` value. + +Editing a posture report must preserve posture-specific and unknown future configuration fields. It must not route the report through a form path that reconstructs only generic configuration or downgrades its type. Saving should merge the edited posture options with the existing configuration. + +All new user-facing strings must exist in the five supported report locale files. + +### Shared PDF renderer + +The PDF layer owns presentation only. It reads `backupRequired` from the persisted summary, treats absence as required for backward compatibility, renders the new antivirus label, and handles product continuation without changing evidence semantics. + +## Error handling and compatibility + +- Vulnerability catalog lookup failure is a report-generation error, not a zero count. +- Tenant and site scoping continue to happen before the catalog read. +- Unknown or unclassified severity values are ignored for high/critical totals but do not invalidate otherwise complete catalog evidence. +- Historical summaries without `backupRequired` remain required. +- Historical product categories continue to render. +- Old report configurations can be regenerated without schema rejection. +- Product output order is stable. +- No unrelated report template behavior changes in this implementation. + +## Testing strategy + +Implementation follows test-driven development, with each production behavior preceded by a failing regression test. + +### API and data isolation + +- Unit test mixed-case high and critical catalog severities. +- Test that open findings count while patched, mitigated, and accepted findings do not. +- Test incomplete catalog lookup fails generation instead of returning zeros. +- Add an RLS integration test proving an organization-context report sees its own findings through the two-phase lookup and cannot see another organization's findings. +- Add a parity fixture comparing posture-report per-device counts to the canonical fleet vulnerability aggregation. + +### Product aggregation + +- Native Defender appears with correct unique-device coverage. +- Endpoint-only SentinelOne appears without an `s1_agents` row. +- SentinelOne present through both sources is emitted once with union coverage. +- RTP-off products remain visible with degraded activity semantics. +- Site and device scope limit product coverage correctly. + +### Backup configuration and UI + +- Schema accepts both boolean values, rejects non-booleans, and applies legacy-required behavior when omitted. +- Template creation posts `backupRequired: false` by default and preserves the posture report type. +- Opting in posts `backupRequired: true`. +- Posture editing loads and saves the choice while preserving unrelated config fields. +- Optional/no-backup summary remains factually `backupConfigured: false` while rendering neutrally and omitting the recommendation. +- Required/no-backup and legacy summaries retain the failed control and recommendation. + +### PDF + +- A fixture matching the supplied artifact renders Huntress, SentinelOne, and Defender. +- A many-product fixture produces a continuation page and contains every product name. +- No product row overlaps the glossary or footer. +- Existing posture and generic report PDF tests remain green. +- Render the final reference-like PDF to PNG and inspect page transitions, alignment, clipping, and legibility. + +## Acceptance criteria + +- Ad-hoc tenant-generated reports count open high and critical vulnerabilities correctly. +- Scheduled reports produce the same vulnerability counts for the same snapshot. +- Mixed-case source severities count identically. +- Cross-tenant findings never enter the report. +- A new posture report can treat backup as optional without a red status or recommendation. +- Optional backup remains visible as neutral evidence, and detected backup products remain listed. +- The security-product inventory includes endpoint-reported Defender and SentinelOne where detected. +- Duplicate detections do not inflate product device coverage. +- Every product in the generated summary appears in the PDF, either on the cover or the continuation page. +- Numeric posture score and methodology remain unchanged. + +## Deferred follow-up + +Track separately: + +- Creating a report from a template appears to duplicate a report-like item in the template list. +- The Technician Activity template is mislabeled and currently generates device inventory. + diff --git a/packages/shared/src/reportPdf/reportPdf.test.ts b/packages/shared/src/reportPdf/reportPdf.test.ts index c5ce86d5ac..85c2236aa7 100644 --- a/packages/shared/src/reportPdf/reportPdf.test.ts +++ b/packages/shared/src/reportPdf/reportPdf.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { buildReportPdf } from './reportPdf'; +import { buildPostureBackupMetric, buildReportPdf } from './reportPdf'; import type { PostureSummary } from '../types/postureReport'; import type { ExecutiveSummary } from '../types/executiveSummaryReport'; @@ -28,6 +28,60 @@ const execSummary: ExecutiveSummary = { }; const opts = { generatedAt: 'Jul 1, 2026, 9:00 AM', timezone: 'UTC' }; +// jsPDF encodes standard-font text as WinAnsi (cp1252) bytes, which surface in +// the page command strings as raw 0x80-0x9f characters — an em-dash arrives as +// \x97, not "—". That range is the only place cp1252 diverges from latin1, so +// mapping it back lets the assertions below match the typography a reader +// actually sees. Without it, `toContain('— 1 device')` fails and the tempting +// "fix" is to downgrade the PDF's punctuation to ASCII to suit the test. +const CP1252_HIGH = + '\u20ac\u0081\u201a\u0192\u201e\u2026\u2020\u2021' + + '\u02c6\u2030\u0160\u2039\u0152\u008d\u017d\u008f' + + '\u0090\u2018\u2019\u201c\u201d\u2022\u2013\u2014' + + '\u02dc\u2122\u0161\u203a\u0153\u009d\u017e\u0178'; +const decodeWinAnsi = (s: string): string => + s.replace(/[\u0080-\u009f]/g, (ch) => CP1252_HIGH[ch.charCodeAt(0) - 0x80] ?? ch); + +function pdfCommandPages(doc: ReturnType): string[] { + return ((doc.internal as unknown as { pages: Array }).pages ?? []) + .filter((page): page is string[] => Array.isArray(page)) + .map((page) => decodeWinAnsi(page.join('\n'))); +} + +function pdfCommandText(doc: ReturnType): string { + return pdfCommandPages(doc).join('\n'); +} + +describe('buildPostureBackupMetric', () => { + it.each([false, true])( + 'renders optional backup neutrally when configured=%s', + (backupConfigured) => { + expect(buildPostureBackupMetric({ + backupRequired: false, + backupConfigured, + })).toEqual({ + label: 'Backup', + value: backupConfigured ? 'Optional; configured' : 'Not required', + status: 'neutral', + }); + }, + ); + + it.each([ + { backupConfigured: false, status: 'bad' }, + { backupConfigured: true, status: 'good' }, + ] as const)( + 'keeps legacy backup configured=$backupConfigured status=$status', + ({ backupConfigured, status }) => { + expect(buildPostureBackupMetric({ backupConfigured })).toEqual({ + label: 'Backup', + value: backupConfigured ? 'Yes' : 'No', + status, + }); + }, + ); +}); + describe('buildReportPdf in Node (no DOM)', () => { it('renders the posture cover + device table', () => { const doc = buildReportPdf(postureRows, { ...opts, reportType: 'security_compliance_posture', summary: postureSummary }); @@ -35,6 +89,120 @@ describe('buildReportPdf in Node (no DOM)', () => { expect(Buffer.from(doc.output('arraybuffer')).byteLength).toBeGreaterThan(1000); }); + it('renders Huntress, SentinelOne, and Defender from the reference-like inventory', () => { + const summary: PostureSummary = { + ...postureSummary, + securityProducts: [ + { product: 'Huntress', category: 'mdr', active: true, deviceCoverage: 6 }, + { product: 'SentinelOne', category: 'edr', active: true, deviceCoverage: 4 }, + { product: 'Defender', category: 'antivirus', active: true, deviceCoverage: 4 }, + ], + }; + const rowsWithoutProductNames = postureRows.map((row) => ({ ...row, protection: 'Device protection' })); + const doc = buildReportPdf(rowsWithoutProductNames, { + ...opts, + reportType: 'security_compliance_posture', + summary, + }); + const text = pdfCommandText(doc); + expect(text).toContain('Huntress'); + expect(text).toContain('SentinelOne'); + expect(text).toContain('Defender'); + const pages = pdfCommandPages(doc); + const continuationPage = pages.findIndex((page) => page.includes('Continued from the posture summary')); + const detailPage = pages.findIndex((page) => page.includes('Per-device detail')); + expect(continuationPage).toBeGreaterThan(0); + expect(detailPage).toBeGreaterThan(continuationPage); + }); + + it('continues a large product inventory across multiple ordered pages with page chrome', () => { + const products = Array.from({ length: 80 }, (_, index) => ({ + product: `Security Product [${String(index + 1).padStart(3, '0')}]`, + category: 'antivirus' as const, + active: true, + deviceCoverage: index + 1, + })); + const doc = buildReportPdf(postureRows, { + ...opts, + reportType: 'security_compliance_posture', + summary: { ...postureSummary, securityProducts: products }, + }); + const pages = pdfCommandPages(doc); + const text = pdfCommandText(doc); + for (const product of products) expect(text).toContain(product.product); + const continuationPages = pages + .map((page, pageIndex) => ({ page, pageIndex })) + .filter(({ page }) => ( + page.includes('Security products in use') + && page.includes('Continued from the posture summary') + )); + expect(continuationPages.length).toBeGreaterThanOrEqual(2); + for (const { page } of continuationPages) { + expect(page).toContain('SECURITY & COMPLIANCE POSTURE'); + expect(page).toContain('Generated by Breeze RMM'); + expect(page).toContain('Confidential'); + } + const detailPage = pages.findIndex((page) => page.includes('Per-device detail')); + expect(detailPage).toBeGreaterThan(continuationPages.at(-1)!.pageIndex); + const inventoryPagesText = pages.slice(0, detailPage).join('\n'); + let previousProductIndex = -1; + for (const product of products) { + const productIndex = inventoryPagesText.indexOf(product.product); + expect(productIndex).toBeGreaterThan(previousProductIndex); + previousProductIndex = productIndex; + } + expect(text).toContain('continued'); + expect(text).toContain('Antivirus'); + expect(text).toContain(' — 1 device'); + expect(text).not.toContain(' — 1 devices'); + }); + + it('renders optional missing backup neutrally and omits the backup recommendation', () => { + const summary: PostureSummary = { + ...postureSummary, + controls: { + ...postureSummary.controls, + backupRequired: false, + backupConfigured: false, + }, + }; + const text = pdfCommandText(buildReportPdf(postureRows, { + ...opts, + reportType: 'security_compliance_posture', + summary, + })); + expect(text).toContain('Not required'); + expect(text).not.toContain('Configure backups'); + }); + + it('renders configured optional backup neutrally without removing the evidence', () => { + const summary: PostureSummary = { + ...postureSummary, + controls: { + ...postureSummary.controls, + backupRequired: false, + backupConfigured: true, + }, + }; + expect(pdfCommandText(buildReportPdf(postureRows, { + ...opts, + reportType: 'security_compliance_posture', + summary, + }))).toContain('Optional; configured'); + }); + + it('keeps missing backup required for legacy summaries', () => { + const summary: PostureSummary = { + ...postureSummary, + controls: { ...postureSummary.controls, backupConfigured: false }, + }; + expect(pdfCommandText(buildReportPdf(postureRows, { + ...opts, + reportType: 'security_compliance_posture', + summary, + }))).toContain('Configure backups'); + }); + it('renders a generic table for row reports', () => { const doc = buildReportPdf([{ hostname: 'PC-1', status: 'online' }], { ...opts, reportType: 'device_inventory' }); expect(doc.getNumberOfPages()).toBe(1); diff --git a/packages/shared/src/reportPdf/reportPdf.ts b/packages/shared/src/reportPdf/reportPdf.ts index 25a869bd10..7256748502 100644 --- a/packages/shared/src/reportPdf/reportPdf.ts +++ b/packages/shared/src/reportPdf/reportPdf.ts @@ -1,6 +1,6 @@ import { jsPDF } from 'jspdf'; import autoTable, { type CellHookData } from 'jspdf-autotable'; -import type { PostureSummary } from '../types/postureReport'; +import type { PostureControls, PostureProduct, PostureSummary } from '../types/postureReport'; import type { ExecutiveSummary } from '../types/executiveSummaryReport'; /** @@ -497,6 +497,20 @@ function pctStatus(v: number | null | undefined, good = 90, warn = 60): MetricSt return 'bad'; } +export function buildPostureBackupMetric(controls: PostureControls) { + const backupRequired = controls.backupRequired !== false; + const backupValue = backupRequired + ? `${yesNo(controls.backupConfigured)}${controls.backupConfigured && controls.backupEncrypted ? ' (encrypted)' : ''}` + : controls.backupConfigured + ? 'Optional; configured' + : 'Not required'; + return { + label: 'Backup', + value: backupValue, + status: backupRequired ? boolStatus(controls.backupConfigured) : 'neutral', + } satisfies Metric; +} + type PostureAggregates = { criticalCount: number; unprotectedCount: number }; function renderPostureCover( @@ -504,7 +518,7 @@ function renderPostureCover( summary: PostureSummary, opts: BuildOpts, agg: PostureAggregates, -): void { +): PostureProduct[] { const c = summary.controls ?? {}; const p = summary.privilegedAccess ?? {}; const deviceCount = summary.deviceCount ?? 0; @@ -573,16 +587,13 @@ function renderPostureCover( : `${c.cisAvgPassRate}% (${c.cisAssessedCount ?? 0}/${deviceCount})`; protectionMetrics.push({ label: 'CIS hardening', value: cisVal, status: pctStatus(c.cisAvgPassRate, 90, 70), target: '>=90%' }); } + const backupMetric = buildPostureBackupMetric(c); const accessMetrics: Metric[] = [ { label: 'Host firewall', value: pctStr(c.firewallPct), status: pctStatus(c.firewallPct), target: '>=95%' }, { label: 'Password complexity', value: pctStr(c.passwordComplexityPct), status: pctStatus(c.passwordComplexityPct), target: '>=90%' }, { label: 'Local-admin exposure', value: pctStr(c.localAdminExposurePct), status: pctStatus(c.localAdminExposurePct == null ? null : 100 - c.localAdminExposurePct), target: '<=10%' }, { label: 'Identity provider connected', value: yesNo(c.identityProviderConnected), status: boolStatus(c.identityProviderConnected) }, - { - label: 'Backup configured', - value: `${yesNo(c.backupConfigured)}${c.backupConfigured && c.backupEncrypted ? ' (encrypted)' : ''}`, - status: boolStatus(c.backupConfigured), - }, + backupMetric, { label: 'DNS filtering active', value: yesNo(c.dnsFilteringActive), status: boolStatus(c.dnsFilteringActive) }, ]; y = drawMetricGrid(doc, [...protectionMetrics, ...accessMetrics], y); @@ -608,32 +619,31 @@ function renderPostureCover( const productReserve = products.length > 0 ? 13.5 : 0; y = drawRecommendedActions(doc, summary, agg, y + 2.5, productReserve); - if (products.length > 0 && y + 8 < PAGE.footY - 9) { + let overflow: PostureProduct[] = []; + if (products.length > 0) { y = drawSectionHeading(doc, 'Security products in use', y + 2.5); - doc.setFont('helvetica', 'normal'); - doc.setFontSize(9); - for (const prod of products) { - if (y > PAGE.footY - 9) break; // never draw into the glossary/footer - const catLabel = PRODUCT_CATEGORY_LABELS[prod.category] ?? prod.category; - // Skip the category tag when the product name already conveys it - // (avoids "Backup (local) (Backup)"). - const cat = prod.product.toLowerCase().includes(catLabel.toLowerCase()) ? '' : ` (${catLabel})`; - const coverage = prod.deviceCoverage != null ? ` — ${prod.deviceCoverage} devices` : ''; - // Sync status is only interesting when it's a problem; "[sync: success]" - // is machine noise on a client-facing page. - const syncOk = !prod.lastSyncStatus || /^(ok|success|succeeded)$/i.test(prod.lastSyncStatus); - const sync = syncOk ? '' : ` — sync ${prod.lastSyncStatus}`; - const degraded = prod.active === false ? ' — not reporting' : ''; - set.fill(doc, prod.active === false ? C.warning : C.success); - doc.circle(PAGE.mx + 1.4, y - 1.2, 1.2, 'F'); - set.text(doc, C.ink); - doc.text(`${prod.product}${cat}${coverage}`, PAGE.mx + 5, y); - if (sync || degraded) { - const baseW = doc.getTextWidth(`${prod.product}${cat}${coverage}`); - set.text(doc, C.warning); - doc.text(`${sync}${degraded}`, PAGE.mx + 5 + baseW, y); - } - y += 5.2; + const bottomY = PAGE.footY - 9; + const fullCapacity = Math.max(0, Math.floor((bottomY - y) / POSTURE_PRODUCT_ROW_HEIGHT) + 1); + const needsContinuation = products.length > fullCapacity; + // When products overflow, leave a full line below the rendered rows for + // the explicit continuation notice instead of silently clipping a name. + const coverCapacity = needsContinuation + ? Math.max(0, Math.floor((bottomY - POSTURE_PRODUCT_CONTINUATION_SPACE - y) / POSTURE_PRODUCT_ROW_HEIGHT)) + : products.length; + const coverProducts = products.slice(0, coverCapacity); + for (const product of coverProducts) { + y = drawPostureProductRow(doc, product, y); + } + overflow = products.slice(coverProducts.length); + if (overflow.length > 0) { + set.text(doc, C.muted); + doc.setFont('helvetica', 'normal'); + doc.setFontSize(7.5); + doc.text( + `+ ${overflow.length} product${overflow.length === 1 ? '' : 's'} continued on the next page`, + PAGE.mx + 5, + y, + ); } } @@ -647,6 +657,7 @@ function renderPostureCover( PAGE.mx, PAGE.footY - 2, ); + return overflow; } // ---------------------------------------------------------------------------- @@ -668,7 +679,7 @@ function buildRecommendations(summary: PostureSummary, agg: PostureAggregates): if (agg.criticalCount > 0) { recs.push({ severity: 'bad', text: `Remediate ${agg.criticalCount} critical patch/vulnerability finding${agg.criticalCount === 1 ? '' : 's'} (see per-device detail).` }); } - if (c.backupConfigured === false) { + if (c.backupRequired !== false && c.backupConfigured === false) { recs.push({ severity: 'bad', text: 'Configure backups — no backup solution is currently detected for this organization.' }); } if (p.mfaStepUpEnforced === false) { @@ -930,6 +941,7 @@ const OS_LABELS: Record = { }; const PRODUCT_CATEGORY_LABELS: Record = { + antivirus: 'Antivirus', edr: 'EDR', mdr: 'MDR', dns_filtering: 'DNS filtering', @@ -937,6 +949,65 @@ const PRODUCT_CATEGORY_LABELS: Record = { identity: 'Identity', }; +const POSTURE_PRODUCT_ROW_HEIGHT = 5.2; +const POSTURE_PRODUCT_CONTINUATION_SPACE = 4; + +function drawPostureProductRow(doc: jsPDF, product: PostureProduct, y: number): number { + const catLabel = PRODUCT_CATEGORY_LABELS[product.category] ?? product.category; + // Skip the category tag when the product name already conveys it + // (avoids "Backup (local) (Backup)"). + const cat = product.product.toLowerCase().includes(catLabel.toLowerCase()) ? '' : ` (${catLabel})`; + const coverage = product.deviceCoverage != null + ? ` — ${product.deviceCoverage} device${product.deviceCoverage === 1 ? '' : 's'}` + : ''; + // Sync status is only interesting when it's a problem; success is machine + // noise on a client-facing page. + const syncOk = !product.lastSyncStatus || /^(ok|success|succeeded)$/i.test(product.lastSyncStatus); + const sync = syncOk ? '' : ` — sync ${product.lastSyncStatus}`; + const degraded = product.active === false ? ' — not reporting' : ''; + doc.setFont('helvetica', 'normal'); + doc.setFontSize(9); + set.fill(doc, product.active === false ? C.warning : C.success); + doc.circle(PAGE.mx + 1.4, y - 1.2, 1.2, 'F'); + set.text(doc, C.ink); + doc.text(`${product.product}${cat}${coverage}`, PAGE.mx + 5, y); + if (sync || degraded) { + const baseW = doc.getTextWidth(`${product.product}${cat}${coverage}`); + set.text(doc, C.warning); + doc.text(`${sync}${degraded}`, PAGE.mx + 5 + baseW, y); + } + return y + POSTURE_PRODUCT_ROW_HEIGHT; +} + +function renderPostureProductContinuation( + doc: jsPDF, + products: PostureProduct[], + opts: BuildOpts, +): void { + let remaining = [...products]; + while (remaining.length > 0) { + doc.addPage('a4', 'landscape'); + let y = drawTitleBlock( + doc, + 'Security products in use', + '', + 'Continued from the posture summary', + PAGE.bandH + 8, + ); + const rowsPerPage = Math.max( + 1, + Math.floor((PAGE.footY - 12 - y) / POSTURE_PRODUCT_ROW_HEIGHT), + ); + const pageProducts = remaining.slice(0, rowsPerPage); + for (const product of pageProducts) { + y = drawPostureProductRow(doc, product, y); + } + remaining = remaining.slice(pageProducts.length); + drawHeaderBand(doc, opts); + drawFooter(doc, opts); + } +} + /** Colour for a posture body cell, keyed by column. null = inherit default ink. */ function postureCellColor(key: string, raw: unknown): RGB | null { const s = typeof raw === 'string' ? raw.trim().toLowerCase() : ''; @@ -1213,10 +1284,11 @@ export function buildReportPdf(rows: unknown[], opts: BuildOpts): jsPDF { }, { criticalCount: 0, unprotectedCount: 0 }, ); - renderPostureCover(doc, opts.summary as PostureSummary, opts, agg); + const overflowProducts = renderPostureCover(doc, opts.summary as PostureSummary, opts, agg); // Draw chrome on the cover page (no autotable runs on it). drawHeaderBand(doc, opts); drawFooter(doc, opts); + renderPostureProductContinuation(doc, overflowProducts, opts); if (records.length > 0) { renderPostureTable(doc, records, opts); } diff --git a/packages/shared/src/types/postureReport.ts b/packages/shared/src/types/postureReport.ts index 2130d9b723..c1c52ee277 100644 --- a/packages/shared/src/types/postureReport.ts +++ b/packages/shared/src/types/postureReport.ts @@ -13,7 +13,13 @@ * many in-scope devices lacked the data for that control. */ -export type PostureProductCategory = 'edr' | 'mdr' | 'dns_filtering' | 'backup' | 'identity'; +export type PostureProductCategory = + | 'antivirus' + | 'edr' + | 'mdr' + | 'dns_filtering' + | 'backup' + | 'identity'; export type PostureProduct = { product: string; @@ -41,6 +47,7 @@ export type PostureControls = { cisAssessedCount?: number; /** Proves an identity provider is CONNECTED, not that MFA is enforced. */ identityProviderConnected?: boolean; + backupRequired?: boolean; backupConfigured?: boolean; backupEncrypted?: boolean | null; dnsFilteringActive?: boolean;