Files
KantSites/tests/ArchiveUpdateTest.php
T
kantarciandClaude Opus 5.5 47292c7266 Finish an archive update in one go and translate Kantarci Reisen into Turkish
The request that swaps the files now ends with a plain page instead of running on through the
framework with old code in memory and new code on disk - that is what answered with a 500 after
a successful update. The page calls the second step itself with a one-time key: migrations,
clean-up, maintenance off, back to the system screen (ADR 112).

Turkish is complete in the fixed template texts and form messages. Two language switches on one
page get distinct landmark names. Kantarci Reisen gets a Turkish version of every page, the
header, footer, menu and enquiry form, with a DE · TR switch; the reset script applies it.
The DM Sans 600 files are needed after all and are committed with fonts.css.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:36:25 +02:00

371 lines
18 KiB
PHP

<?php
namespace App\Tests;
use App\Infrastructure\ConnectionFactory;
use App\Ops\ArchiveUpdate;
use App\Ops\Backup;
use App\Ops\CommandRunner;
use App\Ops\Download;
use App\Ops\Updater;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\TestCase;
/**
* Das Update über das Releasearchiv - der Weg für Installationen ohne Git.
*
* Auf geteiltem Hosting gibt es keine Arbeitskopie, also auch kein `git fetch`. Dort wird über
* das Archiv installiert und über das Archiv aktualisiert, und das ist der Weg, den die meisten
* Installationen dieses CMS nehmen werden. Er war bis hierhin ungeprüft.
*
* Geprüft wird gegen eine Installation in einem Wegwerfverzeichnis und eine Releaseseite, die im
* Speicher liegt (@see ReleaseOnDisk): echte Archive, echte Prüfsummen, echtes Entpacken, echte
* Umbenennungen - nur eben ohne Netz. Die Frage, um die es geht, ist nicht „läuft der Download",
* sondern: Bleibt stehen, was dem Betreiber gehört?
*/
final class ArchiveUpdateTest extends TestCase
{
private const BASE = 'https://releases.example.invalid/download';
private const API = 'https://releases.example.invalid/api';
private string $root;
private Connection $db;
private ReleaseOnDisk $releases;
protected function setUp(): void
{
$this->root = str_replace('\\', '/', sys_get_temp_dir()).'/kant-archiv-'.bin2hex(random_bytes(6));
// Eine Installation, wie sie nach dem Entpacken eines Releases aussieht.
foreach (['bin', 'config', 'migrations', 'src', 'templates', 'vendor', 'var/media', 'public/assets/fonts', 'public/assets/build'] as $verzeichnis) {
mkdir($this->root.'/'.$verzeichnis, 0o775, true);
}
$this->write('VERSION', "1.0.0\n");
$this->write('vendor/autoload.php', "<?php // alt\n");
$this->write('src/Alt.php', "<?php // wird ersetzt\n");
$this->write('composer.json', '{"name":"alt"}');
$this->write('composer.lock', '{"packages":[]}');
$this->write('migrations.php', "<?php return [];\n");
$this->write('cli-config.php', "<?php // alt\n");
$this->write('public/index.php', "<?php // alt\n");
$this->write('public/assets/site.css', 'body{color:alt}');
// Was dem Betreiber gehört - hochgeladene Schriften, das erzeugte Bündel, seine Medien,
// seine Datenbank, sein Geheimnis. Nichts davon steht im Archiv.
$this->write('public/assets/fonts/eigene.woff2', 'SCHRIFT');
$this->write('public/assets/build/bundle.css', 'body{color:erzeugt}');
$this->write('var/media/bild.jpg', 'BILD');
$this->write('.env', "APP_SECRET=geheim\n");
$this->db = ConnectionFactory::create('sqlite:///'.$this->root.'/var/cms.db');
TestSchema::build($this->db);
$this->releases = new ReleaseOnDisk();
}
protected function tearDown(): void
{
$this->db->close();
gc_collect_cycles();
$this->removeDirectory($this->root);
}
public function testAnUpdateReplacesTheApplicationAndLeavesTheInstallationUntouched(): void
{
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'));
$this->write('var/cache/prod/running-service.php', '<?php // Still needed by this request.');
$ergebnis = $this->updater()->run();
self::assertTrue($ergebnis['ok'], $this->explain($ergebnis));
self::assertFileExists($this->root.'/var/cache/prod/running-service.php', 'The update request still loads compiled services after replacing files.');
self::assertTrue($ergebnis['migrate'] ?? false, 'Nach dem Ersetzen fehlen noch die Migrationen.');
self::assertSame(
['Fassung ermitteln', 'Sicherung', 'Archiv laden', 'Prüfsumme', 'Entpacken', 'Wartungszustand', 'Dateien ersetzen', 'Zwischenspeicher', 'Dateien sind ersetzt'],
array_column($ergebnis['steps'], 'step'),
'Die Reihenfolge ist der Punkt: gesichert wird vor dem ersten Handgriff, geprüft vor dem Entpacken.',
);
// Die Anwendung ist die neue.
self::assertSame('1.1.0', trim((string) file_get_contents($this->root.'/VERSION')));
self::assertFileExists($this->root.'/src/Neu.php');
self::assertFileDoesNotExist($this->root.'/src/Alt.php', 'src/ wird ersetzt, nicht ergänzt.');
self::assertStringContainsString('neu', (string) file_get_contents($this->root.'/public/index.php'));
self::assertSame('body{color:neu}', file_get_contents($this->root.'/public/assets/site.css'));
// Und alles, was dem Betreiber gehört, steht noch da.
self::assertSame('SCHRIFT', file_get_contents($this->root.'/public/assets/fonts/eigene.woff2'));
self::assertFileDoesNotExist($this->root.'/public/assets/fonts/andere.woff2', 'Schriften aus dem Archiv würden die hochgeladenen überschreiben.');
self::assertSame('body{color:erzeugt}', file_get_contents($this->root.'/public/assets/build/bundle.css'));
self::assertSame('BILD', file_get_contents($this->root.'/var/media/bild.jpg'));
self::assertStringContainsString('APP_SECRET=geheim', (string) file_get_contents($this->root.'/.env'));
self::assertFileExists($this->root.'/var/cms.db');
// Die Website bleibt bis zu den Migrationen im Wartungszustand, und der alte Stand liegt
// noch da, falls doch etwas zurückgeschoben werden muss.
self::assertFileExists($this->root.'/'.Updater::MAINTENANCE_FILE);
self::assertCount(1, glob($this->root.'/var/backups/sicherung-*') ?: []);
self::assertCount(1, glob($this->root.'/var/update/*/alt/VERSION') ?: []);
// Der zweite Schritt ist auf der Platte vermerkt, nicht in der Sitzung: fällt sie aus,
// bliebe die Website sonst im Wartungszustand und der Knopf wäre weg.
$offen = $this->updater()->pending();
self::assertNotNull($offen, 'Ohne Marke findet niemand mehr den zweiten Schritt.');
self::assertSame('1.1.0', $offen['fassung']);
// Mit dem Einmalschlüssel ruft die Zwischenseite den Abschluss selbst auf (ADR 112).
self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/D', (string) ($offen['token'] ?? ''));
self::assertSame($offen['token'], $ergebnis['token']);
self::assertSame('1.1.0', $ergebnis['fassung']);
}
public function testNothingIsPendingBeforeAnUpdateAndAfterAFailedOne(): void
{
self::assertNull($this->updater()->pending());
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'), str_repeat('a', 64));
$this->updater()->run();
self::assertNull($this->updater()->pending(), 'Ein Update, das nicht ersetzt hat, lässt nichts offen.');
}
public function testAnArchiveWithTheWrongChecksumIsNeverUnpacked(): void
{
// Der Fall, für den die Prüfsumme da ist: unterwegs ausgetauscht oder halb übertragen.
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'), str_repeat('a', 64));
$ergebnis = $this->updater()->run();
self::assertFalse($ergebnis['ok']);
self::assertSame('Prüfsumme', $this->firstFailed($ergebnis)['step']);
self::assertSame('1.0.0', trim((string) file_get_contents($this->root.'/VERSION')), 'Nichts wurde ersetzt.');
self::assertFileExists($this->root.'/src/Alt.php');
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE, 'Die Website darf davon nichts merken.');
}
public function testAnIncompleteArchiveIsRefusedBeforeTheSiteGoesIntoMaintenance(): void
{
// Ein Archiv ohne vendor/ ist auf einem Hosting ohne Composer wertlos - das muss auffallen,
// bevor die halbe Anwendung ersetzt ist.
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0', false));
$ergebnis = $this->updater()->run();
self::assertFalse($ergebnis['ok']);
self::assertSame('Entpacken', $this->firstFailed($ergebnis)['step']);
self::assertStringContainsString('vendor', $this->firstFailed($ergebnis)['detail']);
self::assertSame('1.0.0', trim((string) file_get_contents($this->root.'/VERSION')));
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE);
}
public function testAnUnreachableReleasePageChangesNothingAtAll(): void
{
// Viele Hoster lassen ausgehende Verbindungen nicht zu. Dann passiert hier gar nichts -
// insbesondere wird nicht gesichert, nicht ersetzt und kein Arbeitsordner angelegt.
$ergebnis = $this->updater()->run();
self::assertFalse($ergebnis['ok']);
self::assertCount(1, $ergebnis['steps']);
self::assertStringContainsString('Releaseseite', $ergebnis['steps'][0]['detail']);
self::assertSame([], glob($this->root.'/var/update/*') ?: []);
self::assertSame([], glob($this->root.'/var/backups/*') ?: []);
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE);
}
public function testTheNewestVersionAlreadyInstalledIsNotAFailure(): void
{
$this->releases->release(self::BASE, self::API, '1.0.0', $this->archive('1.0.0'));
$updater = $this->updater();
self::assertNull($updater->updateAvailable());
$ergebnis = $updater->run();
self::assertTrue($ergebnis['ok'], $this->explain($ergebnis));
self::assertFalse($ergebnis['migrate'] ?? false);
self::assertSame('Nichts zu tun', end($ergebnis['steps'])['step']);
self::assertSame([], glob($this->root.'/var/backups/*') ?: []);
}
public function testTheAvailableVersionIsOnlyReportedWhenItIsNewer(): void
{
self::assertNull($this->updater()->updateAvailable(), 'Ohne Antwort gibt es keine neue Fassung.');
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'));
self::assertSame('1.1.0', $this->updater()->updateAvailable());
// Etwas, das keine Fassung ist, wird nicht angeboten - sonst lädt der Updater irgendetwas.
$this->releases->serve(self::API.'/releases/latest', json_encode(['tag_name' => 'nightly']));
self::assertNull($this->updater()->updateAvailable());
}
public function testTheDownloaderRefusesEverythingThatIsNotHttps(): void
{
// Kein Netz nötig, um das zu prüfen: die Sperre greift vor dem ersten Byte. Ein Update
// über eine unverschlüsselte Verbindung könnte unterwegs ausgetauscht worden sein.
$download = new Download();
foreach (['http://releases.example.invalid/a.zip', 'ftp://releases.example.invalid/a.zip', 'file:///etc/passwd', '/tmp/a.zip'] as $url) {
self::assertNull($download->get($url), $url.' darf nicht geholt werden.');
self::assertStringContainsString('HTTPS', $download->reason(), 'Und es muss dastehen, woran es lag.');
}
}
public function testAFailedDownloadSaysWhatWentWrong(): void
{
// Drei Ursachen sehen in der Verwaltung sonst gleich aus: kein Weg nach draußen, kein
// Zertifikatsspeicher, falsche Adresse. Die mittlere ist auf einem Webhosting die
// häufigste und in einer Minute behoben - wenn jemand sie liest.
$download = new Download(connectTimeout: 2, timeout: 5);
self::assertNull($download->get('https://kein-server.invalid/datei.zip'));
self::assertNotSame('', $download->reason(), 'Ein Fehlschlag ohne Grund ist eine Sackgasse.');
// Und der Weg durch den Updater: Die Meldung im Schritt trägt den Grund mit.
$this->releases->serve(self::API.'/releases/latest', json_encode(['tag_name' => 'v1.1.0']));
$this->releases->serve(sprintf('%s/v1.1.0/kantsites-1.1.0.zip.sha256', self::BASE), str_repeat('a', 64));
$ergebnis = $this->updater()->run();
self::assertFalse($ergebnis['ok']);
$gescheitert = $this->firstFailed($ergebnis);
self::assertSame('Archiv laden', $gescheitert['step']);
self::assertStringContainsString('herunterladen', $gescheitert['detail']);
}
public function testEveryStepIsWrittenToTheLogWhileItHappens(): void
{
// Auf Umuts Hosting endete die Ergebnisseite mit 500, und damit war auch verloren, was
// der Updater berichtet hatte. Eine Zeile in einer Datei überlebt das - und auf einem
// Hosting ohne Konsole ist sie manchmal das Einzige, was von einem gescheiterten Update
// übrig bleibt. Geschrieben wird während des Laufs, nicht am Ende: Ein Lauf, der
// mittendrin abbricht, hat sonst nichts hinterlassen.
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'), str_repeat('a', 64));
$this->updater()->run();
$protokoll = (string) file_get_contents($this->root.'/var/updates.log');
self::assertStringContainsString('Update aus dem Archiv', $protokoll);
self::assertStringContainsString('ok Fassung ermitteln', $protokoll);
self::assertStringContainsString('FEHLER Prüfsumme', $protokoll, 'Gerade der gescheiterte Schritt muss dort stehen.');
}
public function testTheUpdaterReplacesEveryEntryTheReleaseArchiveShips(): void
{
// Der Fehler, der schon einmal auf dem Hosting aufgeschlagen ist, andersherum: Kommt eine
// Datei neu ins Release, ohne dass sie hier in der Ersetzungsliste steht, bleibt sie beim
// Update auf dem alten Stand - und das fällt erst auf, wenn etwas nicht mehr passt.
$skript = (string) file_get_contents(__DIR__.'/../scripts/build-release.php');
preg_match('/\$include = \[(.+?)\];/s', $skript, $mit);
self::assertNotEmpty($mit, 'Die Mitnahmeliste des Releaseskripts ist nicht auffindbar.');
$quelle = (string) file_get_contents(__DIR__.'/../src/Ops/ArchiveUpdate.php');
preg_match('/ERSETZEN = \[(.+?)\];/s', $quelle, $ersetzt);
self::assertNotEmpty($ersetzt, 'Die Ersetzungsliste des Updaters ist nicht auffindbar.');
$liste = static fn (string $roh): array => array_filter(array_map(
static fn (string $eintrag): string => trim($eintrag, " '\r\n\t"),
explode(',', $roh),
));
// public/ wird Datei für Datei zusammengeführt statt ersetzt, die drei anderen sind
// Beiwerk, das eine laufende Installation nicht liest.
// plugins/ ebenso Datei für Datei: mitgelieferte Plugins werden aktualisiert, ein vom
// Betreiber installiertes bleibt stehen (ADR 102).
$anders = ['public', 'plugins', 'README.md', 'LICENSE', '.env.example'];
foreach ($liste($mit[1]) as $eintrag) {
self::assertContains(
$eintrag,
[...$liste($ersetzt[1]), ...$anders],
sprintf('„%s" liegt im Releasearchiv, wird beim Update aber weder ersetzt noch bewusst ausgelassen.', $eintrag),
);
}
}
private function updater(): ArchiveUpdate
{
$runner = new CommandRunner();
$url = 'sqlite:///'.$this->root.'/var/cms.db';
return new ArchiveUpdate(
$this->root,
new Updater($this->root, $url, $runner, $this->db),
new Backup($this->root, $url, $this->root.'/var/media', $this->db, $runner),
$this->releases,
self::BASE,
self::API,
);
}
/**
* Baut ein Releasearchiv, wie `scripts/build-release.php` es baut: ein Verzeichnis
* `kantsites-<Fassung>/` im Zip, darin die Anwendung.
*/
private function archive(string $version, bool $vollstaendig = true): string
{
$pfad = $this->root.'/var/release-'.$version.'.zip';
@mkdir(dirname($pfad), 0o775, true);
$zip = new \ZipArchive();
self::assertTrue($zip->open($pfad, \ZipArchive::CREATE | \ZipArchive::OVERWRITE));
$inhalt = [
'VERSION' => $version."\n",
'src/Neu.php' => "<?php // neu\n",
'bin/console' => "<?php // neu\n",
'config/services.yaml' => "services:\n",
'templates/base.html.twig' => 'neu',
'migrations.php' => "<?php return ['neu'];\n",
'cli-config.php' => "<?php // neu\n",
'composer.json' => '{"name":"neu"}',
'composer.lock' => '{"packages":["neu"]}',
'public/index.php' => "<?php // neu\n",
'public/assets/site.css' => 'body{color:neu}',
// Das Archiv bringt eigene Schriften und ein eigenes Bündel mit - beides darf die
// Installation nicht überschreiben.
'public/assets/fonts/andere.woff2' => 'FREMD',
'public/assets/build/bundle.css' => 'body{color:fremd}',
];
if ($vollstaendig) {
$inhalt['vendor/autoload.php'] = "<?php // neu\n";
}
foreach ($inhalt as $name => $text) {
$zip->addFromString('kantsites-'.$version.'/'.$name, $text);
}
$zip->close();
$roh = (string) file_get_contents($pfad);
unlink($pfad);
return $roh;
}
private function write(string $relativ, string $inhalt): void
{
@mkdir(dirname($this->root.'/'.$relativ), 0o775, true);
file_put_contents($this->root.'/'.$relativ, $inhalt);
}
/** @param array{steps: list<array{step: string, ok: bool, detail: string}>} $ergebnis */
private function firstFailed(array $ergebnis): array
{
foreach ($ergebnis['steps'] as $schritt) {
if (!$schritt['ok']) {
return $schritt;
}
}
self::fail('Kein gescheiterter Schritt: '.$this->explain($ergebnis));
}
private function explain(array $ergebnis): string
{
return implode("\n", array_map(
static fn (array $schritt): string => sprintf('%s %s: %s', $schritt['ok'] ? 'ok' : 'FEHLER', $schritt['step'], $schritt['detail']),
$ergebnis['steps'],
));
}
private function removeDirectory(string $pfad): void
{
if (!is_dir($pfad)) {
@unlink($pfad);
return;
}
foreach (scandir($pfad) ?: [] as $eintrag) {
if ('.' !== $eintrag && '..' !== $eintrag) {
@chmod($pfad.'/'.$eintrag, 0o777);
$this->removeDirectory($pfad.'/'.$eintrag);
}
}
@rmdir($pfad);
}
}