diff --git a/server/index.js b/server/index.js index 1394a29..defe08f 100644 --- a/server/index.js +++ b/server/index.js @@ -22,6 +22,7 @@ import { runActivation } from './services/siteFeatures.js' import giteaWebhookRoutes from './routes/webhook/gitea.js' import pushTimeRoutes from './routes/pushTime.js' import adminGiteaRoutes from './routes/admin/gitea.js' +import adminPreviewRoutes from './routes/admin/preview.js' import adminWebsiteProjectsRoutes from './routes/admin/websiteProjects.js' import adminServicesRoutes from './routes/admin/services.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/portal-admin', portalAdminRoutes) app.use('/api/admin/gitea', adminGiteaRoutes) +app.use('/api/admin/website-projects', adminPreviewRoutes) app.use('/api/admin/website-projects', adminWebsiteProjectsRoutes) app.use('/api/admin/services', adminServicesRoutes) app.use('/api/admin/customers', adminCustomersRoutes) diff --git a/server/routes/admin/preview.js b/server/routes/admin/preview.js new file mode 100644 index 0000000..04d8833 --- /dev/null +++ b/server/routes/admin/preview.js @@ -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 diff --git a/server/routes/admin/services.js b/server/routes/admin/services.js index 048e312..62457ff 100644 --- a/server/routes/admin/services.js +++ b/server/routes/admin/services.js @@ -51,6 +51,7 @@ router.get('/', async (_req, res) => { ticketId: p.ticketId || '', woid: tickets.get(p.ticketId)?.woid || '', previewUrl: previewUrlFor(p), + previewState: p.previewState || '', features: {}, }) } diff --git a/server/services/previewLogs.js b/server/services/previewLogs.js index 3b4b5e5..00dff7f 100644 --- a/server/services/previewLogs.js +++ b/server/services/previewLogs.js @@ -31,6 +31,8 @@ export async function saveRunLog(projectId, lines, env) { return runFile } +export const isRunFile = (name) => FILE_RE.test(String(name || '')) + // Neueste zuerst export async function listRunLogs(projectId) { let files diff --git a/server/services/previewRunnerClient.js b/server/services/previewRunnerClient.js index fb45bbb..7ead5e0 100644 --- a/server/services/previewRunnerClient.js +++ b/server/services/previewRunnerClient.js @@ -34,5 +34,7 @@ export const submitBuild = (job) => call('POST', '/jobs/build', job) export const getRun = (runId) => call('GET', `/jobs/${encodeURIComponent(runId)}`) // Dev-Auftrag beim Runner hinterlegen (startet nicht, R12) 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 status = () => call('GET', '/status') diff --git a/server/services/projectKindAssign.js b/server/services/projectKindAssign.js index ec3fc25..fbcd163 100644 --- a/server/services/projectKindAssign.js +++ b/server/services/projectKindAssign.js @@ -95,6 +95,8 @@ export async function completeAssignment( forCustomer: customer, kindSetBy: staffName || '', 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 } diff --git a/test/previewAdmin.test.js b/test/previewAdmin.test.js new file mode 100644 index 0000000..6490fe7 --- /dev/null +++ b/test/previewAdmin.test.js @@ -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 diff --git a/test/servicesRoute.test.js b/test/servicesRoute.test.js index 9d1a3dd..3e056ec 100644 --- a/test/servicesRoute.test.js +++ b/test/servicesRoute.test.js @@ -53,7 +53,7 @@ test('Katalog + Gruppen, Archiv/Vorlage ausgeschlossen, Namen aufgelöst', async assert.deepEqual(body.groups.website[0], { id: 'p1', projectName: 'Doener', repoFullName: 'JUSN/doener', customerId: 'c1', 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].customerName, '')