Migration 20260912000500 legt eine Tabelle menu an: ein Menü je Platz (header, footer) und Sprache, Elementbaum als JSON, Version für dieselbe Konflikterkennung wie bei Seiten. Ein Eintrag verweist auf eine Seite, auf ein Sprungziel innerhalb einer Seite oder auf eine Adresse (HTTPS, lokaler Pfad, mailto:, tel:). Seitenverweise speichern die Seiten-ID, nie den Pfad - ein Eintrag folgt damit einer umbenannten Seite. Beim Rendern fallen Einträge zu gelöschten oder unveröffentlichten Seiten heraus, ohne den gespeicherten Baum zu ändern und ohne die übrigen Einträge zu verlieren. Die Verwaltung nutzt eine nummerierte Zeilenliste statt Drag-and-drop und funktioniert damit vollständig ohne JavaScript; Untermenüs in der Ausgabe über details/summary. tests/bootstrap.php verwirft var/cache/test je Lauf. Ohne Debug prüft Symfony weder den kompilierten Container noch die Vorlagen auf Aktualität, was zweimal ein falsches Testergebnis erzeugt hat. Geprüft: 79 PHP-Tests / 285 Assertions unter PHP 8.2.12 und 8.4.22, 11 JavaScript-Modelltests, Lint/Format/Container, Migration zuerst gegen eine Datenbankkopie, wiederholtes Seeding. Keine Browser- oder Lighthouse-Läufe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
526 lines
27 KiB
PHP
526 lines
27 KiB
PHP
<?php
|
|
|
|
namespace App\Tests;
|
|
|
|
use App\Content\DemoContent;
|
|
use App\Content\DocumentValidator;
|
|
use App\Content\MenuRepository;
|
|
use App\Content\PageRepository;
|
|
use App\Domain\InvalidDocument;
|
|
use App\Domain\InvalidMenu;
|
|
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 DoctrineMigrations\Version20260912000500;
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Psr\Log\NullLogger;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
final class CmsTest extends TestCase
|
|
{
|
|
private Connection $db;
|
|
private PageRepository $pages;
|
|
private MenuRepository $menus;
|
|
private string $path;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->path = tempnam(sys_get_temp_dir(), 'kant-test-');
|
|
$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);
|
|
(new Version20260912000500($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, '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());
|
|
$this->menus = new MenuRepository($this->db);
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
$this->db->close();
|
|
gc_collect_cycles();
|
|
unlink($this->path);
|
|
}
|
|
|
|
public function testDocumentRoundTripAndDraftPrivacy(): void
|
|
{
|
|
$document = DemoContent::pages()['home'];
|
|
$id = $this->pages->saveDraft('page-1', $document, 0);
|
|
self::assertNull($this->pages->findPublished('home'));
|
|
self::assertSame($document, json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$id]), true));
|
|
$this->pages->publish('page-1', $id, 1);
|
|
self::assertSame($document, $this->pages->findPublished('home')['document']);
|
|
$document['title'] = 'Private change';
|
|
$this->pages->saveDraft('page-1', $document, 2);
|
|
self::assertNotSame('Private change', $this->pages->findPublished('home')['document']['title']);
|
|
}
|
|
|
|
public function testStaleSaveRollsBackWithoutOrphanRevision(): void
|
|
{
|
|
$this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0);
|
|
try {
|
|
$this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0);
|
|
self::fail('Stale write was accepted.');
|
|
} catch (StaleWrite) {
|
|
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision'));
|
|
self::assertSame(1, (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['page-1']));
|
|
}
|
|
}
|
|
|
|
public function testForeignRevisionCannotBePublished(): void
|
|
{
|
|
$id = $this->pages->saveDraft('page-2', DemoContent::pages()['home'], 0);
|
|
$this->expectException(\InvalidArgumentException::class);
|
|
$this->pages->publish('page-1', $id, 0);
|
|
}
|
|
|
|
public function testPublicationRejectsStaleVersion(): void
|
|
{
|
|
$id = $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0);
|
|
$this->expectException(StaleWrite::class);
|
|
$this->pages->publish('page-1', $id, 0);
|
|
}
|
|
|
|
public function testPublishedRevisionCanBeRestoredAndImmediatelyChangesOutput(): void
|
|
{
|
|
$original = DemoContent::pages()['home'];
|
|
$first = $this->pages->saveDraft('page-1', $original, 0);
|
|
$this->pages->publish('page-1', $first, 1);
|
|
$changed = $original;
|
|
$changed['title'] = 'Second revision';
|
|
$second = $this->pages->saveDraft('page-1', $changed, 2);
|
|
$this->pages->publish('page-1', $second, 3);
|
|
self::assertSame('Second revision', $this->pages->findPublished('home')['document']['title']);
|
|
$this->pages->publish('page-1', $first, 4);
|
|
self::assertSame($original, $this->pages->findPublished('home')['document']);
|
|
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision'));
|
|
}
|
|
|
|
public static function invalidDocuments(): iterable
|
|
{
|
|
$base = DemoContent::pages()['home'];
|
|
$bad = $base;
|
|
$bad['schemaVersion'] = 999;
|
|
yield 'future schema' => [$bad];
|
|
$bad = $base;
|
|
$bad['blocks'][] = $base['blocks'][0];
|
|
yield 'duplicate IDs' => [$bad];
|
|
foreach (['javascript:alert(1)', 'data:text/html,hi', '//evil.test', '/\\evil.test', "https://example.test/\nfoo"] as $url) {
|
|
$bad = $base;
|
|
$bad['blocks'][0]['children'][3]['props']['href'] = $url;
|
|
yield $url => [$bad];
|
|
}
|
|
$bad = $base;
|
|
$bad['blocks'][0]['layout']['tone'] = '" onclick="alert(1)';
|
|
yield 'style injection' => [$bad];
|
|
foreach ([['desktop' => ['paddingY' => 'url(evil)']], ['watch' => []], ['mobile' => ['columns' => 'two']], ['desktop' => ['unknown' => 'small']], ['mobile' => ['paddingY' => []]]] as $design) {
|
|
$bad = $base;
|
|
$bad['schemaVersion'] = 2;
|
|
$bad['blocks'][0]['layout']['design'] = $design;
|
|
yield 'invalid responsive design '.json_encode($design) => [$bad];
|
|
}
|
|
$bad = $base;
|
|
$bad['blocks'][0]['layout']['design'] = ['desktop' => ['paddingY' => 'large']];
|
|
yield 'v1 cannot silently use v2 settings' => [$bad];
|
|
foreach ([2, 3] as $version) {
|
|
$bad = $base;
|
|
$bad['schemaVersion'] = $version;
|
|
$bad['blocks'][0]['layout']['effects'] = ['shadow' => 2 === $version ? 'soft' : 'evil'];
|
|
yield 'invalid effect version '.$version => [$bad];
|
|
}
|
|
$bad = $base;
|
|
$bad['schemaVersion'] = 3;
|
|
$bad['blocks'][] = ['id' => 'gallery', 'type' => 'gallery', 'children' => [$base['blocks'][0]]];
|
|
yield 'gallery forbids containers' => [$bad];
|
|
$bad = $base;
|
|
$bad['blocks'][] = ['id' => 'gallery', 'type' => 'gallery', 'children' => []];
|
|
yield 'gallery requires v3' => [$bad];
|
|
foreach (['javascript:alert(1)', '/image,400w'] as $source) {
|
|
$bad = $base;
|
|
$bad['schemaVersion'] = 3;
|
|
$bad['blocks'][] = ['id' => 'unsafe-image', 'type' => 'image', 'props' => ['src' => '/safe.png', 'alt' => 'Test', 'decorative' => false, 'width' => 400, 'height' => 300, 'sources' => [['src' => $source, 'width' => 400]]]];
|
|
yield 'unsafe srcset '.$source => [$bad];
|
|
}
|
|
$bad = $base;
|
|
$bad['blocks'][0]['children'][1]['props']['level'] = 9;
|
|
yield 'invalid heading' => [$bad];
|
|
$bad = $base;
|
|
$bad['blocks'][0]['children'][0]['type'] = 'unknown-plugin';
|
|
yield 'unnamespaced unknown type stays rejected' => [$bad];
|
|
foreach (['Acme.Slider', 'acme.', '.slider', 'acme..slider', 'acme.slider.extra', 'acme/slider'] as $type) {
|
|
$bad = $base;
|
|
$bad['schemaVersion'] = 4;
|
|
$bad['blocks'][0]['children'][0]['type'] = $type;
|
|
yield 'malformed plugin type '.$type => [$bad];
|
|
}
|
|
$bad = $base;
|
|
$bad['blocks'][0]['children'][0]['type'] = 'acme.slider';
|
|
yield 'plugin block needs schema v4' => [$bad];
|
|
$bad = $base;
|
|
$bad['blocks'][0]['children'][] = ['id' => 'image', 'type' => 'image', 'props' => ['src' => '/image.png', 'alt' => '', 'decorative' => false, 'width' => 100, 'height' => 100]];
|
|
yield 'missing meaningful alt' => [$bad];
|
|
}
|
|
|
|
#[DataProvider('invalidDocuments')]
|
|
public function testRejectsInvalidDocumentsBeforeWriting(array $document): void
|
|
{
|
|
$this->expectException(InvalidDocument::class);
|
|
$this->pages->saveDraft('page-1', $document, 0);
|
|
}
|
|
|
|
public function testInitialHtmlEscapesContentAndHasNoEditorScripts(): void
|
|
{
|
|
$document = DemoContent::pages()['home'];
|
|
$document['blocks'][0]['children'][0]['props']['text'] = '<script>alert("xss")</script>';
|
|
$id = $this->pages->saveDraft('page-1', $document, 0);
|
|
$this->pages->publish('page-1', $id, 1);
|
|
$response = $this->request('/');
|
|
self::assertSame(200, $response->getStatusCode());
|
|
self::assertStringContainsString('<script>', $response->getContent());
|
|
self::assertStringNotContainsString('<script', $response->getContent());
|
|
self::assertStringContainsString('<h1', $response->getContent());
|
|
self::assertStringContainsString('rel="canonical" href="https://cms.test/"', $response->getContent());
|
|
self::assertStringContainsString('no-store', $response->headers->get('Cache-Control'));
|
|
}
|
|
|
|
public function testResponsiveRevisionPreservesOriginalAndRendersSafeClasses(): void
|
|
{
|
|
$original = DemoContent::pages()['home'];
|
|
$old = $this->pages->saveDraft('page-1', $original, 0);
|
|
$this->pages->publish('page-1', $old, 1);
|
|
$document = $original;
|
|
$document['schemaVersion'] = 2;
|
|
$document['blocks'][0]['layout']['design'] = ['desktop' => ['paddingY' => 'huge'], 'mobile' => ['paddingY' => 'small']];
|
|
$document['blocks'][0]['children'][1]['layout']['design'] = ['desktop' => ['fontSize' => 'hero'], 'mobile' => ['fontSize' => 'title']];
|
|
$new = $this->pages->saveDraft('page-1', $document, 2);
|
|
self::assertSame($original, $this->pages->findPublished('home')['document']);
|
|
self::assertSame($document, $this->pages->findForEditor('page-1')['document']);
|
|
$this->pages->publish('page-1', $new, 3);
|
|
$html = $this->request('/')->getContent();
|
|
self::assertStringContainsString('d-mobile-paddingY-small', $html);
|
|
self::assertStringContainsString('d-desktop-fontSize-hero d-mobile-fontSize-title', $html);
|
|
self::assertStringContainsString('/assets/design.css', $html);
|
|
self::assertStringNotContainsString('style=', $html);
|
|
self::assertStringNotContainsString('<script', $html);
|
|
$this->pages->publish('page-1', $old, 4);
|
|
self::assertSame($original, $this->pages->findPublished('home')['document']);
|
|
self::assertStringNotContainsString('/assets/design.css', $this->request('/')->getContent());
|
|
}
|
|
|
|
public function testDraftAndUnknownUrlsAre404AndWritesAreNotExposed(): void
|
|
{
|
|
$this->pages->saveDraft('page-2', DemoContent::pages()['home'], 0);
|
|
foreach (['/private', '/missing', '/editor'] as $url) {
|
|
self::assertSame(404, $this->request($url)->getStatusCode());
|
|
}
|
|
self::assertSame(405, $this->request('/', 'POST')->getStatusCode());
|
|
}
|
|
|
|
public function testHomeAliasRedirectsToCanonical(): void
|
|
{
|
|
$response = $this->request('/home');
|
|
self::assertSame(301, $response->getStatusCode());
|
|
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());
|
|
}
|
|
|
|
public function testPluginBlocksSurviveSavePublishAndReloadWithoutRenderingTheirProps(): void
|
|
{
|
|
$document = DemoContent::starter('Mit Erweiterung');
|
|
$document['schemaVersion'] = 4;
|
|
$document['blocks'][] = [
|
|
'id' => 'plugin-block', 'type' => 'acme.slider',
|
|
'props' => ['slides' => [['src' => 'javascript:alert(1)', 'headline' => 'MARKER_FROM_PLUGIN_PROPS']], 'autoplay' => true],
|
|
'children' => [['id' => 'plugin-child', 'type' => 'text', 'props' => ['text' => 'MARKER_INSIDE_PLUGIN']]],
|
|
];
|
|
$revision = $this->pages->saveDraft('page-1', $document, 0);
|
|
// Stored byte for byte: a page must never lose content because a plugin is absent.
|
|
self::assertSame($document, json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]), true));
|
|
$this->pages->publish('page-1', $revision, 1);
|
|
self::assertSame($document, $this->pages->findPublished('home')['document']);
|
|
self::assertSame($document, $this->pages->findForEditor('page-1')['document']);
|
|
|
|
$html = $this->request('/')->getContent();
|
|
self::assertSame(200, $this->request('/')->getStatusCode());
|
|
// The core knows no schema for these props, so none of them may reach the output.
|
|
self::assertStringNotContainsString('MARKER_FROM_PLUGIN_PROPS', $html);
|
|
self::assertStringNotContainsString('MARKER_INSIDE_PLUGIN', $html);
|
|
self::assertStringNotContainsString('javascript:', $html);
|
|
self::assertStringNotContainsString('block-unavailable', $html);
|
|
self::assertStringContainsString('<!-- Block plugin-block (acme.slider)', $html);
|
|
// The rest of the page still renders.
|
|
self::assertStringContainsString('>Mit Erweiterung</h1>', $html);
|
|
}
|
|
|
|
public function testMenuRendersPageAnchorAndExternalTargetsWithCurrentMarker(): void
|
|
{
|
|
$about = $this->publishedPage('ueber-uns', 'Ueber uns');
|
|
$this->menus->save('header', 'Hauptnavigation', [
|
|
['label' => 'Start', 'type' => 'page', 'pageId' => 'page-1', 'anchor' => '', 'children' => [
|
|
['label' => 'Kontakt', 'type' => 'page', 'pageId' => 'page-1', 'anchor' => 'kontakt', 'children' => []],
|
|
]],
|
|
['label' => 'Ueber uns', 'type' => 'page', 'pageId' => $about, 'anchor' => '', 'children' => []],
|
|
['label' => 'HausKlar', 'type' => 'url', 'url' => 'https://hausklar.app', 'children' => []],
|
|
['label' => 'Anrufen', 'type' => 'url', 'url' => 'tel:+4972219957150', 'children' => []],
|
|
], 0);
|
|
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
|
|
|
$html = $this->request('/')->getContent();
|
|
self::assertStringContainsString('href="/#kontakt"', $html);
|
|
self::assertStringContainsString('href="/ueber-uns"', $html);
|
|
self::assertStringContainsString('href="https://hausklar.app"', $html);
|
|
self::assertStringContainsString('href="tel:+4972219957150"', $html);
|
|
// The home link is the current page; an anchor inside it is not.
|
|
self::assertSame(1, substr_count($html, 'aria-current="page"'));
|
|
self::assertStringContainsString('<a href="/" aria-current="page">Start</a>', $html);
|
|
|
|
// On another page the marker moves and nothing else changes.
|
|
$other = $this->request('/ueber-uns')->getContent();
|
|
self::assertStringContainsString('<a href="/ueber-uns" aria-current="page">Ueber uns</a>', $other);
|
|
self::assertStringNotContainsString('<a href="/" aria-current="page">', $other);
|
|
}
|
|
|
|
public function testMenuSurvivesDeletedAndUnpublishedPagesWithoutLosingOtherEntries(): void
|
|
{
|
|
$doomed = $this->publishedPage('temporaer', 'Temporaer');
|
|
$draft = $this->pages->createPage('entwurf', null, 'page', DemoContent::starter('Entwurf'));
|
|
$this->menus->save('header', 'Hauptnavigation', [
|
|
['label' => 'Verschwindet', 'type' => 'page', 'pageId' => $doomed, 'anchor' => '', 'children' => [
|
|
['label' => 'Bleibt als Kind', 'type' => 'url', 'url' => 'https://example.com', 'children' => []],
|
|
]],
|
|
['label' => 'Noch Entwurf', 'type' => 'page', 'pageId' => $draft, 'anchor' => '', 'children' => []],
|
|
['label' => 'Extern', 'type' => 'url', 'url' => 'https://hausklar.app', 'children' => []],
|
|
], 0);
|
|
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
|
$this->pages->deletePage($doomed, $this->pages->findForEditor($doomed)['version']);
|
|
|
|
$html = $this->request('/')->getContent();
|
|
self::assertStringNotContainsString('Verschwindet', $html);
|
|
// An unpublished page must not appear in public navigation.
|
|
self::assertStringNotContainsString('Noch Entwurf', $html);
|
|
self::assertStringNotContainsString('/entwurf', $html);
|
|
// The rest of the menu is intact, including the child of the removed branch.
|
|
self::assertStringContainsString('Bleibt als Kind', $html);
|
|
self::assertStringContainsString('href="https://hausklar.app"', $html);
|
|
// The stored items are untouched: publishing the draft brings its entry back.
|
|
self::assertCount(3, $this->menus->all()['header']['items']);
|
|
$page = $this->pages->findForEditor($draft);
|
|
$this->pages->publish($draft, $page['draft_revision_id'], $page['version']);
|
|
self::assertStringContainsString('Noch Entwurf', $this->request('/')->getContent());
|
|
}
|
|
|
|
/**
|
|
* @return iterable<string, array{array}>
|
|
*/
|
|
public static function rejectedMenuItems(): iterable
|
|
{
|
|
$page = static fn (array $extra = []): array => [...['label' => 'Eintrag', 'type' => 'page', 'pageId' => 'page-1', 'anchor' => '', 'children' => []], ...$extra];
|
|
yield 'empty label' => [[$page(['label' => ' '])]];
|
|
yield 'oversized label' => [[$page(['label' => str_repeat('a', 81)])]];
|
|
yield 'unknown type' => [[$page(['type' => 'script'])]];
|
|
yield 'missing page' => [[$page(['pageId' => ''])]];
|
|
yield 'anchor with hash' => [[$page(['anchor' => 'a#b'])]];
|
|
yield 'anchor starting with digit' => [[$page(['anchor' => '1kontakt'])]];
|
|
yield 'javascript url' => [[['label' => 'X', 'type' => 'url', 'url' => 'javascript:alert(1)', 'children' => []]]];
|
|
yield 'plain http url' => [[['label' => 'X', 'type' => 'url', 'url' => 'http://example.com', 'children' => []]]];
|
|
yield 'protocol relative url' => [[['label' => 'X', 'type' => 'url', 'url' => '//evil.example', 'children' => []]]];
|
|
yield 'third level' => [[$page(['children' => [$page(['children' => [$page()]])]])]];
|
|
}
|
|
|
|
#[DataProvider('rejectedMenuItems')]
|
|
public function testRejectsInvalidMenuItemsWithoutWriting(array $items): void
|
|
{
|
|
try {
|
|
$this->menus->save('header', 'Hauptnavigation', $items, 0);
|
|
self::fail('Expected the menu to be rejected.');
|
|
} catch (InvalidMenu) {
|
|
}
|
|
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM menu'));
|
|
}
|
|
|
|
public function testMenuRejectsUnknownSlotOversizeAndStaleWrites(): void
|
|
{
|
|
$item = ['label' => 'Start', 'type' => 'page', 'pageId' => 'page-1', 'anchor' => '', 'children' => []];
|
|
try {
|
|
$this->menus->save('sidebar', 'Seitenleiste', [$item], 0);
|
|
self::fail('Expected an unknown slot to be rejected.');
|
|
} catch (InvalidMenu) {
|
|
}
|
|
try {
|
|
$this->menus->save('header', 'Hauptnavigation', array_fill(0, MenuRepository::MAX_ITEMS + 1, $item), 0);
|
|
self::fail('Expected the item limit to apply.');
|
|
} catch (InvalidMenu) {
|
|
}
|
|
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM menu'));
|
|
|
|
$this->menus->save('header', 'Hauptnavigation', [$item], 0);
|
|
self::assertSame(1, $this->menus->all()['header']['version']);
|
|
$this->expectException(StaleWrite::class);
|
|
$this->menus->save('header', 'Geaendert', [$item], 0);
|
|
}
|
|
|
|
private function publishedPage(string $slug, string $title): string
|
|
{
|
|
$id = $this->pages->createPage($slug, null, 'page', DemoContent::starter($title));
|
|
$page = $this->pages->findForEditor($id);
|
|
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
|
|
|
|
return $id;
|
|
}
|
|
|
|
private function request(string $url, string $method = 'GET'): Response
|
|
{
|
|
$previous = [];
|
|
foreach (['APP_SECRET' => 'test-only', 'DATABASE_URL' => 'sqlite:///'.str_replace('\\', '/', $this->path), 'SITE_ORIGIN' => 'https://cms.test'] as $key => $value) {
|
|
$previous[$key] = [$_SERVER[$key] ?? null, $_ENV[$key] ?? null];
|
|
$_SERVER[$key] = $_ENV[$key] = $value;
|
|
}
|
|
$kernel = new Kernel('test', false);
|
|
try {
|
|
return $kernel->handle(Request::create($url, $method));
|
|
} finally {
|
|
$kernel->shutdown();
|
|
foreach ($previous as $key => [$server, $env]) {
|
|
if (null === $server) {
|
|
unset($_SERVER[$key]);
|
|
} else {
|
|
$_SERVER[$key] = $server;
|
|
}
|
|
if (null === $env) {
|
|
unset($_ENV[$key]);
|
|
} else {
|
|
$_ENV[$key] = $env;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|