Files
Webklar-Kundenbereich/test/previewRunner.test.js
T

369 lines
17 KiB
JavaScript
Raw 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 { 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({ projectName: 'Mein Projekt', 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', displayName: 'Mein Projekt', 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('displayName: previewConfig.displayName hat Vorrang vor projectName, sonst projectName', async () => {
const a = await project({ projectName: 'Projektname' })
await pd.runDeploy(a.repo, 'main', { ...cfg(a.repo), displayName: 'Doener Nation' }, { project: a.doc, sha: SHA })
await pd.waitForRunFollowers()
assert.equal(submits().at(-1).body.displayName, 'Doener Nation')
const b = await project({ projectName: 'Projektname' })
await pd.runDeploy(b.repo, 'main', cfg(b.repo), { project: b.doc, sha: SHA })
await pd.waitForRunFollowers()
assert.equal(submits().at(-1).body.displayName, 'Projektname')
})
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.previewBranch, 'main')
assert.equal(d.previewBuiltSha, SHA)
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')
})
test('Modus off während ein Lauf läuft: Zustand bleibt off, kein TLS-Sync vom alten Verfolger', async () => {
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 })
const { updateDocument } = await import('../server/services/appwriteAdmin.js')
const off = await updateDocument('websiteProjects', id, { previewMode: 'off' })
await pd.runDeploy(repo, 'main', cfg(repo), { project: off, sha: SHA })
rs.runs.set(r1.runId, { state: 'done', detected: { kind: 'static', pm: 'npm' }, sha: SHA, log: ['alt'], error: null, result: 'static' })
await pd.waitForRunFollowers()
assert.equal(aw.get('websiteProjects', id).previewState, 'off')
assert.equal((await logs.listRunLogs(id)).length, 0)
})
test('gleichzeitige Aufrufe: der neueste gewinnt', async () => {
const { id, repo, doc } = await project()
rs.script = (job) => ({ state: 'done', detected: { kind: 'static', pm: 'npm' }, sha: job.sha, log: [job.sha], error: null, result: 'static' })
const sha2 = 'e'.repeat(40)
const [, r2] = await Promise.all([
pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA }),
pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: sha2 }),
])
await pd.waitForRunFollowers()
assert.ok(r2.queued)
assert.equal(aw.get('websiteProjects', id).previewBuiltSha, sha2)
})
test('fehlgeschlagener neuer Auftrag verdrängt den alten Verfolger', async () => {
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.submitError = 503
await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA })
rs.runs.set(r1.runId, { state: 'done', detected: { kind: 'static' }, sha: SHA, log: [], error: null, result: 'static' })
await pd.waitForRunFollowers()
assert.equal(aw.get('websiteProjects', id).previewState, 'failed')
})
test('R15: folgt queued/running über die alte 16-Minuten-Grenze, Runner-Ausfall lange toleriert', async () => {
const { id, repo, doc } = await project()
let polls = 0
rs.script = () => ({ state: 'queued', detected: null, sha: SHA, log: [], error: null, result: null })
const r = await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA })
const orig = rs.runs.get(r.runId)
Object.defineProperty(orig, 'state', { get() { polls++; return polls < 15 ? 'queued' : 'done' }, enumerable: true })
await pd.waitForRunFollowers()
assert.ok(polls >= 15)
assert.equal(aw.get('websiteProjects', id).previewState, 'online')
})
test('R15: Gesamtzeit überschritten => failed mit "Status unbekannt"', async () => {
const { id, repo, doc } = await project()
rs.script = () => ({ state: 'running', detected: null, sha: SHA, log: [], error: null, result: null })
const saved = pd.runnerTiming.maxMs
pd.runnerTiming.maxMs = 60
try {
await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA })
await pd.waitForRunFollowers()
} finally { pd.runnerTiming.maxMs = saved }
const d = aw.get('websiteProjects', id)
assert.equal(d.previewState, 'failed')
assert.equal(d.previewError, 'Status unbekannt – bitte neu bauen')
})
test('R15: Runner dauerhaft unerreichbar (> unreachableMs) => failed', async () => {
const { id, repo, doc } = await project()
rs.script = () => ({ state: 'running', detected: null, sha: SHA, log: [], error: null, result: null })
const { config } = await import('../server/config.js')
const savedUrl = config.previewRunner.url
const saved = pd.runnerTiming.unreachableMs
pd.runnerTiming.unreachableMs = 50
try {
await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA })
config.previewRunner.url = 'http://127.0.0.1:1'
await pd.waitForRunFollowers()
} finally { config.previewRunner.url = savedUrl; pd.runnerTiming.unreachableMs = saved }
const d = aw.get('websiteProjects', id)
assert.equal(d.previewState, 'failed')
assert.match(d.previewError, /nicht erreichbar/)
})
test('Runner-Fehlertext wird maskiert', async () => {
rs.script = done({ state: 'failed', result: null, error: 'Fehler mit supergeheim99', log: [] })
const { id, repo, doc } = await project({ previewEnv: envLib.encryptEnv({ K: 'supergeheim99' }, key) })
await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA })
await pd.waitForRunFollowers()
assert.equal(aw.get('websiteProjects', id).previewError, 'Fehler mit •••')
})
test('dev-Ergebnis: PUT /dev/<sub> mit vollständigem Auftrag inkl. Variablen', async () => {
rs.script = done({ result: 'dev', detected: { kind: 'dev', pm: 'npm' } })
const { repo, doc } = await project({ previewEnv: envLib.encryptEnv({ A: 'wert1234' }, key), previewMode: 'dev' })
await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA })
await pd.waitForRunFollowers()
const put = rs.calls.find((c) => c.method === 'PUT')
assert.deepEqual(put.body, { sub: repo.split('/')[1], repo, sha: SHA, branch: 'main', mode: 'dev', env: { A: 'wert1234' } })
})