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 express from 'express' import { startFakeAppwrite } from './helpers/fakeAppwrite.js' let devScript, aw, runner, gitea, app, base, tmp, cfg, envLib, logs const key = crypto.randomBytes(32) const SHA = 'c'.repeat(40) const rs = { calls: [], busy: false, slow: false, result: 'static', failError: null } const gs = { pkg: undefined, status: 200 } // pkg: undefined = Standardantwort, null = 404, Objekt = package.json before(async () => { const seed = [ { $id: 'pw', projectName: 'Web', repoFullName: 'JUSN/web', subdomain: 'web', previewBranch: 'main', previewBuiltSha: SHA, previewState: 'online', previewDetected: 'static' }, { $id: 'pr', projectName: 'R', repoFullName: 'JUSN/race', subdomain: 'race', previewBranch: 'main', previewBuiltSha: SHA }, { $id: 'pe', projectName: 'E', repoFullName: 'JUSN/envp', subdomain: 'envp', previewBranch: 'main', previewBuiltSha: SHA }, { $id: 'pbad', projectName: 'B', repoFullName: 'JUSN/bad', subdomain: 'bad', previewEnv: 'v1:x:y:z' }, { $id: 'pns', projectName: 'N', repoFullName: 'JUSN/nosha', subdomain: 'nosha' }, { $id: 'pnosub', projectName: 'Ohne', repoFullName: 'JUSN/ohne' }, { $id: 'pa', projectName: 'App', repoFullName: 'JUSN/app', customerId: '', ticketId: '' }, { $id: 'pb', projectName: 'Web2', repoFullName: 'JUSN/web2', customerId: '', ticketId: '' }, { $id: 'pc', projectName: 'Web3', repoFullName: 'JUSN/web3', customerId: '', ticketId: '', previewMode: 'dev' }, ] aw = await startFakeAppwrite({ seed: { websiteProjects: seed, services: [] } }) runner = http.createServer((req, res) => { let raw = '' req.on('data', (c) => (raw += c)) req.on('end', () => { if (rs.busy && (req.url === '/jobs/build' || req.url.endsWith('/start'))) { res.statusCode = 503; res.setHeader('Content-Type', 'application/json'); return res.end(JSON.stringify({ error: 'Server ausgelastet' })) } rs.calls.push({ method: req.method, url: req.url, body: raw ? JSON.parse(raw) : null }) res.setHeader('Content-Type', 'application/json') if (req.method === 'POST' && req.url === '/jobs/build') return res.end(JSON.stringify({ runId: 'run-x' })) if (req.method === 'GET' && req.url.startsWith('/jobs/') && rs.failError) return res.end(JSON.stringify({ state: 'failed', result: null, log: [], error: rs.failError })) if (req.method === 'GET' && req.url.startsWith('/jobs/')) return res.end(JSON.stringify(rs.slow ? { state: 'running' } : { state: 'done', detected: { kind: 'static', pm: 'npm' }, sha: 'b'.repeat(40), log: ['ok'], error: null, result: rs.result })) if (req.url.endsWith('/start')) return res.end(JSON.stringify({ state: 'starting' })) res.end(JSON.stringify({ state: 'sleeping', ok: true })) }) }) await new Promise((r) => runner.listen(0, '127.0.0.1', r)) gitea = http.createServer((req, res) => { res.setHeader('Content-Type', 'application/json') if (req.url.startsWith('/api/v1/repos/') && req.url.includes('/contents/package.json') && gs.pkg !== undefined) { if (gs.status !== 200) { res.statusCode = gs.status; return res.end(JSON.stringify({ message: 'x' })) } if (gs.pkg === null) { res.statusCode = 404; return res.end(JSON.stringify({ message: 'not found' })) } return res.end(JSON.stringify({ content: Buffer.from(JSON.stringify(gs.pkg)).toString('base64') })) } res.end(JSON.stringify({ commit: { id: 'b'.repeat(40) }, default_branch: 'main' })) }) await new Promise((r) => gitea.listen(0, '127.0.0.1', r)) tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'preview-admin-')) Object.assign(process.env, { APPWRITE_ENDPOINT: aw.endpoint, APPWRITE_API_KEY: 'test-key', 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_DEPLOY_ROOT: path.join(tmp, 'previews'), TRAEFIK_DYNAMIC_DIR: path.join(tmp, 'dynamic'), PREVIEW_ENV_KEY: key.toString('base64'), }) ;({ config: cfg } = await import('../server/config.js')) cfg.previewRunner.enabled = true cfg.previewRunner.logDir = path.join(tmp, 'logs') envLib = await import('../server/services/previewEnv.js') logs = await import('../server/services/previewLogs.js') devScript = await import('../server/services/previewDevScript.js') const pd = await import('../server/services/previewDeploy.js') pd.runnerTiming.pollMs = 10 const { default: preview } = await import('../server/routes/admin/preview.js') const { default: wp } = await import('../server/routes/admin/websiteProjects.js') app = express() app.use(express.json({ limit: '2mb' })) app.use('/api/admin/website-projects', preview) app.use('/api/admin/website-projects', wp) const server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)) }) app.server = server base = `http://127.0.0.1:${server.address().port}/api/admin/website-projects` }) after(async () => { // Hintergrund-Verfolger ausklingen lassen, bevor der Fake-Runner schliesst for (let i = 0; i < 100; i++) { const building = ['pw', 'pa', 'pb', 'pc', 'pr', 'pe'].some((id) => aw.get('websiteProjects', id)?.previewState === 'building') if (!building) break await new Promise((r) => setTimeout(r, 20)) } await new Promise((r) => setTimeout(r, 100)) for (const srv of [app.server, runner, gitea]) { srv.closeAllConnections(); await new Promise((r) => srv.close(r)) } await aw.close() await fs.rm(tmp, { recursive: true, force: true }) }) beforeEach(() => { gs.pkg = undefined; gs.status = 200; rs.failError = null; devScript.clearDevScriptCache(); rs.busy = false; rs.slow = false; rs.result = 'static'; rs.calls = []; cfg.previewRunner.enabled = true; process.env.PREVIEW_ENV_KEY = key.toString('base64') }) const call = (method, p, body, jwt = 'jwt-admin') => fetch(`${base}${p}`, { method, headers: { 'Content-Type': 'application/json', ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}) }, body: body === undefined || method === 'GET' ? undefined : JSON.stringify(body), }) test('ohne JWT => 401 auf allen Routen', async () => { for (const [m, p] of [['GET', '/pw/preview'], ['PATCH', '/pw/preview'], ['PUT', '/pw/preview/env'], ['POST', '/pw/preview/rebuild'], ['POST', '/pw/preview/dev/start'], ['POST', '/pw/preview/dev/stop'], ['GET', '/pw/preview/logs/x.log']]) { assert.equal((await call(m, p, {}, null)).status, 401, `${m} ${p}`) } }) test('GET: Form, leerer Modus = auto, 404 und keine Subdomain', async () => { const r = await call('GET', '/pw/preview') assert.equal(r.status, 200) assert.deepEqual(await r.json(), { mode: 'auto', state: 'online', detected: 'static', builtSha: SHA, builtAt: '', error: '', branch: 'main', envNames: [], logs: [], devAvailable: null, }) assert.equal((await call('GET', '/gibtsnicht/preview')).status, 404) const r2 = await call('GET', '/pnosub/preview') assert.equal(r2.status, 400) assert.equal((await r2.json()).error, 'Projekt hat keine Preview-Subdomain') }) test('PATCH: Modus ungueltig => 400; off stoppt Dev; sonst Neubau', async () => { assert.equal((await call('PATCH', '/pw/preview', { mode: 'foo' })).status, 400) assert.equal((await call('PATCH', '/pw/preview', {})).status, 400) const off = await call('PATCH', '/pw/preview', { mode: 'off' }) assert.equal(off.status, 200) const b = await off.json() assert.equal(b.mode, 'off') assert.equal(b.state, 'off') assert.ok(rs.calls.some((c) => c.url === '/dev/web/stop')) assert.equal(rs.calls.filter((c) => c.url === '/jobs/build').length, 0) rs.calls = [] const on = await call('PATCH', '/pw/preview', { mode: 'build' }) assert.equal(on.status, 200) assert.equal((await on.json()).mode, 'build') const sub = rs.calls.find((c) => c.url === '/jobs/build') assert.equal(sub.body.mode, 'build') assert.equal(sub.body.sub, 'web') assert.equal(aw.get('websiteProjects', 'pw').previewState, 'building') }) test('Route-Vorrang: PATCH /:id bleibt bei der Projektbearbeitung', async () => { const r = await call('PATCH', '/pw', { projectName: 'X' }) const b = await r.json() assert.equal(b.mode, undefined) // kein Preview-Handler assert.equal(b.ok !== undefined || b.error !== undefined, true) }) test('env: ohne Schluessel 503, ungueltig 400, Werte nie in Antworten', async () => { delete process.env.PREVIEW_ENV_KEY const r503 = await call('PUT', '/pw/preview/env', { set: { A: 'b' } }) assert.equal(r503.status, 503) assert.deepEqual(await r503.json(), { error: 'Preview-Variablen sind nicht eingerichtet' }) process.env.PREVIEW_ENV_KEY = key.toString('base64') const bad = await call('PUT', '/pw/preview/env', { set: { 'kleinbuchstabe': 'x' } }) assert.equal(bad.status, 400) assert.match((await bad.json()).error, /Ungültiger Variablenname/) const ok = await call('PUT', '/pw/preview/env', { set: { API_URL: 'geheim-wert-123', OTHER: 'zweiter-wert-456' } }) const text = await ok.text() assert.equal(ok.status, 200) assert.ok(!text.includes('geheim-wert-123') && !text.includes('zweiter-wert-456')) assert.deepEqual(JSON.parse(text).envNames, ['API_URL', 'OTHER']) const stored = aw.get('websiteProjects', 'pw').previewEnv assert.ok(stored.startsWith('v1:') && !stored.includes('geheim')) assert.equal(rs.calls.filter((c) => c.url === '/jobs/build').length, 1) const get = await (await call('GET', '/pw/preview')).text() assert.ok(!get.includes('geheim-wert-123')) const rem = await call('PUT', '/pw/preview/env', { remove: ['API_URL'] }) assert.deepEqual((await rem.json()).envNames, ['OTHER']) }) test('Protokolle: Liste, lesen, ../x => 400', async () => { const file = await logs.saveRunLog('pw', ['a', 'b'], {}) const list = await (await call('GET', '/pw/preview')).json() assert.ok(list.logs.some((l) => l.runFile === file)) const r = await call('GET', `/pw/preview/logs/${file}`) assert.deepEqual(await r.json(), { lines: ['a', 'b'] }) assert.equal((await call('GET', '/pw/preview/logs/..%2Fx')).status, 400) assert.equal((await call('GET', '/pw/preview/logs/x.log')).status, 400) }) test('Rebuild und Dev start/stop', async () => { const rb = await call('POST', '/pw/preview/rebuild') assert.equal(rb.status, 200) assert.equal(rs.calls.filter((c) => c.url === '/jobs/build').length, 1) rs.calls = [] const st = await call('POST', '/pw/preview/dev/start') assert.equal(st.status, 200) const start = rs.calls.find((c) => c.url === '/dev/web/start') assert.ok([SHA, 'b'.repeat(40)].includes(start.body.sha)) // gespeicherter Stand (je nach Verfolger) assert.deepEqual(start.body, { sub: 'web', repo: 'JUSN/web', sha: start.body.sha, branch: 'main', mode: 'dev', env: { OTHER: 'zweiter-wert-456' } }) assert.equal((await st.json()).state, 'starting') const stop = await call('POST', '/pw/preview/dev/stop') assert.equal((await stop.json()).state, 'sleeping') assert.ok(rs.calls.some((c) => c.url === '/dev/web/stop')) }) test('dev/start: oeffentliche Seite ausserhalb Modus dev => 409', async () => { const { createDocument } = await import('../server/services/appwriteAdmin.js') await createDocument('websiteProjects', { projectName: 'Pub', repoFullName: 'JUSN/pub', subdomain: 'pub', previewBranch: 'main', previewBuiltSha: SHA, previewState: 'online', previewDetected: 'static', isPublic: true, previewMode: 'auto' }, 'ppub') rs.calls = [] const r = await call('POST', '/ppub/preview/dev/start') assert.equal(r.status, 409) assert.deepEqual(await r.json(), { error: 'Öffentliche Seite: Dev-Server nur im Modus Dev-Server' }) assert.equal(rs.calls.filter((c) => c.url.startsWith('/dev/')).length, 0) }) test('Schalter aus: Schreib-Routen 409, GET geht', async () => { cfg.previewRunner.enabled = false for (const [m, p, b] of [['PATCH', '/pw/preview', { mode: 'auto' }], ['PUT', '/pw/preview/env', { set: {} }], ['POST', '/pw/preview/rebuild'], ['POST', '/pw/preview/dev/start'], ['POST', '/pw/preview/dev/stop']]) { const r = await call(m, p, b) assert.equal(r.status, 409, `${m} ${p}`) assert.deepEqual(await r.json(), { error: 'Preview-Runner ist nicht aktiv' }) } assert.equal((await call('GET', '/pw/preview')).status, 200) assert.equal(rs.calls.filter((c) => c.method !== 'GET').length, 0) }) test('Zuordnen: Vorgabe website => auto, andere => off, vorhandener Wert bleibt', async () => { const { completeAssignment } = await import('../server/services/projectKindAssign.js') const deps = { assignProject: async () => ({}), updateWebsiteProject: async () => ({}) } await completeAssignment('pb', { kind: 'website', forCustomer: false }, { deps }) await completeAssignment('pa', { kind: 'other', forCustomer: false }, { deps }) await completeAssignment('pc', { kind: 'website', forCustomer: false }, { deps }) assert.equal(aw.get('websiteProjects', 'pb').previewMode, 'auto') assert.equal(aw.get('websiteProjects', 'pa').previewMode, 'off') assert.equal(aw.get('websiteProjects', 'pc').previewMode, 'dev') }) test('Services: Eintraege tragen previewState', async () => { const { default: services } = await import('../server/routes/admin/services.js') const a2 = express() a2.use('/s', services) const s = await new Promise((r) => { const x = a2.listen(0, '127.0.0.1', () => r(x)) }) try { const { updateDocument } = await import('../server/services/appwriteAdmin.js') await updateDocument('websiteProjects', 'pw', { previewState: 'online' }) const r = await fetch(`http://127.0.0.1:${s.address().port}/s`, { headers: { Authorization: 'Bearer jwt-admin' } }) const all = Object.values((await r.json()).groups).flat() assert.equal(all.find((p) => p.id === 'pw').previewState, 'online') assert.equal(all.find((p) => p.id === 'pnosub').previewState, '') } finally { await new Promise((r) => s.close(r)) } }) void envLib test('PATCH off während eines laufenden Baus: Zustand bleibt off, keine Dev-Registrierung', async () => { rs.slow = true rs.result = 'dev' assert.equal((await call('POST', '/pr/preview/rebuild')).status, 200) assert.equal(aw.get('websiteProjects', 'pr').previewState, 'building') const off = await call('PATCH', '/pr/preview', { mode: 'off' }) assert.equal(off.status, 200) rs.slow = false await new Promise((r) => setTimeout(r, 300)) assert.equal(aw.get('websiteProjects', 'pr').previewState, 'off') assert.equal(rs.calls.filter((c) => c.method === 'PUT').length, 0) }) test('parallele PUT env: beide Variablen bleiben erhalten', async () => { const [a, b] = await Promise.all([ call('PUT', '/pe/preview/env', { set: { AAA: 'wert-eins' } }), call('PUT', '/pe/preview/env', { set: { BBB: 'wert-zwei' } }), ]) assert.equal(a.status, 200) assert.equal(b.status, 200) const names = (await (await call('GET', '/pe/preview')).json()).envNames assert.deepEqual(names, ['AAA', 'BBB']) }) test('env: set/remove falsch typisiert 400, zu lang 400, unentschlüsselbar 409', async () => { assert.equal((await call('PUT', '/pe/preview/env', { set: [1] })).status, 400) assert.equal((await call('PUT', '/pe/preview/env', { set: 'x' })).status, 400) assert.equal((await call('PUT', '/pe/preview/env', { remove: 'AAA' })).status, 400) const big = Object.fromEntries(Array.from({ length: 10 }, (_, i) => [`V${i}`, 'x'.repeat(4000)])) const r = await call('PUT', '/pe/preview/env', { set: big }) assert.equal(r.status, 400) assert.equal((await r.json()).error, 'Zu viele oder zu lange Variablen') assert.equal((await call('PUT', '/pbad/preview/env', { set: { A: 'b' } })).status, 409) }) test('dev/start ohne gespeicherten Stand: 409; dev/stop ändert failed nicht', async () => { assert.equal((await call('POST', '/pns/preview/dev/start')).status, 409) const { updateDocument } = await import('../server/services/appwriteAdmin.js') await updateDocument('websiteProjects', 'pns', { previewState: 'failed' }) const r = await call('POST', '/pns/preview/dev/stop') assert.equal((await r.json()).state, 'failed') }) test('Runner-Schutzschalter 503: rebuild und dev/start melden Server ausgelastet', async () => { rs.busy = true for (const p of ['/pe/preview/rebuild', '/pw/preview/dev/start']) { const r = await call('POST', p) assert.equal(r.status, 503, p) assert.deepEqual(await r.json(), { error: 'Server ausgelastet, versuche es gleich noch mal' }) } }) test('GET: devAvailable true/false/null', async () => { gs.pkg = { scripts: { dev: 'vite' } } assert.equal((await (await call('GET', '/pw/preview')).json()).devAvailable, true) devScript.clearDevScriptCache() gs.pkg = null assert.equal((await (await call('GET', '/pw/preview')).json()).devAvailable, false) devScript.clearDevScriptCache() gs.pkg = {}; gs.status = 500 assert.equal((await (await call('GET', '/pw/preview')).json()).devAvailable, null) }) test('PATCH dev ohne dev-Skript: 400, Modus bleibt, kein Neubau', async () => { gs.pkg = null const r = await call('PATCH', '/pr/preview', { mode: 'dev' }) assert.equal(r.status, 400) assert.equal((await r.json()).error, 'Dieses Projekt hat kein dev-Skript. Reines HTML braucht keinen Dev-Server – Änderungen erscheinen nach jedem Push automatisch.') assert.notEqual(aw.get('websiteProjects', 'pr').previewMode, 'dev') assert.equal(rs.calls.filter((c) => c.url === '/jobs/build').length, 0) devScript.clearDevScriptCache() gs.pkg = { scripts: { build: 'x' } } assert.equal((await call('PATCH', '/pr/preview', { mode: 'dev' })).status, 400) }) test('PATCH dev erlaubt bei dev-Skript und bei Gitea-Fehler (null)', async () => { gs.pkg = { scripts: { dev: 'vite' } } assert.equal((await call('PATCH', '/pe/preview', { mode: 'dev' })).status, 200) assert.equal(aw.get('websiteProjects', 'pe').previewMode, 'dev') devScript.clearDevScriptCache() gs.pkg = {}; gs.status = 500 assert.equal((await call('PATCH', '/pw/preview', { mode: 'dev' })).status, 200) }) test('Verfolger: Kein dev-Skript => freundliche Meldung, Zustand failed', async () => { rs.failError = 'Kein dev-Skript in package.json' assert.equal((await call('PATCH', '/pe/preview', { mode: 'build' })).status, 200) for (let i = 0; i < 100 && aw.get('websiteProjects', 'pe').previewState !== 'failed'; i++) await new Promise((r) => setTimeout(r, 20)) const d = aw.get('websiteProjects', 'pe') assert.equal(d.previewState, 'failed') assert.equal(d.previewError, 'Dev-Modus hier nicht möglich – statische Version ist online') })