runner = new CommandRunner(); if (!$this->runner->available() || !$this->runner->has('git', sys_get_temp_dir())) { self::markTestSkipped('Ohne proc_open und git ist der Updater nicht prüfbar.'); } $this->temp = sys_get_temp_dir().'/kant-update-'.bin2hex(random_bytes(6)); mkdir($this->temp, 0o775, true); $origin = $this->temp.'/origin.git'; $this->work = $this->temp.'/work'; $this->author = $this->temp.'/author'; $this->git(['init', '--bare', '--initial-branch=main', $origin], $this->temp); $this->git(['clone', $origin, $this->author], $this->temp); $this->configure($this->author); $this->release('1.0.0', true); $this->git(['clone', $origin, $this->work], $this->temp); $this->configure($this->work); // The installation: a database and a media folder, both outside Git. mkdir($this->work.'/var/media', 0o775, true); $db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->work.'/var/cms.db')); $db->executeStatement('CREATE TABLE revision (id INTEGER PRIMARY KEY, body TEXT)'); $db->executeStatement("INSERT INTO revision (body) VALUES ('Inhalt von vor dem Update')"); $db->close(); } protected function tearDown(): void { $this->remove($this->temp); } public function testThePreflightNamesEveryConditionAndWhatIsWrongWithIt(): void { $checks = $this->updater()->check(); $names = array_column($checks, 'name'); self::assertContains('Keine lokalen Änderungen', $names); self::assertContains('Datenbank außerhalb von Git', $names); self::assertContains('Medien außerhalb von Git', $names); self::assertSame([], array_column(array_filter($checks, static fn (array $check): bool => !$check['ok']), 'name')); // A changed file would be lost by a checkout, so the preflight refuses. file_put_contents($this->work.'/README.md', 'lokal geändert'); $dirty = $this->named($this->updater()->check(), 'Keine lokalen Änderungen'); self::assertFalse($dirty['ok']); self::assertStringContainsString('verwerfen', $dirty['detail']); // And a database inside Git would be overwritten by one. $this->git(['checkout', '--', 'README.md'], $this->work); file_put_contents($this->work.'/.gitignore', '/nichts-besonderes/ '); $this->commit($this->work, 'Datenbank nicht mehr ausgenommen'); $exposed = $this->named($this->updater()->check(), 'Datenbank außerhalb von Git'); self::assertFalse($exposed['ok']); self::assertStringContainsString('überschreiben', $exposed['detail']); } public function testAnUpdateMovesToTheNewTagAndLeavesTheSiteOnline(): void { $this->release('1.1.0', true); $updater = $this->updater(); self::assertSame('1.0.0', $updater->version()); self::assertSame('v1.1.0', $updater->availableVersion()); $result = $updater->update(); self::assertTrue($result['ok'], $this->explain($result)); self::assertSame('v1.1.0', $result['target']); self::assertSame('1.1.0', $updater->version(), 'The checked out tag is what is installed.'); self::assertFalse(is_file($this->work.'/'.Updater::MAINTENANCE_FILE), 'A finished update ends the maintenance state.'); // The order matters, so it is asserted. self::assertSame( ['Vorprüfung', 'Ziel bestimmen', 'Sicherung', 'Wartungszustand', 'Code wechseln', 'Fassung prüfen', 'Abhängigkeiten', 'Migration', 'Cache', 'Rauchtest', 'Fertig'], array_column($result['steps'], 'step'), ); // The backup was taken before anything changed, and the media folder is not in it. $backups = glob($this->work.'/var/backups/update-*'); self::assertCount(1, $backups); self::assertFileExists($backups[0].'/cms.db'); self::assertFileDoesNotExist($backups[0].'/media'); self::assertStringContainsString('Inhalt von vor dem Update', (string) file_get_contents($backups[0].'/cms.db')); // The database and the media folder of the installation are untouched. self::assertFileExists($this->work.'/var/cms.db'); self::assertDirectoryExists($this->work.'/var/media'); $log = $updater->log(); self::assertCount(1, $log); self::assertStringContainsString('OK v1.1.0', $log[0]); } public function testAFailingSmokeTestRollsBackTheCodeAndTheDatabase(): void { // The new version ships a console that fails - as a broken release would. $this->release('1.2.0', false); $updater = $this->updater(); $result = $updater->update(); self::assertFalse($result['ok']); $steps = array_column($result['steps'], 'step'); self::assertContains('Zurück auf den alten Stand', $steps); self::assertContains('Datenbank zurückschreiben', $steps); // The migration is where it broke, so the database had to go back too. self::assertSame('Migration', $this->firstFailed($result)['step']); self::assertSame('1.0.0', $updater->version(), 'A failed update leaves the previous version installed.'); self::assertStringContainsString('Inhalt von vor dem Update', (string) file_get_contents($this->work.'/var/cms.db')); // The old version answers, so visitors get the website back rather than a maintenance page. self::assertFalse(is_file($this->work.'/'.Updater::MAINTENANCE_FILE)); self::assertTrue($this->named($result['steps'], 'Wieder online', 'step')['ok']); self::assertStringContainsString('FEHLER v1.2.0', $updater->log()[0]); } public function testADryRunChangesNothing(): void { $this->release('1.1.0', true); $updater = $this->updater(); fwrite(STDERR, ' '); $result = $updater->update(null, true); self::assertTrue($result['ok'], $this->explain($result)); self::assertSame('1.0.0', $updater->version()); self::assertSame([], glob($this->work.'/var/backups/update-*') ?: []); self::assertFalse(is_file($this->work.'/'.Updater::MAINTENANCE_FILE)); self::assertSame([], $updater->log()); } public function testTheNewestVersionAlreadyInstalledIsNotAFailure(): void { $updater = $this->updater(); $result = $updater->update(); self::assertTrue($result['ok'], $this->explain($result)); self::assertSame('Nichts zu tun', end($result['steps'])['step']); self::assertSame([], glob($this->work.'/var/backups/update-*') ?: []); } public function testAnUnknownTagIsRefusedBeforeAnythingIsTouched(): void { $updater = $this->updater(); $result = $updater->update('v9.9.9'); self::assertFalse($result['ok']); self::assertSame('Code wechseln', $this->firstFailed($result)['step']); self::assertSame('1.0.0', $updater->version()); // The maintenance state was switched on before the checkout, and the rollback ends it. self::assertFalse(is_file($this->work.'/'.Updater::MAINTENANCE_FILE)); } private function updater(): Updater { $db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->work.'/var/cms.db')); return new Updater( str_replace('\\', '/', $this->work), 'sqlite:///'.str_replace('\\', '/', $this->work.'/var/cms.db'), $this->runner, $db, ); } /** * Publishes a release: VERSION, a .gitignore that keeps the installation out of Git, and the * two stub commands the updater calls. `$healthy` decides whether they succeed. */ private function release(string $version, bool $healthy): void { file_put_contents($this->author.'/VERSION', $version."\n"); file_put_contents($this->author.'/README.md', 'KantSites '.$version."\n"); // Only the installation is kept out of Git here: the stub commands live under vendor/ // and travel with the tag, the way vendor/ does on a host without Composer. file_put_contents($this->author.'/.gitignore', '/var/ '); foreach (['bin', 'vendor/bin'] as $directory) { if (!is_dir($this->author.'/'.$directory)) { mkdir($this->author.'/'.$directory, 0o775, true); } } // Stubs, not the real console: this test is about the updater's order and its rollback. $stub = $healthy ? "author.'/bin/console', $healthy ? $stub : "author.'/vendor/bin/doctrine-migrations', $stub); $this->commit($this->author, 'Fassung '.$version); $this->git(['tag', 'v'.$version], $this->author); $this->git(['push', '--quiet', 'origin', 'main', '--tags'], $this->author); } private function configure(string $directory): void { $this->git(['config', 'user.email', 'test@example.invalid'], $directory); $this->git(['config', 'user.name', 'Updater Test'], $directory); } private function commit(string $directory, string $message): void { $this->git(['add', '-A'], $directory); $this->git(['commit', '--quiet', '-m', $message], $directory); } private function git(array $arguments, string $cwd): void { $result = $this->runner->run(['git', ...$arguments], $cwd, 60); self::assertTrue($result['ok'], 'git '.implode(' ', $arguments).': '.$result['output']); } /** @param list> $entries */ private function named(array $entries, string $needle, string $key = 'name'): array { foreach ($entries as $entry) { if ($needle === $entry[$key]) { return $entry; } } self::fail(sprintf('Kein Eintrag „%s“ vorhanden: %s', $needle, implode(', ', array_column($entries, $key)))); } private function firstFailed(array $result): array { foreach ($result['steps'] as $step) { if (!$step['ok']) { return $step; } } self::fail('Kein gescheiterter Schritt: '.$this->explain($result)); } private function explain(array $result): string { return implode("\n", array_map( static fn (array $step): string => sprintf('%s %s: %s', $step['ok'] ? 'ok' : 'FEHLER', $step['step'], $step['detail']), $result['steps'], )); } private function remove(string $path): void { if (!is_dir($path)) { @unlink($path); return; } foreach (scandir($path) ?: [] as $entry) { if ('.' !== $entry && '..' !== $entry) { @chmod($path.'/'.$entry, 0o777); $this->remove($path.'/'.$entry); } } @rmdir($path); } }