Files
KantSites/scripts/archive-update-check.mjs
kantarci 7c1c86b864
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
fix: preserve running kernel cache during archive updates
2026-09-14 15:35:52 +02:00

98 lines
7.3 KiB
JavaScript

// 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`, `<?php
class ArchiveCheckDownload extends App\\Ops\\Download {
public function get(string $url): ?string {
if(str_ends_with($url, '/releases/latest')) return json_encode(['tag_name'=>'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`, `<?php
$root = ${JSON.stringify(installation)};
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
if ($uri !== '/' && !str_contains($uri, '..') && is_file($root.'/public'.$uri)) return false;
require_once $root.'/vendor/autoload.php';
require_once ${JSON.stringify(`${run}/download.php`)};
require $root.'/public/index.php';
`);
const probe = net.createServer(); await new Promise(resolve => 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));
}