/** * Per-source custom CA bundle: end-to-end through the product boundary. * * Drives the full chain - API PUT (encrypted at rest) -> API GET (project * exposes has_ca_bundle, never the PEM) -> real HTTPS fetch against a * locally-served fixture repo -> API PUT with remove_ca_bundle=true -> * API GET confirming the stored PEM was cleared -> a fetch that must now * fail on TLS trust. * * This fixture server presents a certificate signed by a SEPARATE CA that * nothing else trusts: it is not the shared dev/E2E CA, so it is absent from * the backend's NODE_EXTRA_CA_CERTS and from system trust. That isolation is * the whole point of the spec. The stored per-source bundle is then the only * thing that can make the fetch succeed, so the test fails if the CA ever * stops reaching native git, and removing it must produce a real trust * failure rather than a result the assertion tolerates. */ import { test, expect } from '@playwright/test'; import fs from 'fs'; import path from 'path'; import { loginAs } from './helpers'; import { gitAvailable, buildFixtureRepo, serveRepos } from './gitServer.helper'; const CA_PEM = fs.readFileSync( path.join(process.cwd(), 'e2e', 'fixtures', 'git-private-ca.pem'), 'utf8', ); const APP_FILES = { 'compose.yaml': 'services:\n x:\n image: nginx\n', }; test.describe('Git Sources per-source CA bundle (product boundary)', () => { test.skip(!gitAvailable(), 'system git binary is not available'); let server: { url: string; close: () => void }; let stackName: string; test.beforeAll(async () => { server = await serveRepos({ app: buildFixtureRepo(APP_FILES), }, 'git-private-server'); }); test.afterAll(() => { server?.close(); }); test.beforeEach(async () => { stackName = `e2e-ca-${Date.now()}`; }); test.afterEach(async ({ page }) => { await page.evaluate(async (name) => { await fetch(`/api/stacks/${name}/git-source`, { method: 'DELETE', credentials: 'include' }).catch(() => {}); await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => {}); }, stackName); }); test('API stores encrypted CA, exposes has_ca_bundle, never returns PEM, and explicit remove clears it', async ({ page }) => { await loginAs(page); await page.evaluate(async (name) => { const res = await fetch('/api/stacks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ stackName: name }), }); if (res.status !== 200) throw new Error(`create stack failed: ${res.status}`); }, stackName); const repoUrl = `${server.url}/app.git`; // Step 1: PUT with a per-source CA bundle. const putRes = await page.evaluate(async ({ name, repoUrl, pem }) => { const res = await fetch(`/api/stacks/${name}/git-source`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ repo_url: repoUrl, branch: 'main', compose_paths: ['compose.yaml'], auth_type: 'none', ca_bundle: pem, auto_apply_on_webhook: false, auto_deploy_on_apply: false, }), }); return { status: res.status, body: await res.json() }; }, { name: stackName, repoUrl, pem: CA_PEM }); expect(putRes.status).toBe(200); expect(putRes.body.has_ca_bundle).toBe(true); // The PEM must not appear anywhere in the PUT response. expect(JSON.stringify(putRes.body)).not.toContain('BEGIN CERTIFICATE'); // Step 2: GET confirms the persisted state and still hides the PEM. const getRes = await page.evaluate(async (name) => { const res = await fetch(`/api/stacks/${name}/git-source`, { credentials: 'include' }); return { status: res.status, body: await res.json() }; }, stackName); expect(getRes.status).toBe(200); expect(getRes.body.has_ca_bundle).toBe(true); expect(JSON.stringify(getRes.body)).not.toContain('BEGIN CERTIFICATE'); // Step 3: a real fetch against the per-source-CA-configured repo // succeeds. This exercises the full product boundary: encrypted row // -> decryption -> combined CA file -> real git fetch. const pull = await page.evaluate(async (name) => { const res = await fetch(`/api/stacks/${name}/git-source/pull`, { method: 'POST', credentials: 'include', }); return { status: res.status, body: await res.json() }; }, stackName); expect(pull.status, JSON.stringify(pull.body)).toBe(200); expect(pull.body.candidateReady).toBe(true); expect(pull.body.commitSha).toMatch(/^[0-9a-f]{40}$/); // Step 4: explicit revocation. The textarea is left empty, the UI // sends remove_ca_bundle: true. The stored CA must be cleared. const revokeRes = await page.evaluate(async ({ name, repoUrl }) => { const res = await fetch(`/api/stacks/${name}/git-source`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ repo_url: repoUrl, branch: 'main', compose_paths: ['compose.yaml'], auth_type: 'none', remove_ca_bundle: true, auto_apply_on_webhook: false, auto_deploy_on_apply: false, }), }); return { status: res.status, body: await res.json() }; }, { name: stackName, repoUrl }); expect(revokeRes.status).toBe(200); expect(revokeRes.body.has_ca_bundle).toBe(false); // Step 5: GET confirms the row no longer carries a CA bundle. const afterRes = await page.evaluate(async (name) => { const res = await fetch(`/api/stacks/${name}/git-source`, { credentials: 'include' }); return { status: res.status, body: await res.json() }; }, stackName); expect(afterRes.status).toBe(200); expect(afterRes.body.has_ca_bundle).toBe(false); // Step 6: with the stored CA gone, the same fetch must now fail on // certificate trust. Nothing else in the environment trusts this // fixture's CA, so a success here would mean the per-source bundle was // never what authorised the earlier fetch. const afterPull = await page.evaluate(async (name) => { const res = await fetch(`/api/stacks/${name}/git-source/pull`, { method: 'POST', credentials: 'include', }); return { status: res.status, body: await res.json() }; }, stackName); expect(afterPull.status, JSON.stringify(afterPull.body)).not.toBe(200); expect(JSON.stringify(afterPull.body)).toContain('TLS certificate error reaching'); }); test('API rejects a non-PEM ca_bundle with 400', async ({ page }) => { await loginAs(page); await page.evaluate(async (name) => { const res = await fetch('/api/stacks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ stackName: name }), }); if (res.status !== 200) throw new Error(`create stack failed: ${res.status}`); }, stackName); const repoUrl = `${server.url}/app.git`; const reject = await page.evaluate(async ({ name, repoUrl }) => { const res = await fetch(`/api/stacks/${name}/git-source`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ repo_url: repoUrl, branch: 'main', compose_paths: ['compose.yaml'], auth_type: 'none', ca_bundle: 'not a certificate at all', auto_apply_on_webhook: false, auto_deploy_on_apply: false, }), }); return { status: res.status, body: await res.json() }; }, { name: stackName, repoUrl }); expect(reject.status).toBe(400); expect(String(reject.body.error || '')).toMatch(/PEM|certificate/i); }); });