Files
KantSites/tests/UpdaterTest.php
T
kantarci 495b26896e
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.7.0: verlässliche Installation, übersichtliche Verwaltung und bedienbare Bewegung
2026-09-14 10:49:54 +02:00

338 lines
15 KiB
PHP

<?php
namespace App\Tests;
use App\Infrastructure\ConnectionFactory;
use App\Ops\CommandRunner;
use App\Ops\Updater;
use PHPUnit\Framework\TestCase;
/**
* The updater against real Git repositories in a temporary directory.
*
* Not against this project: an update checks out a different commit, and a test that does that to
* the working copy it runs in would be a way to lose work. The repositories here are throwaway,
* and the two commands the updater calls - the console and the migration tool - are tiny stub
* scripts inside them. That keeps the test about the updater's order and its rollback, while
* still exercising real git, real file copies and real failures.
*/
final class UpdaterTest extends TestCase
{
private string $temp;
private string $work;
private string $author;
private CommandRunner $runner;
protected function setUp(): void
{
$this->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 testTrackedEnvironmentFromAnOlderTagCannotReplaceInstallationSettings(): void
{
file_put_contents($this->author.'/.env', "DATABASE_URL=sqlite:///release-default.db\n");
$this->release('1.1.0', true);
$this->git(['fetch', '--tags', 'origin'], $this->work);
$this->git(['checkout', 'v1.1.0'], $this->work);
$settings = "DATABASE_URL=sqlite:///customer.db\nAPP_SECRET=customer-secret\n";
file_put_contents($this->work.'/.env', $settings);
$this->git(['update-index', '--assume-unchanged', '.env'], $this->work);
unlink($this->author.'/.env');
$this->release('1.2.0', true);
$result = $this->updater()->update();
self::assertTrue($result['ok'], $this->explain($result));
self::assertSame($settings, file_get_contents($this->work.'/.env'));
}
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);
self::assertTrue($this->named($result['steps'], 'Datenbank zurückschreiben', 'step')['ok']);
// 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 testAFailedDatabaseRestoreKeepsTheSiteInMaintenance(): void
{
$this->release('1.2.0', false, loseBackup: true);
$result = $this->updater()->update();
self::assertFalse($result['ok']);
self::assertFalse($this->named($result['steps'], 'Datenbank zurückschreiben', 'step')['ok']);
self::assertFileExists($this->work.'/'.Updater::MAINTENANCE_FILE);
self::assertNotContains('Wieder online', array_column($result['steps'], 'step'));
}
public function testAnUpdateThatChangesDependenciesStopsWhenComposerIsMissing(): void
{
// The installation carries the lock of 1.0.0; the new version brings a different one. On
// hosting without Composer that cannot be resolved: vendor/ is outside Git and stays old.
$this->release('1.3.0', true, 'neu');
$updater = $this->updater();
$result = $updater->update();
self::assertFalse($result['ok'], $this->explain($result));
$failed = $this->firstFailed($result);
self::assertSame('Abhängigkeiten', $failed['step']);
self::assertStringContainsString('Releasearchiv', $failed['detail']);
// Rolled back, and the site is online again on the previous version.
self::assertSame('1.0.0', $updater->version());
self::assertFalse(is_file($this->work.'/'.Updater::MAINTENANCE_FILE));
}
public function testAnUpdateWithUnchangedDependenciesRunsWithoutComposer(): void
{
// Same dependencies as the installed version, so vendor/ stays valid.
$this->release('1.4.0', true, 'alt');
$result = $this->updater()->update();
self::assertTrue($result['ok'], $this->explain($result));
self::assertStringContainsString('composer.lock ist unverändert', $this->named($result['steps'], 'Abhängigkeiten', 'step')['detail']);
}
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, string $lock = 'alt', bool $loseBackup = false): void
{
file_put_contents($this->author.'/composer.lock', json_encode(['packages' => [$lock]]));
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
? "<?php\nfwrite(STDOUT, 'OK '.implode(' ', array_slice(\$argv, 1)).PHP_EOL);\nexit(0);\n"
: "<?php\nfile_put_contents(__DIR__.'/../../var/cms.db', 'Unvollständige Migration');\nfwrite(STDOUT, 'Migration gescheitert'.PHP_EOL);\nexit(1);\n";
file_put_contents($this->author.'/bin/console', $healthy ? $stub : "<?php\nfwrite(STDOUT, 'OK'.PHP_EOL);\nexit(0);\n");
if ($loseBackup) {
$stub = str_replace('exit(1);', "foreach (glob(__DIR__.'/../../var/backups/update-*/cms.db') ?: [] as \$backup) { unlink(\$backup); } exit(1);", $stub);
}
file_put_contents($this->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<array<string, mixed>> $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);
}
}