365 lines
18 KiB
PHP
365 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']);
|
|
}
|
|
|
|
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.
|
|
$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);
|
|
}
|
|
}
|