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', "write('src/Alt.php', "write('composer.json', '{"name":"alt"}'); $this->write('composer.lock', '{"packages":[]}'); $this->write('migrations.php', "write('cli-config.php', "write('public/index.php', "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', '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). // Die .htaccess im Projektstamm ersetzt das Update nicht: sie kann dem Betreiber gehören. // Fehlt sie, legt die Website sie selbst an (App\Ops\Protection, ADR 116). $anders = ['public', 'plugins', 'README.md', 'LICENSE', '.env.example', '.htaccess']; 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), ); } } public function testTheProgressFileFollowsTheUpdateAndAFinishedOneSaysSo(): void { // Die Systemseite fragt diesen Fortschritt ab, während eine andere Anfrage das Update // ausführt (ADR 113) - Phase, Meldung, von welcher auf welche Fassung. $this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0')); $updater = $this->updater(); self::assertNull($updater->status()); self::assertSame('Fortschritt live.', $updater->latest()['notes'] ?? null, 'Die Hinweise der Releaseseite gehören zur neuen Fassung.'); $ergebnis = $updater->run(); self::assertTrue($ergebnis['ok'], $this->explain($ergebnis)); $status = $updater->status(); self::assertSame(['migrieren', true, '1.0.0', '1.1.0'], [$status['phase'], $status['laeuft'], $status['von'], $status['auf']]); $fertig = $this->updater()->finish($this->installation()); self::assertTrue($fertig['ok'], $this->explain($fertig)); self::assertSame(['fertig', false], [$this->updater()->status()['phase'], $this->updater()->status()['laeuft']]); self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE); } public function testANewVersionThatDoesNotRunIsSwitchedBackCompletely(): void { // Wie bei reiseSoft: Läuft die neue Fassung nach den Migrationen nicht, kommt die alte // zurück - Code, public/ und Datenbank -, und erst dann geht die Wartungsseite aus. $this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0')); $this->db->insert('website', ['id' => 'default', 'name' => 'Vorher', 'default_locale' => 'de', 'theme' => '{}']); $ergebnis = $this->updater()->run(); self::assertTrue($ergebnis['ok'], $this->explain($ergebnis)); self::assertSame('1.1.0', trim((string) file_get_contents($this->root.'/VERSION'))); // Etwas, das die neue Fassung in der Datenbank anrichtet, bevor die Prüfung scheitert. $this->db->update('website', ['name' => 'Nachher'], ['id' => 'default']); $ende = $this->updater()->finish($this->installation(), static fn (): string => 'Die Startseite antwortet mit 500.'); self::assertFalse($ende['ok']); self::assertTrue($ende['zurueckgeschaltet'] ?? false, $this->explain($ende)); self::assertSame('1.0.0', trim((string) file_get_contents($this->root.'/VERSION'))); self::assertFileExists($this->root.'/src/Alt.php'); self::assertFileDoesNotExist($this->root.'/src/Neu.php'); self::assertSame("root.'/public/index.php'), 'Auch public/ kommt zurück.'); self::assertSame('body{color:alt}', file_get_contents($this->root.'/public/assets/site.css')); self::assertSame('SCHRIFT', file_get_contents($this->root.'/public/assets/fonts/eigene.woff2')); self::assertFileDoesNotExist($this->root.'/public/assets/fonts/andere.woff2'); $this->db->close(); $this->db = ConnectionFactory::create('sqlite:///'.$this->root.'/var/cms.db'); self::assertSame('Vorher', $this->db->fetchOne("SELECT name FROM website WHERE id = 'default'"), 'Die Datenbank kommt aus der Sicherung vor dem Update.'); self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE, 'Zurückgeschaltet heißt: die Website läuft wieder.'); self::assertNull($this->updater()->pending()); $status = $this->updater()->status(); self::assertSame(['zurueckgerollt', 'pruefen', false], [$status['phase'], $status['bei'], $status['laeuft']], 'Gescheitert ist die Prüfung - genau dieser Schritt wird markiert.'); self::assertStringContainsString('Zurückgeschaltet auf 1.0.0', $status['meldung']); } private function installation(): Installation { $settings = new SiteSettings($this->db, new MediaLibrary($this->db, $this->root.'/var/media')); $validator = new DocumentValidator(); return new Installation( $this->db, new PageRepository($this->db, $validator, $settings), new PartialRepository($this->db, $validator), new MenuRepository($this->db, $settings), $settings, new UserPasswordHasher(new PasswordHasherFactory([CmsUser::class => ['algorithm' => 'auto']])), $this->root, $this->root.'/var/media', 'test-secret', ); } 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-/` 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' => " " "services:\n", 'templates/base.html.twig' => 'neu', 'migrations.php' => " ['table_name' => 'migration_versions'], 'migrations_paths' => ['DoctrineMigrations' => __DIR__.'/migrations'], 'all_or_nothing' => false];\n", 'migrations/.keep' => '', 'cli-config.php' => " '{"name":"neu"}', 'composer.lock' => '{"packages":["neu"]}', 'public/index.php' => " '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'] = " $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} $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); } }