From b3cf78c82a5774183d97a3bda4cf0e956892e9c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:01:58 +0000 Subject: [PATCH] Preview: Admin-API Nachbesserung (off verdraengt Bau, env-Sperre) Co-Authored-By: Claude Sonnet 5.5 --- server/routes/admin/preview.js | 46 ++++++++++++++++---------- test/previewAdmin.test.js | 59 ++++++++++++++++++++++++++++++---- 2 files changed, 82 insertions(+), 23 deletions(-) diff --git a/server/routes/admin/preview.js b/server/routes/admin/preview.js index 04d8833..a31a613 100644 --- a/server/routes/admin/preview.js +++ b/server/routes/admin/preview.js @@ -7,6 +7,7 @@ import { envKey, encryptEnv, decryptEnv, envNames, validateEnvChange } from '../ import { listRunLogs, readRunLog, isRunFile } from '../../services/previewLogs.js' import * as runnerClient from '../../services/previewRunnerClient.js' import { runDeploy, fetchPreviewConfig } from '../../services/previewDeploy.js' +import { withLocks } from '../../services/keyedLock.js' import { getRepoInfo } from '../../services/giteaAdmin.js' const router = Router() @@ -18,6 +19,7 @@ const SHA_RE = /^[0-9a-f]{7,40}$/ function fail(status, message) { const err = new Error(message) err.status = status + err.expected = true return err } @@ -79,9 +81,9 @@ const wrap = (fn) => async (req, res) => { try { await fn(req, res) } catch (err) { - const status = err.status || 500 - if (status >= 500 && status !== 503) console.error('[admin/preview]', err.message) - res.status(status).json({ error: status >= 500 ? err.message || 'Preview-Aktion fehlgeschlagen' : err.message }) + const status = err.expected ? err.status : 500 + if (!err.expected) console.error('[admin/preview]', err.message) + res.status(status).json({ error: !err.expected ? 'Preview-Aktion fehlgeschlagen' : err.message }) } } @@ -95,11 +97,13 @@ router.patch('/:id/preview', wrap(async (req, res) => { if (!MODES.includes(mode)) throw fail(400, 'Ungültiger Modus (auto, build, dev oder off)') const doc = await loadProject(req.params.id) if (mode === 'off') { + const saved = await updateDocument(col(), doc.$id, { previewMode: mode }) + // runDeploy (Modus off) belegt den Platz des Repos: laufende Verfolger schreiben danach nichts mehr + await triggerRebuild(saved) try { await runnerClient.stopDev(doc.subdomain) } catch (err) { console.error('[admin/preview] Dev stoppen fehlgeschlagen:', err.message) } - const updated = await updateDocument(col(), doc.$id, { previewMode: mode, previewState: 'off' }) - return res.json(await view(updated)) + return res.json(await view(await getDocument(col(), doc.$id))) } const updated = await updateDocument(col(), doc.$id, { previewMode: mode }) await triggerRebuild(updated) @@ -110,18 +114,23 @@ router.put('/:id/preview/env', wrap(async (req, res) => { requireRunner() const key = envKey() if (!key) throw fail(503, 'Preview-Variablen sind nicht eingerichtet') - const doc = await loadProject(req.params.id) - let current - try { current = decryptEnv(doc.previewEnv, key) } catch { - throw fail(409, 'Vorhandene Preview-Variablen sind nicht entschlüsselbar') - } - const { set, remove } = req.body || {} - const result = validateEnvChange(current, { set, remove }) - if (!result.ok) throw fail(400, result.error) - const stored = Object.keys(result.env).length ? encryptEnv(result.env, key) : '' - const updated = await updateDocument(col(), doc.$id, { previewEnv: stored }) + const { set = {}, remove = [] } = req.body || {} + if (!set || typeof set !== 'object' || Array.isArray(set)) throw fail(400, '„set“ muss ein Objekt sein.') + if (!Array.isArray(remove)) throw fail(400, '„remove“ muss eine Liste sein.') + const updated = await withLocks(['preview-env:' + req.params.id], async () => { + const doc = await loadProject(req.params.id) + let current + try { current = decryptEnv(doc.previewEnv, key) } catch { + throw fail(409, 'Vorhandene Preview-Variablen sind nicht entschlüsselbar') + } + const result = validateEnvChange(current, { set, remove }) + if (!result.ok) throw fail(400, result.error) + const stored = Object.keys(result.env).length ? encryptEnv(result.env, key) : '' + if (stored.length > 20000) throw fail(400, 'Zu viele oder zu lange Variablen') + return updateDocument(col(), doc.$id, { previewEnv: stored }) + }) if ((updated.previewMode || 'auto') !== 'off') await triggerRebuild(updated) - res.json(await view(await getDocument(col(), doc.$id))) + res.json(await view(await getDocument(col(), updated.$id))) })) router.post('/:id/preview/rebuild', wrap(async (req, res) => { @@ -159,7 +168,10 @@ router.post('/:id/preview/dev/stop', wrap(async (req, res) => { requireRunner() const doc = await loadProject(req.params.id) await runnerClient.stopDev(doc.subdomain) - const updated = await updateDocument(col(), doc.$id, { previewState: 'sleeping' }) + const fresh = await getDocument(col(), doc.$id) + const updated = ['online', 'starting', 'sleeping'].includes(fresh.previewState) + ? await updateDocument(col(), doc.$id, { previewState: 'sleeping' }) + : fresh res.json(await view(updated)) })) diff --git a/test/previewAdmin.test.js b/test/previewAdmin.test.js index 6490fe7..68b041f 100644 --- a/test/previewAdmin.test.js +++ b/test/previewAdmin.test.js @@ -11,11 +11,15 @@ import { startFakeAppwrite } from './helpers/fakeAppwrite.js' let aw, runner, gitea, app, base, tmp, cfg, envLib, logs const key = crypto.randomBytes(32) const SHA = 'c'.repeat(40) -const rs = { calls: [] } +const rs = { calls: [], slow: false, result: 'static' } 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: '' }, @@ -29,7 +33,7 @@ before(async () => { 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/')) return res.end(JSON.stringify({ state: 'done', detected: { kind: 'static', pm: 'npm' }, sha: 'b'.repeat(40), log: ['ok'], error: null, result: 'static' })) + 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 })) }) @@ -62,7 +66,7 @@ before(async () => { 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()) + 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)) }) @@ -73,7 +77,7 @@ before(async () => { after(async () => { // Hintergrund-Verfolger ausklingen lassen, bevor der Fake-Runner schliesst for (let i = 0; i < 100; i++) { - const building = ['pw', 'pa', 'pb', 'pc'].some((id) => aw.get('websiteProjects', id)?.previewState === 'building') + 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)) } @@ -83,7 +87,7 @@ after(async () => { await fs.rm(tmp, { recursive: true, force: true }) }) -beforeEach(() => { rs.calls = []; cfg.previewRunner.enabled = true; process.env.PREVIEW_ENV_KEY = key.toString('base64') }) +beforeEach(() => { 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}`, { @@ -164,7 +168,7 @@ test('env: ohne Schluessel 503, ungueltig 400, Werte nie in Antworten', async () 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.equal(list.logs[0].runFile, file) + 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) @@ -226,3 +230,46 @@ test('Services: Eintraege tragen previewState', async () => { }) 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') +})