Files
2026-09-30 22:47:54 +00:00

345 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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')
})