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:
1 parent
82b07820bc
commit
a6ffb9bdfd
16 files changed
+555
-23
No files matched your search
+119
-1
@@ -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 = [];
|
||||
|
||||
Reference in new issue
Block a user