Update aus dem Releasearchiv
diff --git a/tests/ArchiveUpdateTest.php b/tests/ArchiveUpdateTest.php
index b505519..19cec7c 100644
--- a/tests/ArchiveUpdateTest.php
+++ b/tests/ArchiveUpdateTest.php
@@ -195,9 +195,30 @@ final class ArchiveUpdateTest extends TestCase
$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['label']);
+ self::assertStringContainsString('herunterladen', $gescheitert['detail']);
+ }
+
public function testTheUpdaterReplacesEveryEntryTheReleaseArchiveShips(): void
{
// Der Fehler, der schon einmal auf dem Hosting aufgeschlagen ist, andersherum: Kommt eine
diff --git a/tests/SystemScreenTest.php b/tests/SystemScreenTest.php
new file mode 100644
index 0000000..57033b8
--- /dev/null
+++ b/tests/SystemScreenTest.php
@@ -0,0 +1,134 @@
+path = tempnam(sys_get_temp_dir(), 'kant-system-');
+ $url = 'sqlite:///'.str_replace('\\', '/', $this->path);
+ $this->previous = [$_SERVER, $_ENV];
+ foreach (['APP_SECRET' => 'isolated-test-secret', 'DATABASE_URL' => $url, 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media'] as $key => $value) {
+ $_SERVER[$key] = $_ENV[$key] = $value;
+ }
+ $this->db = ConnectionFactory::create($url);
+ // Die Tabellen entstehen hier über das Testschema und nicht über die Migrationen - genau
+ // die Lage einer Installation, deren Dateien von Hand hochgeladen wurden: Der Code ist
+ // neu, die Migrationsliste der Datenbank kennt ihn nicht.
+ TestSchema::build($this->db);
+ $this->db->insert('website', ['id' => 'default', 'name' => 'Test', 'default_locale' => 'de', 'theme' => '{}']);
+ $this->db->insert('page', ['id' => 'p1', 'slug' => 'home', 'path' => 'home', 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]);
+ $pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
+ $pages->publish('p1', $pages->saveDraft('p1', DemoContent::pages()['home'], 0), 1);
+ $partials = new PartialRepository($this->db, new DocumentValidator());
+ foreach (DemoContent::areas() as $kind => $area) {
+ $partials->create($kind, 'default', $area['name'], $area['document']);
+ }
+ foreach (['admin' => 'ROLE_ADMIN', 'editor' => 'ROLE_EDITOR'] as $username => $role) {
+ $this->db->insert('cms_user', [
+ 'id' => bin2hex(random_bytes(8)),
+ 'username' => $username,
+ 'password_hash' => password_hash(self::FIXTURE_PASSWORD, PASSWORD_BCRYPT, ['cost' => 4]),
+ 'roles' => json_encode([$role], JSON_THROW_ON_ERROR),
+ 'created_at' => gmdate('Y-m-d H:i:s'),
+ 'password_changed_at' => gmdate('Y-m-d H:i:s'),
+ ]);
+ }
+ }
+
+ protected function tearDown(): void
+ {
+ parent::tearDown();
+ $this->db->close();
+ gc_collect_cycles();
+ @unlink($this->path);
+ foreach (glob($this->path.'-media/*') ?: [] as $file) {
+ unlink($file);
+ }
+ if (is_dir($this->path.'-media')) {
+ rmdir($this->path.'-media');
+ }
+ [$_SERVER, $_ENV] = $this->previous;
+ }
+
+ public function testOutstandingMigrationsAreImpossibleToOverlookAndCanBeRunFromThere(): void
+ {
+ $client = $this->login();
+ $client->request('GET', '/admin/system');
+ self::assertSame(200, $client->getResponse()->getStatusCode());
+ $html = (string) $client->getResponse()->getContent();
+
+ // Vorher gab es dafür nur die Konsole - also genau das, was auf geteiltem Hosting fehlt.
+ self::assertStringContainsString('Migrationen stehen aus', $html);
+ self::assertStringContainsString('Version20260912000100', $html, 'Die Namen gehören dazu: sie sagen, was passieren wird.');
+ self::assertStringContainsString('/admin/system/migrationen', $html);
+ // Eine Sicherung vorher ist keine Höflichkeit: Migrationen sind nicht umkehrbar.
+ self::assertStringContainsString('Sicherung', $html);
+ }
+
+ public function testNobodyButAnAdministratorCanStartAMigration(): void
+ {
+ // Ein Knopf, der die Datenbank umbaut, ist das lohnendste Ziel auf diesem Bildschirm.
+ self::ensureKernelShutdown();
+ $anonym = self::createClient();
+ $anonym->followRedirects(false);
+ $anonym->request('POST', '/admin/system/migrationen');
+ self::assertSame('http://localhost/admin/login', $anonym->getResponse()->headers->get('Location'));
+
+ $redaktion = $this->login('editor');
+ $redaktion->request('POST', '/admin/system/migrationen');
+ self::assertSame(403, $redaktion->getResponse()->getStatusCode(), 'Eine Redakteurin darf das nicht.');
+
+ // Und selbst als Administrator nicht ohne gültiges Formular aus dieser Sitzung.
+ $admin = $this->login();
+ $admin->request('POST', '/admin/system/migrationen', ['_csrf_token' => 'erfunden']);
+ $admin->followRedirect();
+ self::assertStringContainsString('Sitzung ist abgelaufen', (string) $admin->getResponse()->getContent());
+ self::assertStringContainsString('Migrationen stehen aus', (string) $admin->getResponse()->getContent(), 'Und es steht immer noch aus.');
+ }
+
+ private function login(string $username = 'admin'): KernelBrowser
+ {
+ self::ensureKernelShutdown();
+ $client = self::createClient();
+ $client->followRedirects(false);
+ $crawler = $client->request('GET', '/admin/login');
+ $client->submit($crawler->selectButton('Anmelden')->form(['_username' => $username, '_password' => self::FIXTURE_PASSWORD]));
+ $client->followRedirect();
+
+ return $client;
+ }
+}