Files
Webklar-Kundenbereich/test/previewRunner.test.js
T
2026-09-30 21:54:01 +00:00

259 lines
11 KiB
JavaScript

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, logs, envLib
const key = crypto.randomBytes(32)
const SHA = 'a'.repeat(40)
// Fake-Runner: zaehlt Aufrufe, Laeufe per Skript steuerbar
const rs = { calls: [], runs: new Map(), next: 0, script: null, submitError: null }
function startRunner() {
return http.createServer((req, res) => {
let raw = ''
req.on('data', (c) => (raw += c))
req.on('end', () => {
const body = raw ? JSON.parse(raw) : null
rs.calls.push({ method: req.method, url: req.url, auth: req.headers.authorization, body })
res.setHeader('Content-Type', 'application/json')
const send = (code, obj) => { res.statusCode = code; res.end(JSON.stringify(obj)) }
if (req.method === 'POST' && req.url === '/jobs/build') {
if (rs.submitError) return send(rs.submitError, { error: 'Server ausgelastet' })
const runId = `run-${++rs.next}`
rs.runs.set(runId, rs.script(body))
return send(200, { runId })
}
const m = req.url.match(/^\/jobs\/(.+)$/)
if (m && req.method === 'GET') return rs.runs.has(m[1]) ? send(200, rs.runs.get(m[1])) : send(404, { error: 'unbekannt' })
if (req.method === 'PUT' && req.url.startsWith('/dev/')) return send(200, { state: 'sleeping' })
send(404, { error: 'x' })
})
})
}
const done = (over = {}) => (job) => ({ state: 'done', detected: { kind: 'static', pm: 'npm' }, sha: job.sha, log: ['ok'], error: null, result: 'static', ...over })
const submits = () => rs.calls.filter((c) => c.url === '/jobs/build')
before(async () => {
aw = await startFakeAppwrite({ seed: { websiteProjects: [] } })
runner = startRunner()
await new Promise((r) => runner.listen(0, '127.0.0.1', r))
gitea = http.createServer((req, res) => {
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ commit: { id: 'b'.repeat(40) } }))
})
await new Promise((r) => gitea.listen(0, '127.0.0.1', r))
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'preview-runner-'))
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'),
})
const { config } = await import('../server/config.js')
config.previewRunner.enabled = true
config.previewRunner.logDir = path.join(tmp, 'logs')
pd = await import('../server/services/previewDeploy.js')
pd.runnerTiming.pollMs = 10
pd.runnerTiming.maxMs = 2000
logs = await import('../server/services/previewLogs.js')
envLib = await import('../server/services/previewEnv.js')
globalThis.__cfg = config
})
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 })
})
let n = 0
async function project(extra = {}) {
const id = `proj${++n}`
const repo = `JUSN/site-${n}`
const { createDocument } = await import('../server/services/appwriteAdmin.js')
const doc = await createDocument('websiteProjects', { repoFullName: repo, projectName: `S${n}`, ...extra }, id)
return { id, repo, doc }
}
const cfg = (repo) => ({ subdomain: repo.split('/')[1], type: 'static', branch: 'main' })
beforeEach(() => {
rs.calls = []; rs.runs = new Map(); rs.script = done(); rs.submitError = null
globalThis.__cfg.previewRunner.enabled = true
})
test('Schalter aus: kein Runner-Aufruf, alter Pfad', async () => {
const { repo } = await project()
globalThis.__cfg.previewRunner.enabled = false
globalThis.__cfg.preview.deployRoot = ''
const r = await pd.runDeploy(repo, 'main', cfg(repo))
globalThis.__cfg.preview.deployRoot = path.join(tmp, 'previews')
assert.equal(r.deployed, false)
assert.match(r.reason, /PREVIEW_DEPLOY_ROOT/)
assert.equal(rs.calls.length, 0)
})
test('an: Auftrag mit entschlüsselten Variablen, sofortige Rückgabe, Ergebnis online', async () => {
const { id, repo, doc } = await project({ previewEnv: envLib.encryptEnv({ API_URL: 'https://x.test' }, key) })
const r = await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA })
assert.equal(r.queued, true)
assert.equal(r.deployed, false)
assert.equal(r.runId, 'run-' + rs.next)
assert.equal(aw.get('websiteProjects', id).previewState, 'building')
const [sub] = submits()
assert.equal(sub.auth, 'Bearer sekret')
assert.deepEqual(sub.body, { sub: repo.split('/')[1], repo, sha: SHA, branch: 'main', mode: 'auto', env: { API_URL: 'https://x.test' } })
await pd.waitForRunFollowers()
const d = aw.get('websiteProjects', id)
assert.equal(d.previewState, 'online')
assert.equal(d.previewDetected, 'static')
assert.equal(d.previewBuiltSha, SHA)
assert.ok(d.previewBuiltAt)
assert.equal(d.previewError, '')
assert.equal((await logs.listRunLogs(id)).length, 1)
})
test('Sha fehlt: Branch-Kopf per Gitea-API', async () => {
const { repo, doc } = await project()
await pd.runDeploy(repo, 'main', cfg(repo), { project: doc })
await pd.waitForRunFollowers()
assert.equal(submits()[0].body.sha, 'b'.repeat(40))
})
test('ohne project-Option: Dokument per Repo gefunden', async () => {
const { id, repo } = await project({ previewMode: 'build' })
await pd.runDeploy(repo, 'main', cfg(repo), { sha: SHA })
await pd.waitForRunFollowers()
assert.equal(submits()[0].body.mode, 'build')
assert.equal(aw.get('websiteProjects', id).previewState, 'online')
})
test('failed: Zustand failed, Fehler gespeichert, Folge-Stand nicht online', async () => {
rs.script = done({ state: 'failed', result: null, error: 'Build fehlgeschlagen', detected: { kind: 'build', pm: 'pnpm' }, log: ['boom'] })
const { id, repo, doc } = await project({ previewBuiltSha: 'c'.repeat(40), previewState: 'online' })
await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA })
await pd.waitForRunFollowers()
const d = aw.get('websiteProjects', id)
assert.equal(d.previewState, 'failed')
assert.equal(d.previewError, 'Build fehlgeschlagen')
assert.equal(d.previewBuiltSha, 'c'.repeat(40))
assert.equal(await fs.stat(path.join(tmp, 'previews', repo.split('/')[1])).catch(() => null), null)
})
test('dev: sleeping + Registrierung per PUT /dev/<sub>', async () => {
rs.script = done({ result: 'dev', detected: { kind: 'dev', pm: 'pnpm' } })
const { id, repo, doc } = await project()
await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA })
await pd.waitForRunFollowers()
const d = aw.get('websiteProjects', id)
assert.equal(d.previewState, 'sleeping')
assert.equal(d.previewDetected, 'dev:pnpm')
const put = rs.calls.find((c) => c.method === 'PUT')
assert.equal(put.url, `/dev/${repo.split('/')[1]}`)
assert.equal(put.body.sha, SHA)
})
test('build: previewDetected build:<pm>', async () => {
rs.script = done({ result: 'build', detected: { kind: 'build', pm: 'yarn' } })
const { id, repo, doc } = await project()
await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA })
await pd.waitForRunFollowers()
assert.equal(aw.get('websiteProjects', id).previewDetected, 'build:yarn')
})
test('Modus off: kein Auftrag, previewState off', async () => {
const { id, repo, doc } = await project({ previewMode: 'off' })
const r = await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA })
assert.equal(r.skipped, true)
assert.equal(rs.calls.length, 0)
assert.equal(aw.get('websiteProjects', id).previewState, 'off')
})
test('Protokoll maskiert und nur 5 behalten', async () => {
const env = { TOKEN: 'supergeheim' }
const id = 'logproj'
const files = []
for (let i = 0; i < 7; i++) files.push(await logs.saveRunLog(id, [`Zeile ${i}`, 'nutzt supergeheim hier'], env))
const list = await logs.listRunLogs(id)
assert.equal(list.length, 5)
assert.equal(list[0].runFile, files[6].toString())
const text = await logs.readRunLog(id, list[0].runFile)
assert.ok(!text.includes('supergeheim'))
assert.ok(text.includes('•••'))
assert.equal(await logs.readRunLog(id, files[0]), null)
})
test('Protokoll: nur die letzten 300 Zeilen, keine Pfadtricks', async () => {
const lines = Array.from({ length: 400 }, (_, i) => `z${i}`)
const f = await logs.saveRunLog('logproj2', lines, {})
const text = (await logs.readRunLog('logproj2', f)).trim().split('\n')
assert.equal(text.length, 300)
assert.equal(text[0], 'z100')
assert.equal(await logs.readRunLog('logproj2', '../../x.log'), null)
assert.equal(await logs.readRunLog('logproj2', '../logproj/' + f), null)
assert.equal(await logs.readRunLog('..', f), null)
assert.deepEqual(await logs.listRunLogs('..'), [])
})
test('Variablen im Lauf-Protokoll maskiert (Ende-zu-Ende)', async () => {
rs.script = done({ log: ['Schluessel=supergeheim123'] })
const { id, repo, doc } = await project({ previewEnv: envLib.encryptEnv({ K: 'supergeheim123' }, key) })
await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA })
await pd.waitForRunFollowers()
const [{ runFile }] = await logs.listRunLogs(id)
assert.equal((await logs.readRunLog(id, runFile)).trim(), 'Schluessel=•••')
})
test('neuerer Lauf verdrängt den älteren Verfolger', async () => {
// Lauf 1 bleibt lange "running", Lauf 2 ist sofort fertig
const { id, repo, doc } = await project()
rs.script = () => ({ state: 'running', detected: null, sha: SHA, log: [], error: null, result: null })
const r1 = await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA })
rs.script = done({ log: ['zwei'] })
const sha2 = 'd'.repeat(40)
const r2 = await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: sha2 })
assert.notEqual(r1.runId, r2.runId)
// Lauf 1 meldet jetzt "failed" - darf nichts mehr schreiben
rs.runs.set(r1.runId, { state: 'failed', error: 'alt', log: ['eins'], detected: null, sha: SHA, result: null })
await pd.waitForRunFollowers()
const d = aw.get('websiteProjects', id)
assert.equal(d.previewState, 'online')
assert.equal(d.previewBuiltSha, sha2)
assert.equal((await logs.listRunLogs(id)).length, 1)
})
test('PREVIEW_ENV_KEY fehlt bei gespeicherten Variablen: Build ohne Variablen, Fehlertext', async () => {
const { id, repo, doc } = await project({ previewEnv: envLib.encryptEnv({ A: 'xxxxxx' }, key) })
const saved = process.env.PREVIEW_ENV_KEY
delete process.env.PREVIEW_ENV_KEY
try {
await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA })
await pd.waitForRunFollowers()
} finally {
process.env.PREVIEW_ENV_KEY = saved
}
assert.deepEqual(submits()[0].body.env, {})
const d = aw.get('websiteProjects', id)
assert.equal(d.previewState, 'online')
assert.equal(d.previewError, 'Preview-Variablen nicht entschlüsselbar (PREVIEW_ENV_KEY fehlt)')
})
test('Runner ausgelastet (503): failed mit Meldung, kein Wurf', async () => {
rs.submitError = 503
const { id, repo, doc } = await project()
const r = await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA })
assert.equal(r.deployed, false)
assert.equal(r.error, 'Server ausgelastet')
assert.equal(aw.get('websiteProjects', id).previewState, 'failed')
})