Seiten im Browser anlegen und löschen, Adresshierarchie und Beitragstyp

Migration 20260912000400 ergänzt page.path, parent_id, type und
created_at. Die websiteweite Eindeutigkeit wandert vom Slug auf den
vollständigen Pfad, damit gleiche Segmente unter verschiedenen Eltern
möglich sind. Nur ADD COLUMN: Index- oder Fremdschlüsseländerungen
würden SQLite die Tabelle neu aufbauen lassen, was am Fremdschlüssel
revision.page_id scheitert; die Indizes werden in postUp() gesetzt.

POST /admin/pages legt Seiten und Beiträge als privaten Entwurf an
(ROLE_EDITOR), POST /admin/pages/{id}/delete entfernt eine Seite mit
allen Revisionen (ROLE_ADMIN). Beides mit CSRF, ohne JavaScript
bedienbar, mit Schutz für Elternseiten und veraltete Versionen.
Die öffentliche Route bedient verschachtelte Pfade und lässt die
CMS-Routen unberührt.

Testumgebung: Login-Throttling zählt jetzt in einem Array-Cache,
weil die persistierten Zähler die Anmeldung über Testläufe hinweg
aussperrten.

Geprüft: 56 PHP-Tests / 212 Assertions unter PHP 8.2.12 und 8.4.22,
10 JavaScript-Modelltests, Lint/Format/Container, Migration zuerst
gegen eine Datenbankkopie. Keine Browser- oder Lighthouse-Läufe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 10:31:46 +02:00
1 parent 82b07820bc
commit a6ffb9bdfd
16 files changed
+555 -23

No files matched your search

+52 -1
View File
@@ -13,6 +13,7 @@ use Doctrine\DBAL\Schema\Schema;
use DoctrineMigrations\Version20260912000100;
use DoctrineMigrations\Version20260912000200;
use DoctrineMigrations\Version20260912000300;
use DoctrineMigrations\Version20260912000400;
use Psr\Log\NullLogger;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
@@ -42,11 +43,13 @@ final class EditorSecurityTest extends WebTestCase
(new Version20260912000100($this->db, new NullLogger()))->up($schema);
(new Version20260912000200($this->db, new NullLogger()))->up($schema);
(new Version20260912000300($this->db, new NullLogger()))->up($schema);
(new Version20260912000400($this->db, new NullLogger()))->up($schema);
foreach ($schema->toSql($this->db->getDatabasePlatform()) as $sql) {
$this->db->executeStatement($sql);
}
Version20260912000400::applyIndexes($this->db);
$this->db->insert('website', ['id' => 'default', 'name' => 'Test', 'default_locale' => 'de', 'theme' => '{}']);
$this->db->insert('page', ['id' => 'p1', 'slug' => 'home', 'locale' => 'de', 'website_id' => 'default', 'version' => 0]);
$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());
$revision = $pages->saveDraft('p1', DemoContent::pages()['home'], 0);
$pages->publish('p1', $revision, 1);
@@ -107,6 +110,54 @@ final class EditorSecurityTest extends WebTestCase
$this->post($client, $action, []);
self::assertResponseRedirects('http://localhost/admin/login');
}
foreach ([['/admin/pages', ['title' => 'Geschmuggelt', 'slug' => 'geschmuggelt']], ['/admin/pages/p1/delete', ['expectedVersion' => 2]]] as [$url, $body]) {
$client->request('POST', $url, $body);
self::assertResponseRedirects('http://localhost/admin/login');
}
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
}
public function testCreatingAPageNeedsACsrfTokenAndStartsAsAPrivateDraft(): void
{
$client = $this->login('editor');
$client->request('POST', '/admin/pages', ['title' => 'Ohne Token', 'slug' => 'ohne-token', '_csrf_token' => 'wrong']);
self::assertResponseRedirects('/admin');
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
$crawler = $client->request('GET', '/admin');
$client->submit($crawler->filter('.create-form')->form(['title' => 'Über uns', 'slug' => '', 'type' => 'page', 'parent_id' => '']));
self::assertResponseRedirects();
// The address is derived from the title when the field is left empty.
self::assertSame('ueber-uns', $this->db->fetchOne('SELECT path FROM page WHERE slug <> ?', ['home']));
// A new page is a draft, so it must not answer publicly yet.
$client->request('GET', '/ueber-uns');
self::assertResponseStatusCodeSame(404);
}
public function testEditorsAreNeitherOfferedNorAllowedToDelete(): void
{
$client = $this->login('editor');
$crawler = $client->request('GET', '/admin');
self::assertCount(0, $crawler->filter('.delete-panel'), 'An editor must not be offered the delete action.');
$client->request('POST', '/admin/pages/p1/delete', ['_csrf_token' => $crawler->filter('input[name="_csrf_token"]')->last()->attr('value'), 'expectedVersion' => 2]);
self::assertResponseStatusCodeSame(403);
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
}
public function testAdministratorsDeleteAPageWithAllRevisionsOnlyWithACsrfToken(): void
{
$client = $this->login('admin');
$client->request('POST', '/admin/pages/p1/delete', ['_csrf_token' => 'wrong', 'expectedVersion' => 2]);
self::assertResponseRedirects('/admin');
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
$crawler = $client->request('GET', '/admin');
$client->submit($crawler->filter('.delete-panel form')->form());
self::assertResponseRedirects('/admin');
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision'));
$client->request('GET', '/');
self::assertResponseStatusCodeSame(404);
}
public function testMediaUploadReencodingCsrfMetadataConflictAndArchive(): void