import { test, before, after, beforeEach } from 'node:test' import assert from 'node:assert/strict' import http from 'node:http' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import crypto from 'node:crypto' import { startFakeAppwrite } from './helpers/fakeAppwrite.js' let aw, runner, gitea, tmp, pd, config, hostsFile, envLib const key = crypto.randomBytes(32) const BUILT = 'c'.repeat(40) const HEAD = 'd'.repeat(40) const puts = [] let giteaDown = false let runnerFails = false before(async () => { aw = await startFakeAppwrite({ seed: { websiteProjects: [] } }) runner = http.createServer((req, res) => { let raw = '' req.on('data', (c) => (raw += c)) req.on('end', () => { res.setHeader('Content-Type', 'application/json') if (req.method === 'PUT' && req.url.startsWith('/dev/')) { puts.push({ url: req.url, body: JSON.parse(raw) }) res.statusCode = runnerFails ? 500 : 200 return res.end(JSON.stringify(runnerFails ? { error: 'kaputt' } : { state: 'sleeping' })) } res.statusCode = 404 res.end('{}') }) }) await new Promise((r) => runner.listen(0, '127.0.0.1', r)) gitea = http.createServer((req, res) => { res.setHeader('Content-Type', 'application/json') giteaHits++ if (giteaDown) { res.statusCode = 500; return res.end('{}') } res.end(JSON.stringify({ commit: { id: HEAD } })) }) await new Promise((r) => gitea.listen(0, '127.0.0.1', r)) tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'preview-dev-')) await fs.mkdir(path.join(tmp, 'previews', 'statisch'), { recursive: true }) await fs.mkdir(path.join(tmp, 'previews', 'offen'), { recursive: true }) hostsFile = path.join(tmp, 'dynamic', 'preview-hosts.yml') Object.assign(process.env, { APPWRITE_ENDPOINT: aw.endpoint, APPWRITE_API_KEY: 'test-key', PREVIEW_DEPLOY_ROOT: path.join(tmp, 'previews'), TRAEFIK_DYNAMIC_DIR: path.join(tmp, 'dynamic'), GITEA_BASE_URL: `http://127.0.0.1:${gitea.address().port}`, GITEA_API_TOKEN: 'tok', PREVIEW_RUNNER_URL: `http://127.0.0.1:${runner.address().port}`, PREVIEW_RUNNER_TOKEN: 'sekret', PREVIEW_ENV_KEY: key.toString('base64'), }) ;({ config } = await import('../server/config.js')) pd = await import('../server/services/previewDeploy.js') envLib = await import('../server/services/previewEnv.js') const { createDocument } = await import('../server/services/appwriteAdmin.js') const base = { previewMode: 'auto' } await createDocument('websiteProjects', { ...base, subdomain: 'offen', isPublic: true, previewState: 'online', previewDetected: 'static' }, 'p1') await createDocument('websiteProjects', { ...base, subdomain: 'statisch', previewState: 'online', previewDetected: 'build:npm' }, 'p2') await createDocument('websiteProjects', { ...base, subdomain: 'devapp', repoFullName: 'JUSN/devapp', previewState: 'sleeping', previewDetected: 'dev:npm', previewBuiltSha: BUILT, previewBranch: 'feature-x', previewEnv: envLib.encryptEnv({ API_KEY: 'geheim-wert' }, key), }, 'p3') await createDocument('websiteProjects', { ...base, subdomain: 'devoff', repoFullName: 'JUSN/devoff', previewState: 'failed', previewDetected: 'dev:npm', }, 'p4') }) after(async () => { await new Promise((r) => runner.close(r)) await new Promise((r) => gitea.close(r)) await aw.close() await fs.rm(tmp, { recursive: true, force: true }) }) beforeEach(() => { puts.length = 0 giteaDown = false runnerFails = false config.previewRunner.enabled = true }) const read = () => fs.readFile(hostsFile, 'utf8') // Erwartete Datei von VOR Task 10 (Schalter aus): zwei Ordner, 'offen' oeffentlich const block = (name, isPublic) => [ ` preview-${name}-https:`, ` rule: "Host(\`${name}.project.webklar.com\`)"`, ' entryPoints:', ' - websecure', ' service: preview-nginx@docker', ...(isPublic ? [] : [' middlewares:', ' - preview-chain@docker']), ' priority: 100', ' tls:', ' certResolver: letsencrypt', '', ` preview-${name}-http:`, ` rule: "Host(\`${name}.project.webklar.com\`)"`, ' entryPoints:', ' - web', ' service: preview-nginx@docker', ' middlewares:', ' - preview-sites-redirect@docker', ' priority: 100', ].join('\n') const LEGACY = [ '# Auto-generated by previewDeploy.syncPreviewTls - do not edit manually', 'http:', ' routers:', block('offen', true), block('statisch', false), '', ].join('\n') const routerBlock = (content, name) => { const start = content.indexOf(` preview-${name}-https:`) assert.ok(start >= 0, `Router ${name} fehlt`) const rest = content.slice(start + 1) const next = rest.search(/\n {4}preview-[a-z0-9-]+-https:/) return next < 0 ? content.slice(start) : content.slice(start, start + 1 + next) } test('Schalter aus: Datei byte-identisch wie bisher, keine Dev-Hosts, keine Registrierung', async () => { config.previewRunner.enabled = false const result = await pd.syncPreviewTls() assert.deepEqual(result, { synced: true, hosts: 2, publicSource: 'appwrite' }) assert.equal(await read(), LEGACY) await pd.waitForDevRegistration() assert.equal(puts.length, 0) }) test('Dev-Projekt bekommt Runner-Service (auch ohne Ordner), static bleibt nginx', async () => { await pd.syncPreviewTls() await pd.waitForDevRegistration() const content = await read() const dev = routerBlock(content, 'devapp') assert.equal((dev.match(/service: preview-runner@docker/g) || []).length, 2) // HTTPS + HTTP assert.match(dev, /preview-chain@docker/) // nicht oeffentlich => geschuetzt assert.match(dev, /preview-sites-redirect@docker/) assert.match(routerBlock(content, 'statisch'), /preview-nginx@docker/) assert.doesNotMatch(routerBlock(content, 'statisch'), /preview-runner/) // I5: auch ein fehlgeschlagener/neu bauender Dev-Host bleibt beim Runner (kein Umschalten auf nginx) assert.match(routerBlock(content, 'devoff'), /preview-runner@docker/) assert.deepEqual([...pd.devSubdomainsFromHostsFile(content)].sort(), ['devapp', 'devoff']) }) test('Appwrite nicht erreichbar: Dev-Hosts aus der letzten Datei bleiben', async () => { await pd.syncPreviewTls() await pd.waitForDevRegistration() aw.failNext({ method: 'GET', pathIncludes: 'documents', status: 500 }) // getPublicSubdomains aw.failNext({ method: 'GET', pathIncludes: 'documents', status: 500 }) // getDevProjects const errors = console.error console.error = () => {} try { const result = await pd.syncPreviewTls() assert.equal(result.publicSource, 'previous-file') } finally { console.error = errors } await pd.waitForDevRegistration() assert.deepEqual([...pd.devSubdomainsFromHostsFile(await read())].sort(), ['devapp', 'devoff']) assert.match(routerBlock(await read(), 'devapp'), /preview-runner@docker/) assert.equal(puts.length, 1) // nur die Runde mit Appwrite-Daten }) test('devSubdomainsFromHostsFile: nur HTTPS-Router mit Runner-Service', () => { const content = [ 'http:', ' routers:', ' preview-a-https:', ' service: preview-runner@docker', ' preview-a-http:', ' service: preview-runner@docker', ' preview-b-https:', ' service: preview-nginx@docker', ' preview-c-d-https:', ' rule: x', ' service: preview-runner@docker', ].join('\n') assert.deepEqual([...pd.devSubdomainsFromHostsFile(content)].sort(), ['a', 'c-d']) assert.equal(pd.devSubdomainsFromHostsFile('').size, 0) assert.equal(pd.devSubdomainsFromHostsFile(undefined).size, 0) }) test('Registrierung: Runner bekommt Spec mit entschluesselten Variablen', async () => { await pd.syncPreviewTls() await pd.waitForDevRegistration() assert.equal(puts.length, 1) assert.equal(puts[0].url, '/dev/devapp') assert.deepEqual(puts[0].body, { sub: 'devapp', repo: 'JUSN/devapp', sha: BUILT, branch: 'feature-x', mode: 'dev', env: { API_KEY: 'geheim-wert' }, }) }) test('Registrierung: nicht entschluesselbare Variablen (Schluessel fehlt) => uebersprungen', async () => { const saved = process.env.PREVIEW_ENV_KEY delete process.env.PREVIEW_ENV_KEY const errors = console.error console.error = () => {} try { await pd.syncPreviewTls() await pd.waitForDevRegistration() } finally { process.env.PREVIEW_ENV_KEY = saved; console.error = errors } assert.equal(puts.length, 0) assert.match(await read(), /preview-runner@docker/) }) test('Registrierung: Runner-Fehler bricht den Abgleich nicht, Datei ist geschrieben', async () => { runnerFails = true await fs.rm(hostsFile, { force: true }) const errors = console.error console.error = () => {} try { const result = await pd.syncPreviewTls() assert.equal(result.synced, true) await pd.waitForDevRegistration() } finally { console.error = errors } assert.equal(puts.length, 1) assert.match(await read(), /preview-runner@docker/) }) let giteaHits = 0 test('Registrierung: fehlender Stand oder Branch => uebersprungen, kein Gitea-Aufruf', async () => { const { updateDocument } = await import('../server/services/appwriteAdmin.js') const errors = console.error console.error = () => {} try { for (const patch of [{ previewBuiltSha: '' }, { previewBranch: '' }]) { await updateDocument('websiteProjects', 'p3', patch) giteaHits = 0 puts.length = 0 await pd.syncPreviewTls() await pd.waitForDevRegistration() assert.equal(puts.length, 0) assert.equal(giteaHits, 0) assert.match(await read(), /preview-runner@docker/) await updateDocument('websiteProjects', 'p3', { previewBuiltSha: BUILT, previewBranch: 'feature-x' }) } } finally { console.error = errors } }) test('Registrierung: kaputte verschluesselte Variablen => uebersprungen', async () => { const { updateDocument } = await import('../server/services/appwriteAdmin.js') const good = envLib.encryptEnv({ API_KEY: 'geheim-wert' }, key) await updateDocument('websiteProjects', 'p3', { previewEnv: 'v1:xx:yy:zz' }) const errors = console.error console.error = () => {} try { await pd.syncPreviewTls() await pd.waitForDevRegistration() } finally { console.error = errors await updateDocument('websiteProjects', 'p3', { previewEnv: good }) } assert.equal(puts.length, 0) }) test('Registrierung: laeuft eine Runde, folgt genau eine Wiederholung', async () => { await Promise.all([pd.syncPreviewTls(), pd.syncPreviewTls(), pd.syncPreviewTls()]) await pd.waitForDevRegistration() assert.equal(puts.length, 2) }) test('I5: Dev-Projekt im Zustand building bleibt beim Runner, static im Zustand building nicht', async () => { const { createDocument } = await import('../server/services/appwriteAdmin.js') await createDocument('websiteProjects', { previewMode: 'auto', subdomain: 'baut', repoFullName: 'JUSN/baut', previewState: 'building', previewDetected: 'dev:npm' }, 'p5') await createDocument('websiteProjects', { previewMode: 'auto', subdomain: 'baustatic', repoFullName: 'JUSN/baustatic', previewState: 'building', previewDetected: 'static' }, 'p6') await pd.syncPreviewTls() await pd.waitForDevRegistration() const content = await read() assert.match(routerBlock(content, 'baut'), /preview-runner@docker/) assert.ok(!pd.devSubdomainsFromHostsFile(content).has('baustatic')) })