import { describe, expect, test } from 'bun:test'; import { readFileSync } from 'node:fs'; import { BASELINE_RELEASE_CAPABILITIES, capabilityEnabled, parseBackendCapabilityAllowlist, parseCapabilityAllowlist, releaseIdIsImmutable, } from '../src/lib/server/release-gate'; import { releaseSafetyErrors } from '../scripts/release-check'; describe('deployment rollout gate', () => { test('keeps the existing private baseline and defaults new capabilities off', () => { const baseline = parseCapabilityAllowlist(undefined); expect(baseline).toEqual(BASELINE_RELEASE_CAPABILITIES); expect(capabilityEnabled('asset_search', baseline)).toBe(true); expect(capabilityEnabled('wallet_balance', baseline)).toBe(false); expect(capabilityEnabled('subscriptions', baseline)).toBe(true); }); test('supports explicit narrowing and rejects unknown or empty names', () => { expect(parseCapabilityAllowlist('asset_search, asset_search,session_token')).toEqual(['asset_search', 'session_token']); expect(parseCapabilityAllowlist('subscriptions')).toEqual(['subscriptions']); expect(parseCapabilityAllowlist('')).toEqual([]); expect(() => parseCapabilityAllowlist('asset_search,typo')).toThrow('Unknown deployment capability'); expect(() => parseCapabilityAllowlist('asset_search,')).toThrow('Unknown deployment capability'); expect(parseBackendCapabilityAllowlist('asset_sync')).toEqual(['asset_sync']); expect(() => parseBackendCapabilityAllowlist(undefined)).toThrow('required in the backend deployment'); }); test('requires a non-placeholder production release identifier', () => { expect(releaseIdIsImmutable('local', 'production')).toBe(false); expect(releaseIdIsImmutable('abc1234', 'production')).toBe(true); expect(releaseIdIsImmutable('latest', 'production')).toBe(false); expect(releaseIdIsImmutable('local', 'development')).toBe(true); }); test('release check catches unsafe production inputs without printing values', () => { const errors = releaseSafetyErrors({ mode: 'demo', releaseId: 'local', releaseEnvironment: 'production', dashboardImage: 'convex-dashboard:latest', convexImage: 'convex-backend:latest', dockerfile: 'FROM oven/bun:latest', capabilityAllowlist: 'wallet_balance,unknown', trackedFiles: ['.env.example', 'secrets/app.pem'], browserBundleFiles: ['build/client/app.js'], browserBundlePresent: false, }); expect(errors).toEqual(expect.arrayContaining([ 'APP_RELEASE_ID must be a non-placeholder immutable identifier for this release environment', 'CONVEX_DASHBOARD_IMAGE must be digest-pinned for staging/production', 'CONVEX_IMAGE must be digest-pinned for staging/production', 'Dockerfile base images must be digest-pinned for staging/production', 'APP_MODE=demo is not permitted for a staging/production release', 'Unknown deployment capability: unknown', 'secret-like file is tracked: secrets/app.pem', 'browser client artifacts contain managed secret names', 'built browser client artifacts are required for staging/production release checks', ])); }); test('the repository Dockerfile passes strict base-image validation', () => { const errors = releaseSafetyErrors({ mode: 'live', releaseId: 'ci-abc1234', releaseEnvironment: 'production', dashboardImage: 'ghcr.io/get-convex/convex-dashboard@sha256:' + 'a'.repeat(64), convexImage: 'ghcr.io/get-convex/convex-backend@sha256:' + 'b'.repeat(64), dockerfile: readFileSync('Dockerfile', 'utf8'), capabilityAllowlist: undefined, trackedFiles: [], browserBundlePresent: true, }); expect(errors).toEqual([]); }); });