Files
Webklar-Kundenbereich/test/previewDevRouter.test.js

291 lines
12 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, config, hostsFile, envLib
const key = crypto.randomBytes(32)
const BUILT = 'c'.repeat(40)
const HEAD = 'd'.repeat(40)
const puts = []
let giteaDown = false
let runnerFails = false
before(async () => {
aw = await startFakeAppwrite({ seed: { websiteProjects: [] } })
runner = http.createServer((req, res) => {
let raw = ''
req.on('data', (c) => (raw += c))
req.on('end', () => {
res.setHeader('Content-Type', 'application/json')
if (req.method === 'PUT' && req.url.startsWith('/dev/')) {
puts.push({ url: req.url, body: JSON.parse(raw) })
res.statusCode = runnerFails ? 500 : 200
return res.end(JSON.stringify(runnerFails ? { error: 'kaputt' } : { state: 'sleeping' }))
}
res.statusCode = 404
res.end('{}')
})
})
await new Promise((r) => runner.listen(0, '127.0.0.1', r))
gitea = http.createServer((req, res) => {
res.setHeader('Content-Type', 'application/json')
giteaHits++
if (giteaDown) { res.statusCode = 500; return res.end('{}') }
res.end(JSON.stringify({ commit: { id: HEAD } }))
})
await new Promise((r) => gitea.listen(0, '127.0.0.1', r))
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'preview-dev-'))
await fs.mkdir(path.join(tmp, 'previews', 'statisch'), { recursive: true })
await fs.mkdir(path.join(tmp, 'previews', 'offen'), { recursive: true })
hostsFile = path.join(tmp, 'dynamic', 'preview-hosts.yml')
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'),
})
;({ config } = await import('../server/config.js'))
pd = await import('../server/services/previewDeploy.js')
envLib = await import('../server/services/previewEnv.js')
const { createDocument } = await import('../server/services/appwriteAdmin.js')
const base = { previewMode: 'auto' }
await createDocument('websiteProjects', { ...base, subdomain: 'offen', isPublic: true, previewState: 'online', previewDetected: 'static' }, 'p1')
await createDocument('websiteProjects', { ...base, subdomain: 'statisch', previewState: 'online', previewDetected: 'build:npm' }, 'p2')
await createDocument('websiteProjects', {
...base, subdomain: 'devapp', repoFullName: 'JUSN/devapp', previewState: 'sleeping', previewDetected: 'dev:npm',
previewBuiltSha: BUILT, previewBranch: 'feature-x', previewEnv: envLib.encryptEnv({ API_KEY: 'geheim-wert' }, key),
}, 'p3')
await createDocument('websiteProjects', {
...base, subdomain: 'devoff', repoFullName: 'JUSN/devoff', previewState: 'failed', previewDetected: 'dev:npm',
}, 'p4')
})
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 })
})
beforeEach(() => {
puts.length = 0
giteaDown = false
runnerFails = false
config.previewRunner.enabled = true
})
const read = () => fs.readFile(hostsFile, 'utf8')
// Erwartete Datei von VOR Task 10 (Schalter aus): zwei Ordner, 'offen' oeffentlich
const block = (name, isPublic) => [
` preview-${name}-https:`,
` rule: "Host(\`${name}.project.webklar.com\`)"`,
' entryPoints:',
' - websecure',
' service: preview-nginx@docker',
...(isPublic ? [] : [' middlewares:', ' - preview-chain@docker']),
' priority: 100',
' tls:',
' certResolver: letsencrypt',
'',
` preview-${name}-http:`,
` rule: "Host(\`${name}.project.webklar.com\`)"`,
' entryPoints:',
' - web',
' service: preview-nginx@docker',
' middlewares:',
' - preview-sites-redirect@docker',
' priority: 100',
].join('\n')
const LEGACY = [
'# Auto-generated by previewDeploy.syncPreviewTls - do not edit manually',
'http:',
' routers:',
block('offen', true),
block('statisch', false),
'',
].join('\n')
const routerBlock = (content, name) => {
const start = content.indexOf(` preview-${name}-https:`)
assert.ok(start >= 0, `Router ${name} fehlt`)
const rest = content.slice(start + 1)
const next = rest.search(/\n {4}preview-[a-z0-9-]+-https:/)
return next < 0 ? content.slice(start) : content.slice(start, start + 1 + next)
}
test('Schalter aus: Datei byte-identisch wie bisher, keine Dev-Hosts, keine Registrierung', async () => {
config.previewRunner.enabled = false
const result = await pd.syncPreviewTls()
assert.deepEqual(result, { synced: true, hosts: 2, publicSource: 'appwrite' })
assert.equal(await read(), LEGACY)
await pd.waitForDevRegistration()
assert.equal(puts.length, 0)
})
test('Dev-Projekt bekommt Runner-Service (auch ohne Ordner), static bleibt nginx', async () => {
await pd.syncPreviewTls()
await pd.waitForDevRegistration()
const content = await read()
const dev = routerBlock(content, 'devapp')
assert.equal((dev.match(/service: preview-runner@docker/g) || []).length, 2) // HTTPS + HTTP
assert.match(dev, /preview-chain@docker/) // nicht oeffentlich => geschuetzt
assert.match(dev, /preview-sites-redirect@docker/)
assert.match(routerBlock(content, 'statisch'), /preview-nginx@docker/)
assert.doesNotMatch(routerBlock(content, 'statisch'), /preview-runner/)
// I5: auch ein fehlgeschlagener/neu bauender Dev-Host bleibt beim Runner (kein Umschalten auf nginx)
assert.match(routerBlock(content, 'devoff'), /preview-runner@docker/)
assert.deepEqual([...pd.devSubdomainsFromHostsFile(content)].sort(), ['devapp', 'devoff'])
})
test('Oeffentliche Seite mit previewDetected dev geht nur bei explizitem previewMode dev zum Runner', async () => {
const { createDocument, deleteDocument } = await import('../server/services/appwriteAdmin.js')
await createDocument('websiteProjects', { previewMode: 'auto', subdomain: 'livesite', isPublic: true, previewState: 'sleeping', previewDetected: 'dev:npm' }, 'pub1')
await createDocument('websiteProjects', { previewMode: 'dev', subdomain: 'livedev', isPublic: true, previewState: 'sleeping', previewDetected: 'dev:npm' }, 'pub2')
await fs.mkdir(path.join(tmp, 'previews', 'livesite'), { recursive: true }) // veroeffentlichter Ordner
try {
await pd.syncPreviewTls()
await pd.waitForDevRegistration()
const content = await read()
assert.match(routerBlock(content, 'livedev'), /preview-runner@docker/)
assert.match(routerBlock(content, 'livesite'), /preview-nginx@docker/)
assert.doesNotMatch(routerBlock(content, 'livesite'), /preview-runner/)
assert.ok(!pd.devSubdomainsFromHostsFile(content).has('livesite'))
} finally {
await fs.rm(path.join(tmp, 'previews', 'livesite'), { recursive: true, force: true })
await deleteDocument('websiteProjects', 'pub1'); await deleteDocument('websiteProjects', 'pub2')
await pd.syncPreviewTls(); await pd.waitForDevRegistration()
}
})
test('Appwrite nicht erreichbar: Dev-Hosts aus der letzten Datei bleiben', async () => {
await pd.syncPreviewTls()
await pd.waitForDevRegistration()
aw.failNext({ method: 'GET', pathIncludes: 'documents', status: 500 }) // getPublicSubdomains
aw.failNext({ method: 'GET', pathIncludes: 'documents', status: 500 }) // getDevProjects
const errors = console.error
console.error = () => {}
try {
const result = await pd.syncPreviewTls()
assert.equal(result.publicSource, 'previous-file')
} finally { console.error = errors }
await pd.waitForDevRegistration()
assert.deepEqual([...pd.devSubdomainsFromHostsFile(await read())].sort(), ['devapp', 'devoff'])
assert.match(routerBlock(await read(), 'devapp'), /preview-runner@docker/)
assert.equal(puts.length, 1) // nur die Runde mit Appwrite-Daten
})
test('devSubdomainsFromHostsFile: nur HTTPS-Router mit Runner-Service', () => {
const content = [
'http:', ' routers:',
' preview-a-https:', ' service: preview-runner@docker',
' preview-a-http:', ' service: preview-runner@docker',
' preview-b-https:', ' service: preview-nginx@docker',
' preview-c-d-https:', ' rule: x', ' service: preview-runner@docker',
].join('\n')
assert.deepEqual([...pd.devSubdomainsFromHostsFile(content)].sort(), ['a', 'c-d'])
assert.equal(pd.devSubdomainsFromHostsFile('').size, 0)
assert.equal(pd.devSubdomainsFromHostsFile(undefined).size, 0)
})
test('Registrierung: Runner bekommt Spec mit entschluesselten Variablen', async () => {
await pd.syncPreviewTls()
await pd.waitForDevRegistration()
assert.equal(puts.length, 1)
assert.equal(puts[0].url, '/dev/devapp')
assert.deepEqual(puts[0].body, {
sub: 'devapp', repo: 'JUSN/devapp', sha: BUILT, branch: 'feature-x', mode: 'dev', env: { API_KEY: 'geheim-wert' },
})
})
test('Registrierung: nicht entschluesselbare Variablen (Schluessel fehlt) => uebersprungen', async () => {
const saved = process.env.PREVIEW_ENV_KEY
delete process.env.PREVIEW_ENV_KEY
const errors = console.error
console.error = () => {}
try {
await pd.syncPreviewTls()
await pd.waitForDevRegistration()
} finally { process.env.PREVIEW_ENV_KEY = saved; console.error = errors }
assert.equal(puts.length, 0)
assert.match(await read(), /preview-runner@docker/)
})
test('Registrierung: Runner-Fehler bricht den Abgleich nicht, Datei ist geschrieben', async () => {
runnerFails = true
await fs.rm(hostsFile, { force: true })
const errors = console.error
console.error = () => {}
try {
const result = await pd.syncPreviewTls()
assert.equal(result.synced, true)
await pd.waitForDevRegistration()
} finally { console.error = errors }
assert.equal(puts.length, 1)
assert.match(await read(), /preview-runner@docker/)
})
let giteaHits = 0
test('Registrierung: fehlender Stand oder Branch => uebersprungen, kein Gitea-Aufruf', async () => {
const { updateDocument } = await import('../server/services/appwriteAdmin.js')
const errors = console.error
console.error = () => {}
try {
for (const patch of [{ previewBuiltSha: '' }, { previewBranch: '' }]) {
await updateDocument('websiteProjects', 'p3', patch)
giteaHits = 0
puts.length = 0
await pd.syncPreviewTls()
await pd.waitForDevRegistration()
assert.equal(puts.length, 0)
assert.equal(giteaHits, 0)
assert.match(await read(), /preview-runner@docker/)
await updateDocument('websiteProjects', 'p3', { previewBuiltSha: BUILT, previewBranch: 'feature-x' })
}
} finally { console.error = errors }
})
test('Registrierung: kaputte verschluesselte Variablen => uebersprungen', async () => {
const { updateDocument } = await import('../server/services/appwriteAdmin.js')
const good = envLib.encryptEnv({ API_KEY: 'geheim-wert' }, key)
await updateDocument('websiteProjects', 'p3', { previewEnv: 'v1:xx:yy:zz' })
const errors = console.error
console.error = () => {}
try {
await pd.syncPreviewTls()
await pd.waitForDevRegistration()
} finally {
console.error = errors
await updateDocument('websiteProjects', 'p3', { previewEnv: good })
}
assert.equal(puts.length, 0)
})
test('Registrierung: laeuft eine Runde, folgt genau eine Wiederholung', async () => {
await Promise.all([pd.syncPreviewTls(), pd.syncPreviewTls(), pd.syncPreviewTls()])
await pd.waitForDevRegistration()
assert.equal(puts.length, 2)
})
test('I5: Dev-Projekt im Zustand building bleibt beim Runner, static im Zustand building nicht', async () => {
const { createDocument } = await import('../server/services/appwriteAdmin.js')
await createDocument('websiteProjects', { previewMode: 'auto', subdomain: 'baut', repoFullName: 'JUSN/baut', previewState: 'building', previewDetected: 'dev:npm' }, 'p5')
await createDocument('websiteProjects', { previewMode: 'auto', subdomain: 'baustatic', repoFullName: 'JUSN/baustatic', previewState: 'building', previewDetected: 'static' }, 'p6')
await pd.syncPreviewTls()
await pd.waitForDevRegistration()
const content = await read()
assert.match(routerBlock(content, 'baut'), /preview-runner@docker/)
assert.ok(!pd.devSubdomainsFromHostsFile(content).has('baustatic'))
})