// Exercise the real HTTP update across two complete release archives in an isolated installation. // Usage: PHP_BINARY=php node scripts/archive-update-check.mjs old.zip new.zip [expected-status] // UPDATE_SOURCE_VERSION changes only the disposable source VERSION to test a future upgrade // from the fixed updater before a subsequent release exists. import assert from 'node:assert/strict'; import { chromium } from 'playwright'; import { execFileSync, spawn } from 'node:child_process'; import { mkdir, readFile, writeFile, readdir, access } from 'node:fs/promises'; import { createHash, randomBytes } from 'node:crypto'; import path from 'node:path'; import net from 'node:net'; const php = process.env.PHP_BINARY || 'php'; const [source, target, expected = '302'] = process.argv.slice(2); assert.ok(source && target, 'Supply the installed and target release ZIP paths.'); const root = process.cwd().replaceAll('\\', '/'); const run = `${root}/var/archive-check-${randomBytes(5).toString('hex')}`; await mkdir(run, { recursive: true }); const originalHash = createHash('sha256').update(await readFile('var/kantit.db')).digest('hex'); execFileSync(php, ['-r', '$z=new ZipArchive(); if(true!==$z->open($argv[1])||!$z->extractTo($argv[2]))exit(1); $z->close();', path.resolve(source), run], { windowsHide: true }); const [directory] = (await readdir(run)).filter(name => /^kantsites-/.test(name)); assert.ok(directory); const installation = `${run}/${directory}`; const targetVersion = execFileSync(php, ['-r', '$z=new ZipArchive(); $z->open($argv[1]); for($i=0;$i<$z->numFiles;$i++){if(preg_match("#^kantsites-[^/]+/VERSION$#",$z->getNameIndex($i)))echo trim($z->getFromIndex($i));}', path.resolve(target)], { encoding: 'utf8', windowsHide: true }).trim(); assert.match(targetVersion, /^\d+\.\d+\.\d+$/); if (process.env.UPDATE_SOURCE_VERSION) await writeFile(`${installation}/VERSION`, `${process.env.UPDATE_SOURCE_VERSION}\n`); const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true })); await mkdir(`${installation}/var/media`, { recursive: true }); // Only the download transport is replaced; the real archive, checksum, backup, file swap, // production kernel, session, redirects, migration and subsequent requests remain in use. await writeFile(`${run}/download.php`, `'v'.getenv('QA_TARGET_VERSION')]); if(str_ends_with($url, '.zip.sha256')) return hash_file('sha256', getenv('QA_TARGET_ZIP')); if(str_ends_with($url, '.zip')) return file_get_contents(getenv('QA_TARGET_ZIP')); return null; } } `); await writeFile(`${installation}/config/services.yaml`, (await readFile(`${installation}/config/services.yaml`, 'utf8')) + '\n App\\Ops\\Download:\n class: ArchiveCheckDownload\n'); // Keep the test router outside the replaced bin/ directory (Windows locks the active script). await writeFile(`${run}/router.php`, ` probe.listen(0, '127.0.0.1', resolve)); const port = probe.address().port; await new Promise(resolve => probe.close(resolve)); const origin = `http://127.0.0.1:${port}`; const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: `${installation}/var/media`, SITE_ORIGIN: origin, QA_TARGET_ZIP: path.resolve(target), QA_TARGET_VERSION: targetVersion }; const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', `${run}/router.php`], { cwd: installation, env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); let log = '', browser; server.stdout.on('data', data => log += data); server.stderr.on('data', data => log += data); const result = { source: path.resolve(source), target: path.resolve(target), simulatedSourceVersion: process.env.UPDATE_SOURCE_VERSION ?? null }; try { let ready = false; for (let attempt = 0; attempt < 60; attempt++) { try { if ((await fetch(origin + '/admin/login')).ok) { ready = true; break; } } catch {} await new Promise(resolve => setTimeout(resolve, 200)); } assert.ok(ready, 'Isolated release must boot before testing an update.'); browser = await chromium.launch(); const page = await browser.newPage(); await page.goto(origin + '/admin/login'); await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password); await page.locator('button[type=submit]').click(); assert.equal((await page.goto(origin + '/admin/system')).status(), 200); const token = await page.locator('form[action="/admin/system/archiv"] [name="_csrf_token"]').first().inputValue(); const response = await page.request.post(origin + '/admin/system/archiv', { form: { _csrf_token: token, target: targetVersion }, maxRedirects: 0 }); result.updateStatus = response.status(); assert.equal(response.status(), Number(expected)); if (response.status() === 302) assert.equal(response.headers().location, '/admin/system'); assert.equal((await readFile(`${installation}/VERSION`, 'utf8')).trim(), targetVersion); const updateLog = await readFile(`${installation}/var/updates.log`, 'utf8'); assert.ok(updateLog.includes('Dateien sind ersetzt')); assert.ok(!updateLog.includes('FEHLER')); assert.equal((await page.request.get(origin)).status(), 503, 'Maintenance stays active until migrations finish.'); result.systemStatus = (await page.goto(origin + '/admin/system')).status(); assert.equal(result.systemStatus, 200); const finish = page.locator('form').filter({ has: page.locator('input[name=finish]') }); assert.equal(await finish.count(), 1, 'The migration step remains accessible with the same session.'); const csrf = await finish.locator('[name="_csrf_token"]').inputValue(); const finished = await page.request.post(origin + '/admin/system/archiv', { form: { _csrf_token: csrf, finish: '1' }, maxRedirects: 0 }); result.finishStatus = finished.status(); assert.equal(finished.status(), 302); result.publicStatus = (await page.request.get(origin)).status(); assert.equal(result.publicStatus, 200); assert.equal((await page.goto(origin + '/admin/system')).status(), 200); assert.equal(await page.locator('input[name=finish]').count(), 0); assert.equal((await readdir(`${installation}/var/backups`)).filter(name => name.startsWith('sicherung-')).length, 1); await access(`${installation}/.env`); assert.equal(createHash('sha256').update(await readFile('var/kantit.db')).digest('hex'), originalHash); if (Number(expected) === 302) assert.doesNotMatch(log, /\[critical\]|Failed to open stream|PHP Fatal error/); result.ok = true; } finally { await browser?.close(); server.kill(); await writeFile(`${run}/server.log`, log); await writeFile(`${run}/result.json`, JSON.stringify(result, null, 2)); console.log(JSON.stringify({ ...result, report: run }, null, 2)); }