Zwei Luecken, die erst das Veroeffentlichen von 1.4.3 gezeigt hat. - Es gab keinen Weg, ausstehende Migrationen im Browser auszufuehren. Wer die Dateien per FTP hochlaedt - und das muss genau einmal jeder tun, dessen Installation die Updatekarte noch nicht kennt -, hatte danach neuen Code und eine alte Datenbank, und dafuer nur die Konsole. Also genau das, was auf geteiltem Hosting fehlt. Der Bildschirm "System" zeigt jetzt eine Karte, sobald etwas aussteht, nennt die Namen und fuehrt sie auf Knopfdruck aus. - "Die Releaseseite antwortet nicht" waren drei Ursachen in einem Satz. Beim Gegenpruefen des frischen Releases holte der Updater von hier gar nichts - nicht weil die Leitung zu waere, sondern weil diesem PHP der Zertifikatsspeicher fehlt. Auf einem Webhosting ist das der haeufigste Grund und der einzige, den man in einer Minute behebt; er steht jetzt in der Meldung. Bei genau diesem Fehler wird zusaetzlich der Weg ueber den Dateizeiger versucht - damit funktioniert der Abruf auf diesem Rechner. Abgeschaltet wird die Pruefung nicht: ein Update ohne Zertifikatspruefung koennte unterwegs ausgetauscht worden sein. Dazu der manuelle Weg Schritt fuer Schritt in docs/UPDATE.md und ein Test, der beides festhaelt: dass die Karte auffaellt und dass nur ein angemeldeter Administrator mit gueltigem Formular migrieren kann. Geprueft: 431 Tests auf PHP 8.2 und 8.4, Editor-, Browser- und Linkpruefung, Editor-Modelltests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
135 lines
6.1 KiB
PHP
135 lines
6.1 KiB
PHP
<?php
|
|
|
|
namespace App\Tests;
|
|
|
|
use App\Content\DemoContent;
|
|
use App\Content\DocumentValidator;
|
|
use App\Content\MediaLibrary;
|
|
use App\Content\PageRepository;
|
|
use App\Content\PartialRepository;
|
|
use App\Content\SiteSettings;
|
|
use App\Infrastructure\ConnectionFactory;
|
|
use App\Kernel;
|
|
use Doctrine\DBAL\Connection;
|
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
|
|
|
/**
|
|
* Der Bildschirm „System“ - die einzige Stelle, an der eine Installation ohne Konsole erfährt,
|
|
* dass etwas mit ihr nicht stimmt.
|
|
*
|
|
* Geprüft wird hier nicht das Aussehen, sondern zwei Eigenschaften, die schweigend verschwinden
|
|
* können: dass ausstehende Migrationen dort **auffallen**, und dass niemand sie auslösen kann,
|
|
* der nicht angemeldeter Administrator ist. Beides fiel beim Veröffentlichen von 1.4.3 auf -
|
|
* siehe ADR 070.
|
|
*/
|
|
final class SystemScreenTest extends WebTestCase
|
|
{
|
|
private const FIXTURE_PASSWORD = 'Fixture-only-password';
|
|
|
|
private Connection $db;
|
|
private string $path;
|
|
private array $previous;
|
|
|
|
protected static function getKernelClass(): string
|
|
{
|
|
return Kernel::class;
|
|
}
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->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;
|
|
}
|
|
}
|