Show a maintenance page instead of a blank 500 and update in one click
Emergency layer logs fatal errors to var/log/notfall.log and answers 503. The system page checks for a new release on load, shows its notes, runs backup, download, switch, migration and health check in one flow with live progress, and switches back automatically when the new version fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
339a48c810
commit
a6a334fe68
15 files changed
+973
-42
No files matched your search
@@ -2,14 +2,24 @@
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Backup;
|
||||
use App\Ops\CommandRunner;
|
||||
use App\Ops\Download;
|
||||
use App\Ops\Installation;
|
||||
use App\Ops\Updater;
|
||||
use App\Security\CmsUser;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher;
|
||||
|
||||
/**
|
||||
* Das Update über das Releasearchiv - der Weg für Installationen ohne Git.
|
||||
@@ -272,6 +282,77 @@ final class ArchiveUpdateTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
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("<?php // alt\n", file_get_contents($this->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();
|
||||
@@ -303,7 +384,8 @@ final class ArchiveUpdateTest extends TestCase
|
||||
'bin/console' => "<?php // neu\n",
|
||||
'config/services.yaml' => "services:\n",
|
||||
'templates/base.html.twig' => 'neu',
|
||||
'migrations.php' => "<?php return ['neu'];\n",
|
||||
'migrations.php' => "<?php return ['table_storage' => ['table_name' => 'migration_versions'], 'migrations_paths' => ['DoctrineMigrations' => __DIR__.'/migrations'], 'all_or_nothing' => false];\n",
|
||||
'migrations/.keep' => '',
|
||||
'cli-config.php' => "<?php // neu\n",
|
||||
'composer.json' => '{"name":"neu"}',
|
||||
'composer.lock' => '{"packages":["neu"]}',
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\EventSubscriber\ErrorLogSubscriber;
|
||||
use App\Ops\Notfall;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
|
||||
/**
|
||||
* Kein leerer 500er mehr (ADR 113).
|
||||
*
|
||||
* Kantarci Reisen am 09.10.2026: Datenbank per FTP ersetzt, danach eine weiße Seite auf jeder
|
||||
* Adresse und kein Hinweis darauf, warum. Seitdem sehen Besucher eine Wartungsseite mit 503, und
|
||||
* der Grund steht in var/log/notfall.log.
|
||||
*/
|
||||
final class NotfallTest extends TestCase
|
||||
{
|
||||
private string $root;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->root = str_replace('\\', '/', sys_get_temp_dir()).'/kant-notfall-'.bin2hex(random_bytes(6));
|
||||
mkdir($this->root, 0o775, true);
|
||||
Notfall::einschalten($this->root);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach (glob($this->root.'/var/log/*') ?: [] as $datei) {
|
||||
unlink($datei);
|
||||
}
|
||||
@rmdir($this->root.'/var/log');
|
||||
@rmdir($this->root.'/var');
|
||||
@rmdir($this->root);
|
||||
}
|
||||
|
||||
private function ereignis(\Throwable $fehler): ExceptionEvent
|
||||
{
|
||||
$kernel = $this->createStub(HttpKernelInterface::class);
|
||||
|
||||
return new ExceptionEvent($kernel, Request::create('/leistungen'), HttpKernelInterface::MAIN_REQUEST, $fehler);
|
||||
}
|
||||
|
||||
public function testAServerErrorBecomesAMaintenancePageAndALogLine(): void
|
||||
{
|
||||
$ursache = new \PDOException('file is not a database');
|
||||
$ereignis = $this->ereignis(new \RuntimeException('Abfrage fehlgeschlagen', 0, $ursache));
|
||||
|
||||
(new ErrorLogSubscriber(debug: false))->onException($ereignis);
|
||||
|
||||
$antwort = $ereignis->getResponse();
|
||||
self::assertNotNull($antwort);
|
||||
self::assertSame(503, $antwort->getStatusCode(), 'Für Suchmaschinen: gleich wieder da, nicht kaputt.');
|
||||
self::assertSame('300', $antwort->headers->get('Retry-After'));
|
||||
self::assertStringContainsString('Die Website ist gleich wieder da.', (string) $antwort->getContent());
|
||||
self::assertStringNotContainsString('database', (string) $antwort->getContent(), 'Einzelheiten gehören ins Protokoll, nicht auf die Seite.');
|
||||
$protokoll = (string) file_get_contents($this->root.'/'.Notfall::PROTOKOLL);
|
||||
self::assertStringContainsString('GET /leistungen – RuntimeException: Abfrage fehlgeschlagen', $protokoll);
|
||||
self::assertStringContainsString('Ursache: PDOException: file is not a database', $protokoll, 'Die eigentliche Ursache steckt eine Ebene tiefer.');
|
||||
}
|
||||
|
||||
public function testANotFoundIsNoEmergencyAndDevelopmentKeepsItsDetailedPage(): void
|
||||
{
|
||||
$nichtGefunden = $this->ereignis(new NotFoundHttpException('Gibt es nicht'));
|
||||
(new ErrorLogSubscriber(debug: false))->onException($nichtGefunden);
|
||||
self::assertNull($nichtGefunden->getResponse());
|
||||
self::assertFileDoesNotExist($this->root.'/'.Notfall::PROTOKOLL);
|
||||
|
||||
// „Zugriff verweigert“ führt zur Anmeldung - die Sicherheitsschicht entscheidet, nicht der Notfall.
|
||||
$verweigert = $this->ereignis(new \Symfony\Component\Security\Core\Exception\AccessDeniedException());
|
||||
(new ErrorLogSubscriber(debug: false))->onException($verweigert);
|
||||
self::assertNull($verweigert->getResponse());
|
||||
self::assertFileDoesNotExist($this->root.'/'.Notfall::PROTOKOLL);
|
||||
|
||||
$entwicklung = $this->ereignis(new \LogicException('Fehler beim Entwickeln'));
|
||||
(new ErrorLogSubscriber(debug: true))->onException($entwicklung);
|
||||
self::assertNull($entwicklung->getResponse(), 'Im Entwicklungsmodus bleibt die ausführliche Fehlerseite.');
|
||||
self::assertStringContainsString('LogicException: Fehler beim Entwickeln', (string) file_get_contents($this->root.'/'.Notfall::PROTOKOLL));
|
||||
}
|
||||
|
||||
public function testTheFrontControllerLoadsTheEmergencyLayerBeforeAnythingElse(): void
|
||||
{
|
||||
$einstieg = (string) file_get_contents(dirname(__DIR__).'/public/index.php');
|
||||
self::assertLessThan(strpos($einstieg, 'config/bootstrap.php'), strpos($einstieg, 'Notfall::einschalten'), 'Scheitert schon der Start, muss die Notfallschicht stehen.');
|
||||
self::assertStringContainsString('catch (Throwable $fehler)', $einstieg);
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ final class ReleaseOnDisk extends Download
|
||||
/** Legt ein vollständiges Release ab: Releaseliste, Archiv und Prüfsumme. */
|
||||
public function release(string $base, string $api, string $version, string $archiv, ?string $checksum = null): void
|
||||
{
|
||||
$this->serve($api.'/releases/latest', json_encode(['tag_name' => 'v'.$version], JSON_THROW_ON_ERROR));
|
||||
$this->serve($api.'/releases/latest', json_encode(['tag_name' => 'v'.$version, 'name' => 'KantSites '.$version, 'body' => 'Fortschritt live.'], JSON_THROW_ON_ERROR));
|
||||
$this->serve(sprintf('%s/v%s/kantsites-%s.zip', $base, $version, $version), $archiv);
|
||||
$this->serve(sprintf('%s/v%s/kantsites-%s.zip.sha256', $base, $version, $version), sprintf(
|
||||
"%s kantsites-%s.zip\n",
|
||||
|
||||
@@ -306,6 +306,38 @@ final class SystemScreenTest extends WebTestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function testTheOneClickUpdateEndpointsAreForAdministratorsOnly(): void
|
||||
{
|
||||
// Der Fortschritt für die Systemseite (ADR 113): Redakteure sehen ihn nicht, ein Start
|
||||
// braucht das Token der Sitzung, ein Abschluss den Einmalschlüssel des laufenden Updates.
|
||||
$redaktion = $this->login('editor');
|
||||
$redaktion->request('GET', '/admin/system/update/status');
|
||||
self::assertSame(403, $redaktion->getResponse()->getStatusCode());
|
||||
|
||||
$admin = $this->login();
|
||||
$admin->request('GET', '/admin/system/update/status');
|
||||
self::assertSame(200, $admin->getResponse()->getStatusCode());
|
||||
self::assertArrayHasKey('installiert', json_decode((string) $admin->getResponse()->getContent(), true));
|
||||
|
||||
$admin->request('POST', '/admin/system/update/starten', [], [], ['HTTP_X_CSRF_TOKEN' => 'erfunden']);
|
||||
self::assertSame(403, $admin->getResponse()->getStatusCode());
|
||||
self::assertStringContainsString('Sitzung ist abgelaufen', (string) $admin->getResponse()->getContent());
|
||||
|
||||
$admin->request('POST', '/admin/system/update/abschliessen', ['token' => 'egal']);
|
||||
self::assertStringContainsString('bereits abgeschlossen', (string) $admin->getResponse()->getContent(), 'Ohne offenes Update gibt es nichts abzuschließen.');
|
||||
|
||||
$marke = dirname(__DIR__).'/'.ArchiveUpdate::MARKE;
|
||||
@mkdir(dirname($marke), 0o775, true);
|
||||
file_put_contents($marke, json_encode(['fassung' => '9.9.9', 'seit' => gmdate('c'), 'alt' => 'x', 'token' => str_repeat('b', 32)]));
|
||||
try {
|
||||
$admin->request('POST', '/admin/system/update/abschliessen', ['token' => 'falsch']);
|
||||
self::assertSame(403, $admin->getResponse()->getStatusCode());
|
||||
self::assertFileExists($marke);
|
||||
} finally {
|
||||
@unlink($marke);
|
||||
}
|
||||
}
|
||||
|
||||
public function testNobodyButAnAdministratorCanStartAMigration(): void
|
||||
{
|
||||
// Ein Knopf, der die Datenbank umbaut, ist das lohnendste Ziel auf diesem Bildschirm.
|
||||
|
||||
Reference in new issue
Block a user