diff --git a/backend/src/__tests__/community-deploy-policy-route.test.ts b/backend/src/__tests__/community-deploy-policy-route.test.ts new file mode 100644 index 00000000..436ca765 --- /dev/null +++ b/backend/src/__tests__/community-deploy-policy-route.test.ts @@ -0,0 +1,134 @@ +/** + * Community deploy-policy route regressions: a matching block_on_deploy policy + * must hard-block via the stack deploy route on Community (local session and + * trusted node_proxy with x-sencho-tier: community), returning the established + * 409 payload and never calling deployStack. + */ +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest'; +import request, { type Response } from 'supertest'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin, TEST_JWT_SECRET } from './helpers/setupTestDb'; +import { PROXY_TIER_HEADER } from '../services/license-headers'; + +vi.mock('../services/FileSystemService', () => ({ + FileSystemService: { + getInstance: () => ({ + getStacks: vi.fn().mockResolvedValue([]), + getBaseDir: () => '/tmp/compose', + readComposeFile: vi.fn().mockResolvedValue(''), + hasComposeFile: vi.fn().mockResolvedValue(true), + }), + }, +})); + +let tmpDir: string; +let app: import('express').Express; +let authCookie: string; +let listImagesSpy: ReturnType; +let deploySpy: ReturnType; +let trivyAvailableSpy: ReturnType; +let scanSpy: ReturnType; + +function expectPolicyHardBlock(res: Response): void { + expect(res.status).toBe(409); + expect(res.body.error).toContain('community-block-critical'); + expect(res.body.policy).toMatchObject({ name: 'community-block-critical' }); + expect(res.body.violations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ imageRef: 'nginx:bad' }), + ]), + ); + expect(scanSpy).toHaveBeenCalled(); + expect(deploySpy).not.toHaveBeenCalled(); +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + authCookie = await loginAsTestAdmin(app); + + const { ComposeService } = await import('../services/ComposeService'); + listImagesSpy = vi.spyOn(ComposeService.prototype, 'listStackImages').mockResolvedValue(['nginx:bad']); + deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockResolvedValue(undefined); + + const TrivyService = (await import('../services/TrivyService')).default; + const trivy = TrivyService.getInstance(); + trivyAvailableSpy = vi.spyOn(trivy, 'isTrivyAvailable').mockReturnValue(true); + scanSpy = vi.spyOn(trivy, 'scanImagePreflight').mockResolvedValue({ + id: 42, + node_id: 1, + image_ref: 'nginx:bad', + image_digest: null, + scanned_at: Date.now(), + total_vulnerabilities: 1, + critical_count: 1, + high_count: 0, + medium_count: 0, + low_count: 0, + unknown_count: 0, + fixable_count: 0, + secret_count: 0, + misconfig_count: 0, + scanners_used: 'vuln', + highest_severity: 'CRITICAL', + os_info: null, + trivy_version: '0.50.0', + scan_duration_ms: null, + triggered_by: 'deploy-preflight', + status: 'completed', + error: null, + stack_context: 'community-block-app', + policy_evaluation: null, + }); + + const { DatabaseService } = await import('../services/DatabaseService'); + DatabaseService.getInstance().createScanPolicy({ + name: 'community-block-critical', + node_id: null, + node_identity: '', + stack_pattern: 'community-block-*', + max_severity: 'HIGH', + block_on_deploy: 1, + block_on_severity: 1, + block_on_kev: 0, + block_on_fixable: 0, + enabled: 1, + replicated_from_control: 0, + }); +}); + +afterAll(() => { + listImagesSpy.mockRestore(); + deploySpy.mockRestore(); + trivyAvailableSpy.mockRestore(); + scanSpy.mockRestore(); + vi.restoreAllMocks(); + cleanupTestDb(tmpDir); +}); + +beforeEach(() => { + deploySpy.mockClear(); + listImagesSpy.mockClear(); + scanSpy.mockClear(); +}); + +describe('Community deploy policy hard-block (route)', () => { + it('returns 409 and skips deployStack for a local Community session', async () => { + const res = await request(app) + .post('/api/stacks/community-block-app/deploy') + .set('Cookie', authCookie); + + expectPolicyHardBlock(res); + }); + + it('returns 409 and skips deployStack for trusted node_proxy Community tier', async () => { + const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); + + const res = await request(app) + .post('/api/stacks/community-block-proxy/deploy') + .set('Authorization', `Bearer ${token}`) + .set(PROXY_TIER_HEADER, 'community'); + + expectPolicyHardBlock(res); + }); +}); diff --git a/backend/src/__tests__/git-source-service.test.ts b/backend/src/__tests__/git-source-service.test.ts index 0396f226..56291127 100644 --- a/backend/src/__tests__/git-source-service.test.ts +++ b/backend/src/__tests__/git-source-service.test.ts @@ -1216,12 +1216,10 @@ describe('GitSourceService.apply', () => { const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); const { FileSystemService } = await import('../services/FileSystemService'); const { ComposeService } = await import('../services/ComposeService'); - const { LicenseService } = await import('../services/LicenseService'); const TrivyService = (await import('../services/TrivyService')).default; const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockResolvedValue(); const listImagesSpy = vi.spyOn(ComposeService.prototype, 'listStackImages').mockResolvedValue(['nginx:bad']); const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockResolvedValue(); - const tierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); const trivy = TrivyService.getInstance(); const trivyAvailableSpy = vi.spyOn(trivy, 'isTrivyAvailable').mockReturnValue(true); const scanSpy = vi.spyOn(trivy, 'scanImagePreflight').mockResolvedValue({ @@ -1267,13 +1265,13 @@ describe('GitSourceService.apply', () => { expect(result.applied).toBe(true); expect(result.deployed).toBe(false); expect(result.deployError).toContain('Policy "block-high" blocked deploy'); + expect(scanSpy).toHaveBeenCalled(); expect(deploySpy).not.toHaveBeenCalled(); validateSpy.mockRestore(); saveSpy.mockRestore(); listImagesSpy.mockRestore(); deploySpy.mockRestore(); - tierSpy.mockRestore(); trivyAvailableSpy.mockRestore(); scanSpy.mockRestore(); }); diff --git a/backend/src/__tests__/policy-enforcement.test.ts b/backend/src/__tests__/policy-enforcement.test.ts index 5d9cf2c4..c3ec63fe 100644 --- a/backend/src/__tests__/policy-enforcement.test.ts +++ b/backend/src/__tests__/policy-enforcement.test.ts @@ -175,21 +175,6 @@ describe('enforcePolicyPreDeploy', () => { expect(composeStub.listStackImages).not.toHaveBeenCalled(); }); - it('allows deploy without scanning when paid-tier blocking is disabled', async () => { - dbStub.getMatchingPolicy.mockReturnValue(mkPolicy()); - - const result = await enforcePolicyPreDeploy('web', 1, { - bypass: false, - actor: 'u', - blockingEnabled: false, - }); - - expect(result.ok).toBe(true); - expect(result.violations).toEqual([]); - expect(trivyStub.isTrivyAvailable).not.toHaveBeenCalled(); - expect(composeStub.listStackImages).not.toHaveBeenCalled(); - }); - it('fails open with a warning alert when Trivy is not installed', async () => { dbStub.getMatchingPolicy.mockReturnValue(mkPolicy()); trivyStub.isTrivyAvailable.mockReturnValue(false); diff --git a/backend/src/helpers/policyGate.ts b/backend/src/helpers/policyGate.ts index 1abd1aaf..dba9da17 100644 --- a/backend/src/helpers/policyGate.ts +++ b/backend/src/helpers/policyGate.ts @@ -5,8 +5,6 @@ import { DatabaseService } from '../services/DatabaseService'; import type { ScanPolicy } from '../services/DatabaseService'; import { NotificationService } from '../services/NotificationService'; import TrivyService, { DIGEST_CACHE_TTL_MS } from '../services/TrivyService'; -import { LicenseService } from '../services/LicenseService'; -import { effectiveTier } from '../middleware/tierGates'; import { getErrorMessage } from '../utils/errors'; import { sanitizeForLog } from '../utils/safeLog'; import { summarizeBlockReasons } from '../utils/policy-risk'; @@ -38,7 +36,6 @@ export function buildPolicyGateOptions( return { bypass: overrides.bypass ?? defaultBypass, actor: overrides.actor ?? req.user?.username ?? 'unknown', - blockingEnabled: effectiveTier(req) === 'paid', ip: (req.ip ?? req.socket.remoteAddress ?? '') as string, auditMethod: req.method, auditPath: req.originalUrl || req.url, @@ -47,12 +44,11 @@ export function buildPolicyGateOptions( export function buildSystemPolicyGateOptions( actor: string, - overrides: { bypass?: boolean; blockingEnabled?: boolean; auditPath?: string; auditMethod?: string } = {}, + overrides: { bypass?: boolean; auditPath?: string; auditMethod?: string } = {}, ): PolicyEnforcementOptions { return { bypass: overrides.bypass ?? false, actor, - blockingEnabled: overrides.blockingEnabled ?? LicenseService.getInstance().getTier() === 'paid', auditMethod: overrides.auditMethod ?? 'POST', auditPath: overrides.auditPath, }; diff --git a/backend/src/services/PolicyEnforcement.ts b/backend/src/services/PolicyEnforcement.ts index dbee1241..c14df60d 100644 --- a/backend/src/services/PolicyEnforcement.ts +++ b/backend/src/services/PolicyEnforcement.ts @@ -53,11 +53,6 @@ export interface PolicyViolation { export interface PolicyEnforcementOptions { bypass: boolean; actor: string; - /** - * Paid-tier deploy enforcement switch. Community keeps policies as - * evaluation-only and must not block compose starts. - */ - blockingEnabled?: boolean; ip?: string; /** HTTP method of the originating request; used for audit attribution. */ auditMethod?: string; @@ -283,10 +278,6 @@ export async function enforcePolicyPreDeploy( return { ok: true, bypassed: false, policy: policy ?? undefined, violations: [] }; } - if (opts.blockingEnabled === false) { - return { ok: true, bypassed: false, policy, violations: [] }; - } - const svc = TrivyService.getInstance(); if (!svc.isTrivyAvailable()) { notifyTrivyMissingOnce(nodeId, stackName); diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index 8e124143..b2ad6d37 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -184,8 +184,7 @@ export class SchedulerService { * and the offending images, then throw so the caller records the outcome: * the auto-update loop catches per stack and continues the rest of the run, * while a single-stack auto-start surfaces as a task failure. The gate - * fails open when Trivy is missing and is evaluation-only when the node's - * local tier is unpaid. + * fails open when Trivy is missing. */ private async enforceSchedulerPolicyGate( stackName: string,