Preview: Admin-API und Modus-Vorgabe
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3316b562af
commit
07bef6971c
8 files changed
+412
-1
No files matched your search
@@ -22,6 +22,7 @@ import { runActivation } from './services/siteFeatures.js'
|
|||||||
import giteaWebhookRoutes from './routes/webhook/gitea.js'
|
import giteaWebhookRoutes from './routes/webhook/gitea.js'
|
||||||
import pushTimeRoutes from './routes/pushTime.js'
|
import pushTimeRoutes from './routes/pushTime.js'
|
||||||
import adminGiteaRoutes from './routes/admin/gitea.js'
|
import adminGiteaRoutes from './routes/admin/gitea.js'
|
||||||
|
import adminPreviewRoutes from './routes/admin/preview.js'
|
||||||
import adminWebsiteProjectsRoutes from './routes/admin/websiteProjects.js'
|
import adminWebsiteProjectsRoutes from './routes/admin/websiteProjects.js'
|
||||||
import adminServicesRoutes from './routes/admin/services.js'
|
import adminServicesRoutes from './routes/admin/services.js'
|
||||||
import adminCustomersRoutes from './routes/admin/customers.js'
|
import adminCustomersRoutes from './routes/admin/customers.js'
|
||||||
@@ -72,6 +73,7 @@ app.use('/api/abo', aboRoutes)
|
|||||||
app.use('/api/funktionen', funktionenRoutes)
|
app.use('/api/funktionen', funktionenRoutes)
|
||||||
app.use('/api/portal-admin', portalAdminRoutes)
|
app.use('/api/portal-admin', portalAdminRoutes)
|
||||||
app.use('/api/admin/gitea', adminGiteaRoutes)
|
app.use('/api/admin/gitea', adminGiteaRoutes)
|
||||||
|
app.use('/api/admin/website-projects', adminPreviewRoutes)
|
||||||
app.use('/api/admin/website-projects', adminWebsiteProjectsRoutes)
|
app.use('/api/admin/website-projects', adminWebsiteProjectsRoutes)
|
||||||
app.use('/api/admin/services', adminServicesRoutes)
|
app.use('/api/admin/services', adminServicesRoutes)
|
||||||
app.use('/api/admin/customers', adminCustomersRoutes)
|
app.use('/api/admin/customers', adminCustomersRoutes)
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// Admin-API fuer den Preview-Block je Projekt (WOMS). Nie Variablenwerte ausgeben.
|
||||||
|
import { Router } from 'express'
|
||||||
|
import { config } from '../../config.js'
|
||||||
|
import { getDocument, updateDocument } from '../../services/appwriteAdmin.js'
|
||||||
|
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
||||||
|
import { envKey, encryptEnv, decryptEnv, envNames, validateEnvChange } from '../../services/previewEnv.js'
|
||||||
|
import { listRunLogs, readRunLog, isRunFile } from '../../services/previewLogs.js'
|
||||||
|
import * as runnerClient from '../../services/previewRunnerClient.js'
|
||||||
|
import { runDeploy, fetchPreviewConfig } from '../../services/previewDeploy.js'
|
||||||
|
import { getRepoInfo } from '../../services/giteaAdmin.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
router.use(requireAppwriteStaff)
|
||||||
|
|
||||||
|
const MODES = ['auto', 'build', 'dev', 'off']
|
||||||
|
const SHA_RE = /^[0-9a-f]{7,40}$/
|
||||||
|
|
||||||
|
function fail(status, message) {
|
||||||
|
const err = new Error(message)
|
||||||
|
err.status = status
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const col = () => config.collections.websiteProjects
|
||||||
|
|
||||||
|
async function loadProject(id) {
|
||||||
|
let doc
|
||||||
|
try {
|
||||||
|
doc = await getDocument(col(), id)
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 404) throw fail(404, 'Projekt nicht gefunden')
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
if (!doc.subdomain) throw fail(400, 'Projekt hat keine Preview-Subdomain')
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireRunner() {
|
||||||
|
if (!config.previewRunner.enabled) throw fail(409, 'Preview-Runner ist nicht aktiv')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function view(doc) {
|
||||||
|
const key = envKey()
|
||||||
|
let names = []
|
||||||
|
if (doc.previewEnv && key) {
|
||||||
|
try { names = envNames(doc.previewEnv, key) } catch { names = [] }
|
||||||
|
}
|
||||||
|
const mode = doc.previewMode || 'auto'
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
state: doc.previewState || (mode === 'off' ? 'off' : ''),
|
||||||
|
detected: doc.previewDetected || '',
|
||||||
|
builtSha: doc.previewBuiltSha || '',
|
||||||
|
builtAt: doc.previewBuiltAt || '',
|
||||||
|
error: doc.previewError || '',
|
||||||
|
branch: doc.previewBranch || '',
|
||||||
|
envNames: names,
|
||||||
|
logs: await listRunLogs(doc.$id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neubau anstossen (wie beim Bearbeiten/Reaktivieren eines Projekts)
|
||||||
|
async function triggerRebuild(doc) {
|
||||||
|
const repo = doc.repoFullName
|
||||||
|
const branch = doc.previewBranch || (await getRepoInfo(repo).catch(() => null))?.defaultBranch || 'main'
|
||||||
|
const found = await fetchPreviewConfig(repo, branch).catch(() => null)
|
||||||
|
const cfg = {
|
||||||
|
enabled: true,
|
||||||
|
type: 'static',
|
||||||
|
displayName: doc.projectName,
|
||||||
|
...(found || {}),
|
||||||
|
branch,
|
||||||
|
subdomain: doc.subdomain,
|
||||||
|
}
|
||||||
|
return runDeploy(repo, branch, cfg, { project: doc })
|
||||||
|
}
|
||||||
|
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/:id/preview', wrap(async (req, res) => {
|
||||||
|
res.json(await view(await loadProject(req.params.id)))
|
||||||
|
}))
|
||||||
|
|
||||||
|
router.patch('/:id/preview', wrap(async (req, res) => {
|
||||||
|
requireRunner()
|
||||||
|
const mode = req.body?.mode
|
||||||
|
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') {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
const updated = await updateDocument(col(), doc.$id, { previewMode: mode })
|
||||||
|
await triggerRebuild(updated)
|
||||||
|
res.json(await view(await getDocument(col(), doc.$id)))
|
||||||
|
}))
|
||||||
|
|
||||||
|
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 })
|
||||||
|
if ((updated.previewMode || 'auto') !== 'off') await triggerRebuild(updated)
|
||||||
|
res.json(await view(await getDocument(col(), doc.$id)))
|
||||||
|
}))
|
||||||
|
|
||||||
|
router.post('/:id/preview/rebuild', wrap(async (req, res) => {
|
||||||
|
requireRunner()
|
||||||
|
const doc = await loadProject(req.params.id)
|
||||||
|
if ((doc.previewMode || 'auto') === 'off') throw fail(409, 'Preview ist ausgeschaltet')
|
||||||
|
await triggerRebuild(doc)
|
||||||
|
res.json(await view(await getDocument(col(), doc.$id)))
|
||||||
|
}))
|
||||||
|
|
||||||
|
router.post('/:id/preview/dev/start', wrap(async (req, res) => {
|
||||||
|
requireRunner()
|
||||||
|
const doc = await loadProject(req.params.id)
|
||||||
|
if ((doc.previewMode || 'auto') === 'off') throw fail(409, 'Preview ist ausgeschaltet')
|
||||||
|
// Gespeicherten Stand verwenden (R17), nie raten
|
||||||
|
if (!doc.repoFullName || !doc.previewBranch || !SHA_RE.test(String(doc.previewBuiltSha || ''))) {
|
||||||
|
throw fail(409, 'Noch kein Stand gespeichert – bitte zuerst neu bauen')
|
||||||
|
}
|
||||||
|
let env = {}
|
||||||
|
if (doc.previewEnv) {
|
||||||
|
const key = envKey()
|
||||||
|
try { env = decryptEnv(doc.previewEnv, key) } catch {
|
||||||
|
throw fail(409, 'Preview-Variablen nicht entschlüsselbar (PREVIEW_ENV_KEY fehlt)')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out = await runnerClient.startDevNow(doc.subdomain, {
|
||||||
|
sub: doc.subdomain, repo: doc.repoFullName, sha: doc.previewBuiltSha, branch: doc.previewBranch, mode: 'dev', env,
|
||||||
|
})
|
||||||
|
const state = ['starting', 'online'].includes(out?.state) ? out.state : 'starting'
|
||||||
|
const updated = await updateDocument(col(), doc.$id, { previewState: state })
|
||||||
|
res.json(await view(updated))
|
||||||
|
}))
|
||||||
|
|
||||||
|
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' })
|
||||||
|
res.json(await view(updated))
|
||||||
|
}))
|
||||||
|
|
||||||
|
router.get('/:id/preview/logs/:runFile', wrap(async (req, res) => {
|
||||||
|
const doc = await loadProject(req.params.id)
|
||||||
|
if (!isRunFile(req.params.runFile)) throw fail(400, 'Ungültiger Protokollname')
|
||||||
|
const text = await readRunLog(doc.$id, req.params.runFile)
|
||||||
|
if (text === null) throw fail(404, 'Protokoll nicht gefunden')
|
||||||
|
res.json({ lines: text.replace(/\n$/, '').split('\n') })
|
||||||
|
}))
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -51,6 +51,7 @@ router.get('/', async (_req, res) => {
|
|||||||
ticketId: p.ticketId || '',
|
ticketId: p.ticketId || '',
|
||||||
woid: tickets.get(p.ticketId)?.woid || '',
|
woid: tickets.get(p.ticketId)?.woid || '',
|
||||||
previewUrl: previewUrlFor(p),
|
previewUrl: previewUrlFor(p),
|
||||||
|
previewState: p.previewState || '',
|
||||||
features: {},
|
features: {},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export async function saveRunLog(projectId, lines, env) {
|
|||||||
return runFile
|
return runFile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const isRunFile = (name) => FILE_RE.test(String(name || ''))
|
||||||
|
|
||||||
// Neueste zuerst
|
// Neueste zuerst
|
||||||
export async function listRunLogs(projectId) {
|
export async function listRunLogs(projectId) {
|
||||||
let files
|
let files
|
||||||
|
|||||||
@@ -34,5 +34,7 @@ export const submitBuild = (job) => call('POST', '/jobs/build', job)
|
|||||||
export const getRun = (runId) => call('GET', `/jobs/${encodeURIComponent(runId)}`)
|
export const getRun = (runId) => call('GET', `/jobs/${encodeURIComponent(runId)}`)
|
||||||
// Dev-Auftrag beim Runner hinterlegen (startet nicht, R12)
|
// Dev-Auftrag beim Runner hinterlegen (startet nicht, R12)
|
||||||
export const startDev = (sub, job) => call('PUT', `/dev/${encodeURIComponent(sub)}`, job)
|
export const startDev = (sub, job) => call('PUT', `/dev/${encodeURIComponent(sub)}`, job)
|
||||||
|
// Dev-Server jetzt starten (mit gespeichertem Auftrag)
|
||||||
|
export const startDevNow = (sub, job) => call('POST', `/dev/${encodeURIComponent(sub)}/start`, job)
|
||||||
export const stopDev = (sub) => call('POST', `/dev/${encodeURIComponent(sub)}/stop`, {})
|
export const stopDev = (sub) => call('POST', `/dev/${encodeURIComponent(sub)}/stop`, {})
|
||||||
export const status = () => call('GET', '/status')
|
export const status = () => call('GET', '/status')
|
||||||
@@ -95,6 +95,8 @@ export async function completeAssignment(
|
|||||||
forCustomer: customer,
|
forCustomer: customer,
|
||||||
kindSetBy: staffName || '',
|
kindSetBy: staffName || '',
|
||||||
kindSetAt: now.toISOString(),
|
kindSetAt: now.toISOString(),
|
||||||
|
// Preview-Modus nur vorbelegen, wenn noch keiner gesetzt ist
|
||||||
|
...(project.previewMode ? {} : { previewMode: kind === 'website' || kind === 'webapp' ? 'auto' : 'off' }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = { ticketId: finalTicketId }
|
const result = { ticketId: finalTicketId }
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
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 aw, runner, gitea, app, base, tmp, cfg, envLib, logs
|
||||||
|
const key = crypto.randomBytes(32)
|
||||||
|
const SHA = 'c'.repeat(40)
|
||||||
|
const rs = { calls: [] }
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
const seed = [
|
||||||
|
{ $id: 'pw', projectName: 'Web', repoFullName: 'JUSN/web', subdomain: 'web', previewBranch: 'main', previewBuiltSha: SHA, previewState: 'online', previewDetected: 'static' },
|
||||||
|
{ $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', () => {
|
||||||
|
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.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')
|
||||||
|
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')
|
||||||
|
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())
|
||||||
|
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'].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(() => { 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: [],
|
||||||
|
})
|
||||||
|
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.equal(list.logs[0].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('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
|
||||||
@@ -53,7 +53,7 @@ test('Katalog + Gruppen, Archiv/Vorlage ausgeschlossen, Namen aufgelöst', async
|
|||||||
assert.deepEqual(body.groups.website[0], {
|
assert.deepEqual(body.groups.website[0], {
|
||||||
id: 'p1', projectName: 'Doener', repoFullName: 'JUSN/doener', customerId: 'c1',
|
id: 'p1', projectName: 'Doener', repoFullName: 'JUSN/doener', customerId: 'c1',
|
||||||
customerName: 'Döner Nation', ticketId: 't1', woid: '100115',
|
customerName: 'Döner Nation', ticketId: 't1', woid: '100115',
|
||||||
previewUrl: 'https://doener.project.webklar.com', features: {},
|
previewUrl: 'https://doener.project.webklar.com', previewState: '', features: {},
|
||||||
})
|
})
|
||||||
assert.equal(body.groups.missing[0].previewUrl, 'https://ohne.de')
|
assert.equal(body.groups.missing[0].previewUrl, 'https://ohne.de')
|
||||||
assert.equal(body.groups.missing[0].customerName, '')
|
assert.equal(body.groups.missing[0].customerName, '')
|
||||||
|
|||||||
Reference in new issue
Block a user