338 lines
15 KiB
PHP
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);
|
|
}
|
|
}
|