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

+119 -1
View File
@@ -6,12 +6,14 @@ use App\Content\DemoContent;
use App\Content\DocumentValidator;
use App\Content\PageRepository;
use App\Domain\InvalidDocument;
use App\Domain\InvalidPage;
use App\Domain\StaleWrite;
use App\Infrastructure\ConnectionFactory;
use App\Kernel;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Schema\Schema;
use DoctrineMigrations\Version20260912000100;
use DoctrineMigrations\Version20260912000400;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
@@ -30,12 +32,14 @@ final class CmsTest extends TestCase
$this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->path));
$schema = new Schema();
(new Version20260912000100($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' => 'Kant CMS', 'default_locale' => 'de', 'theme' => '{}']);
foreach (['page-1' => 'home', 'page-2' => 'private'] as $id => $slug) {
$this->db->insert('page', ['id' => $id, 'slug' => $slug, 'locale' => 'de', 'website_id' => 'default', 'version' => 0]);
$this->db->insert('page', ['id' => $id, 'slug' => $slug, 'path' => $slug, 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]);
}
$this->pages = new PageRepository($this->db, new DocumentValidator());
}
@@ -219,6 +223,120 @@ final class CmsTest extends TestCase
self::assertSame('/', $response->headers->get('Location'));
}
public function testCreatedPageStaysPrivateUntilPublishedAndKeepsItsAddress(): void
{
$id = $this->pages->createPage('ueber-uns', null, 'page', DemoContent::starter('Über uns'));
self::assertSame(404, $this->request('/ueber-uns')->getStatusCode());
$page = $this->pages->findForEditor($id);
self::assertSame('Über uns', $page['document']['title']);
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
$response = $this->request('/ueber-uns');
self::assertSame(200, $response->getStatusCode());
self::assertStringContainsString('>Über uns</h1>', $response->getContent());
self::assertStringContainsString('href="https://cms.test/ueber-uns"', $response->getContent());
}
public function testChildPagesBuildNestedPathsAndSiblingSlugsStayIndependent(): void
{
$parent = $this->pages->createPage('leistungen', null, 'page', DemoContent::starter('Leistungen'));
$child = $this->pages->createPage('webdesign', $parent, 'page', DemoContent::starter('Webdesign'));
$other = $this->pages->createPage('beratung', null, 'page', DemoContent::starter('Beratung'));
// The same slug under a different parent is a different page, not a collision.
$twin = $this->pages->createPage('webdesign', $other, 'page', DemoContent::starter('Webdesign kompakt'));
foreach ([$parent => 'leistungen', $child => 'leistungen/webdesign', $twin => 'beratung/webdesign'] as $id => $path) {
$draft = $this->pages->findForEditor($id);
$this->pages->publish($id, $draft['draft_revision_id'], $draft['version']);
self::assertSame(200, $this->request('/'.$path)->getStatusCode(), $path);
}
self::assertSame('Webdesign', $this->pages->findPublished('leistungen/webdesign')['document']['title']);
self::assertSame('Webdesign kompakt', $this->pages->findPublished('beratung/webdesign')['document']['title']);
}
/**
* @return iterable<string, array{string, string|null, string}>
*/
public static function rejectedPages(): iterable
{
yield 'uppercase slug' => ['Über Uns', null, 'page'];
yield 'path separator in slug' => ['a/b', null, 'page'];
yield 'empty slug' => ['', null, 'page'];
yield 'reserved slug' => ['admin', null, 'page'];
yield 'unknown type' => ['gueltig', null, 'template'];
yield 'missing parent' => ['gueltig', 'does-not-exist', 'page'];
}
#[DataProvider('rejectedPages')]
public function testRejectsInvalidAddressesWithoutWriting(string $slug, ?string $parent, string $type): void
{
$before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM page');
try {
$this->pages->createPage($slug, $parent, $type, DemoContent::starter('Titel'));
self::fail('Expected the page to be rejected.');
} catch (InvalidPage) {
}
self::assertSame($before, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
}
public function testDuplicateAddressAndExcessiveDepthAreRejected(): void
{
$this->pages->createPage('kontakt', null, 'page', DemoContent::starter('Kontakt'));
$this->expectException(InvalidPage::class);
$this->pages->createPage('kontakt', null, 'page', DemoContent::starter('Kontakt erneut'));
}
public function testHierarchyStopsAtTheDocumentedDepthAndPostsTakeNoChildren(): void
{
$id = null;
for ($level = 1; $level <= PageRepository::MAX_DEPTH; ++$level) {
$id = $this->pages->createPage('ebene-'.$level, $id, 'page', DemoContent::starter('Ebene '.$level));
}
try {
$this->pages->createPage('zu-tief', $id, 'page', DemoContent::starter('Zu tief'));
self::fail('Expected the depth limit to apply.');
} catch (InvalidPage) {
}
$post = $this->pages->createPage('erster-beitrag', null, 'post', DemoContent::starter('Erster Beitrag', 'post'));
$this->expectException(InvalidPage::class);
$this->pages->createPage('unter-beitrag', $post, 'page', DemoContent::starter('Unter Beitrag'));
}
public function testDeleteRemovesEveryRevisionButRefusesStaleVersionsAndParents(): void
{
$parent = $this->pages->createPage('eltern', null, 'page', DemoContent::starter('Eltern'));
$child = $this->pages->createPage('kind', $parent, 'page', DemoContent::starter('Kind'));
$version = $this->pages->findForEditor($parent)['version'];
try {
$this->pages->deletePage($parent, $version);
self::fail('Expected the parent to be protected.');
} catch (InvalidPage) {
}
self::assertNotNull($this->pages->findForEditor($parent));
$childVersion = $this->pages->findForEditor($child)['version'];
try {
$this->pages->deletePage($child, $childVersion - 1);
self::fail('Expected a stale delete to be refused.');
} catch (StaleWrite) {
}
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', [$child]));
$this->pages->deletePage($child, $childVersion);
self::assertNull($this->pages->findForEditor($child));
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', [$child]));
// Deleting the child frees the parent and leaves every other page untouched.
$this->pages->deletePage($parent, $this->pages->findForEditor($parent)['version']);
self::assertSame(['home', 'private'], $this->db->fetchFirstColumn('SELECT slug FROM page ORDER BY slug'));
}
public function testCmsRoutesAreNotSwallowedByThePublicPathRoute(): void
{
// The catch-all would otherwise answer these as a missing public page with 404.
foreach (['/admin', '/admin/media'] as $url) {
self::assertSame(302, $this->request($url)->getStatusCode(), $url);
}
self::assertStringContainsString('name="_username"', $this->request('/admin/login')->getContent());
}
private function request(string $url, string $method = 'GET'): Response
{
$previous = [];