Files
KantSites/tests/ArchiveUpdateTest.php
T
kantarciandClaude Opus 5 d295757ce0
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
Fassung 1.4.5: drei Fehler, die nur auf fremden Servern auftraten
Alle drei von Umut auf seinem Hosting gefunden, keiner davon auf
meinem Rechner sichtbar.

- Der Updateknopf endete mit 500. Das Update lief vollstaendig durch -
  alle neun Schritte, Dateien getauscht, var/ und .env unberuehrt -,
  aber die Ergebnisseite starb daran: der Updater ueber Git nennt
  einen Schritt "step", der ueber das Archiv nannte ihn "label", beide
  legen ihn unter demselben Sitzungsschluessel ab, und die Vorlage
  liest "step". Damit war auch der zweite Knopf unerreichbar. Zehn
  Tests prueften das Rueckgabe-Array, keiner die Seite, die es
  anzeigt; der neue Test rendert sie (ADR 071).
- Die Verwaltung zeigte Git-Fehler auf einer Installation ohne Git.
  "git ist installiert" und "das hier ist eine Arbeitskopie" waren
  derselbe Schalter; auf dem Hosting ist Git vorhanden, die
  Installation kam aber aus dem Archiv. Dort stand "fatal: Not a git
  repository" an der Stelle der Fassungsnummer. Ohne Arbeitskopie
  verschwinden die Git-Karten jetzt (ADR 072).
- "Sicherung anlegen" endete mit 500: VACUUM INTO gibt es erst seit
  SQLite 3.27, das Hosting hat eine aeltere Fassung - alt genug fuer
  die Installation, zu alt fuer die Sicherung. Bei aelteren wird jetzt
  unter einer Schreibsperre kopiert, und der Knopf faengt jeden Fehler
  ab statt mit einer 500-Seite zu enden (ADR 072).

Nachgestellt wurde das mit einer echten 1.4.3-Installation aus dem
Releasearchiv, eigener Datenbank, eigenem Port. Der Archiv-Updater
laeuft dort vollstaendig durch: 1.4.3 -> 1.4.4, neun Schritte, var/
und .env unberuehrt.

Geprueft: 433 Tests auf PHP 8.2 und 8.4, Editor-Browsertest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:54:13 +02:00

347 lines
16 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'));
$ergebnis = $this->updater()->run();
self::assertTrue($ergebnis['ok'], $this->explain($ergebnis));
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']);
}
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 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.
$anders = ['public', '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);
}
}