Files
KantSites/tests/CmsTest.php
T
kantarciandClaude Opus 5 84d471acd9 Das System heißt KantSites
Umut hat den Namen festgelegt: aus dem Arbeitstitel "Kant CMS" wird KantSites.
Geändert in allen Seitentiteln der Verwaltung, im Wortmarken-Schriftzug von
Anmeldung und Seitenleiste, in den Fußzeilen, in den Demoinhalten für eine
frische Installation, in package.json, .claude/launch.json und der
Dokumentation. Die Bezeichnung der Website in der Datenbank heißt jetzt
ebenfalls KantSites.

Nicht angefasst: die bereits gespeicherten Demoinhalte der lokalen
Installation. Titel wie "Die Grundlage · Kant CMS" und der Text im
Kopfbereich sind Inhalt, nicht Systemname - die gehören in den Editor, nicht
in ein Skript, das Revisionen umschreibt.

219 PHP-Tests / 881 Assertions grün, Playwright-Editortest grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 18:36:50 +02:00

1791 lines
99 KiB
PHP

<?php
namespace App\Tests;
use App\Content\CustomCode;
use App\Content\DemoContent;
use App\Content\DocumentValidator;
use App\Content\MediaLibrary;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\PostRepository;
use App\Content\RedirectRepository;
use App\Content\RichText;
use App\Content\SiteSettings;
use App\Content\Theme;
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 PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
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 PostRepository $posts;
private SiteSettings $site;
private RedirectRepository $redirects;
private Theme $themes;
private PartialRepository $partials;
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));
TestSchema::build($this->db);
$this->db->insert('website', ['id' => 'default', 'name' => 'KantSites', '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(), new SiteSettings($this->db));
$this->menus = new MenuRepository($this->db, new SiteSettings($this->db));
$this->posts = new PostRepository($this->db, new MediaLibrary($this->db, $this->path.'-media'), new SiteSettings($this->db));
$this->site = new SiteSettings($this->db);
$this->redirects = new RedirectRepository($this->db);
$this->themes = new Theme($this->db);
$this->partials = new PartialRepository($this->db, new DocumentValidator());
// A real installation has a header and a footer; the navigation lives in the header area.
foreach (DemoContent::areas() as $kind => $area) {
$this->partials->create($kind, 'default', $area['name'], $area['document']);
}
}
protected function tearDown(): void
{
$this->db->close();
gc_collect_cycles();
// Each request() boots its own kernel with its own connection to the same file. On Windows
// the handle is sometimes released a moment after the kernel is gone, so a single unlink
// can fail on a database that is otherwise finished with. Retry briefly, then leave the
// temporary file to the operating system rather than failing a passing test.
for ($attempt = 0; $attempt < 20 && file_exists($this->path); ++$attempt) {
if (@unlink($this->path)) {
return;
}
usleep(25000);
}
}
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('&lt;script&gt;', $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;
}
public function testArchiveOrdersPostsByDatePaginatesAndHidesPagesAndDrafts(): void
{
// Three published posts with distinct dates, one draft post and one ordinary page.
foreach ([['alt', '2024-01-05'], ['mittel', '2025-06-01'], ['neu', '2026-03-09']] as [$slug, $date]) {
$id = $this->publishedPost($slug, ucfirst($slug), $date);
self::assertNotNull($this->pages->findForEditor($id));
}
$this->pages->createPage('unveroeffentlicht', null, 'post', DemoContent::starter('Unveroeffentlicht', 'post'));
$this->publishedPage('eine-seite', 'Eine Seite');
$first = $this->posts->published(1, null, 2);
self::assertSame(3, $first['total']);
self::assertSame(2, $first['pages']);
self::assertSame(['Neu', 'Mittel'], array_column($first['posts'], 'title'));
self::assertSame(['Alt'], array_column($this->posts->published(2, null, 2)['posts'], 'title'));
// A page is not a post, and an unpublished post stays out of the archive.
foreach ($this->posts->published(1, null, 20)['posts'] as $post) {
self::assertNotSame('Eine Seite', $post['title']);
self::assertNotSame('Unveroeffentlicht', $post['title']);
}
// Out-of-range page numbers clamp instead of showing an empty archive.
self::assertSame(2, $this->posts->published(99, null, 2)['page']);
self::assertSame(1, $this->posts->published(-5, null, 2)['page']);
$html = $this->request('/beitraege')->getContent();
self::assertSame(200, $this->request('/beitraege')->getStatusCode());
self::assertStringContainsString('<time datetime="2026-03-09">09.03.2026</time>', $html);
self::assertStringNotContainsString('Unveroeffentlicht', $html);
self::assertStringContainsString('href="https://cms.test/beitraege"', $html);
}
public function testCategoriesAndTagsGroupPostsAndGetTheirOwnArchives(): void
{
$projekt = $this->publishedPost('kanttresor', 'KantTresor', '2026-02-01', ['Projekte'], ['WordPress', 'DSGVO']);
$this->publishedPost('andere', 'Andere', '2026-01-01', ['Notizen'], []);
$terms = $this->posts->termsFor($projekt);
self::assertSame(['Projekte'], array_column($terms['category'], 'name'));
self::assertSame(['DSGVO', 'WordPress'], array_column($terms['tag'], 'name'));
self::assertSame('/kategorie/projekte', $terms['category'][0]['href']);
self::assertSame('/schlagwort/dsgvo', $terms['tag'][0]['href']);
$category = $this->request('/kategorie/projekte');
self::assertSame(200, $category->getStatusCode());
self::assertStringContainsString('KantTresor', $category->getContent());
self::assertStringNotContainsString('Andere', $category->getContent());
self::assertStringContainsString('KantTresor', $this->request('/schlagwort/wordpress')->getContent());
self::assertSame(404, $this->request('/kategorie/gibt-es-nicht')->getStatusCode());
// A tag slug is not a category slug.
self::assertSame(404, $this->request('/kategorie/dsgvo')->getStatusCode());
// Saving metadata again replaces the assignment instead of adding to it.
$version = $this->pages->findForEditor($projekt)['version'];
$this->posts->saveMetadata($projekt, ['publishedAt' => '2026-02-01'], ['Projekte'], [], $version);
self::assertSame([], $this->posts->termsFor($projekt)['tag']);
// The term itself survives as a reusable label, so its archive answers but lists nothing.
$emptied = $this->request('/schlagwort/wordpress');
self::assertSame(200, $emptied->getStatusCode());
self::assertStringNotContainsString('KantTresor', $emptied->getContent());
}
public function testPostListBlockIsFilledByTheRendererAndRespectsItsFilter(): void
{
$this->publishedPost('projekt-eins', 'Projekt eins', '2026-02-01', ['Projekte'], []);
$this->publishedPost('notiz', 'Eine Notiz', '2026-02-02', ['Notizen'], []);
$document = DemoContent::starter('Startseite');
$document['schemaVersion'] = 5;
$document['blocks'][] = ['id' => 'liste', 'type' => 'postlist', 'props' => ['count' => 5, 'category' => 'projekte', 'tag' => ''], 'children' => []];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('Projekt eins', $html);
self::assertStringNotContainsString('Eine Notiz', $html);
// The stored block carries no post data; only the filter and the count are content.
$stored = $this->pages->findPublished('home')['document'];
self::assertSame(['count' => 5, 'category' => 'projekte', 'tag' => ''], $stored['blocks'][2]['props']);
}
public function testFeedListsPublishedPostsWithAbsoluteAddresses(): void
{
$this->publishedPost('erster', 'Erster Beitrag', '2026-02-01', ['Projekte'], []);
$this->pages->createPage('geheim', null, 'post', DemoContent::starter('Geheim', 'post'));
$response = $this->request('/feed.xml');
self::assertSame(200, $response->getStatusCode());
self::assertStringContainsString('application/xml', (string) $response->headers->get('Content-Type'));
$xml = $response->getContent();
self::assertStringContainsString('<link>https://cms.test/erster</link>', $xml);
self::assertStringContainsString('<category>Projekte</category>', $xml);
self::assertStringNotContainsString('Geheim', $xml);
self::assertNotFalse(simplexml_load_string($xml), 'The feed must be well-formed XML.');
}
/**
* @return iterable<string, array{array, array, array}>
*/
public static function rejectedPostMetadata(): iterable
{
yield 'oversized excerpt' => [['excerpt' => str_repeat('a', 501)], [], []];
yield 'oversized author' => [['author' => str_repeat('a', 121)], [], []];
yield 'malformed date' => [['publishedAt' => '09.03.2026'], [], []];
yield 'unknown media' => [['featuredMediaId' => str_repeat('a', 32)], [], []];
yield 'malformed media id' => [['featuredMediaId' => 'not-a-media-id'], [], []];
yield 'unusable term name' => [[], ['---'], []];
}
#[DataProvider('rejectedPostMetadata')]
public function testRejectsInvalidPostMetadataWithoutWriting(array $values, array $categories, array $tags): void
{
$id = $this->pages->createPage('beitrag', null, 'post', DemoContent::starter('Beitrag', 'post'));
$version = $this->pages->findForEditor($id)['version'];
try {
$this->posts->saveMetadata($id, $values, $categories, $tags, $version);
self::fail('Expected the metadata to be rejected.');
} catch (InvalidPage) {
}
self::assertSame($version, $this->pages->findForEditor($id)['version']);
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page_term'));
}
public function testDeletingAPostReleasesItsTermAssignments(): void
{
$id = $this->publishedPost('weg', 'Weg', '2026-01-01', ['Projekte'], ['Tag']);
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page_term'));
$this->pages->deletePage($id, $this->pages->findForEditor($id)['version']);
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page_term'));
// The terms themselves stay: they are reusable labels, not content of one post.
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM term'));
$archive = $this->request('/kategorie/projekte');
self::assertSame(200, $archive->getStatusCode());
self::assertStringNotContainsString('Weg', $archive->getContent());
self::assertSame(0, $this->posts->published(1, $this->posts->findTerm('category', 'projekte')['id'])['total']);
}
public function testPublishingStampsAMissingDateButKeepsAGivenOne(): void
{
$given = $this->publishedPost('mit-datum', 'Mit Datum', '2024-07-04');
self::assertStringStartsWith('2024-07-04', (string) $this->db->fetchOne('SELECT published_at FROM page WHERE id = ?', [$given]));
$without = $this->pages->createPage('ohne-datum', null, 'post', DemoContent::starter('Ohne Datum', 'post'));
$page = $this->pages->findForEditor($without);
$this->pages->publish($without, $page['draft_revision_id'], $page['version']);
self::assertNotNull($this->db->fetchOne('SELECT published_at FROM page WHERE id = ?', [$without]));
// An ordinary page is never stamped.
$plain = $this->publishedPage('normale-seite', 'Normale Seite');
self::assertNull($this->db->fetchOne('SELECT published_at FROM page WHERE id = ?', [$plain]));
}
private function publishedPost(string $slug, string $title, string $date, array $categories = [], array $tags = []): string
{
$id = $this->pages->createPage($slug, null, 'post', DemoContent::starter($title, 'post'));
$this->posts->saveMetadata($id, ['publishedAt' => $date, 'author' => 'Umut'], $categories, $tags, $this->pages->findForEditor($id)['version']);
$page = $this->pages->findForEditor($id);
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
return $id;
}
/** A page built from the section kinds the pilot start page actually uses. */
private static function pilotDocument(): array
{
return [
'schemaVersion' => 6,
'title' => 'Kant-IT',
'description' => 'Ihr IT-Partner.',
'blocks' => [
['id' => 'hero', 'type' => 'section', 'children' => [
['id' => 'h1', 'type' => 'heading', 'props' => ['text' => 'Digitale Exzellenz', 'level' => 1]],
['id' => 'stat1', 'type' => 'stat', 'props' => ['value' => '15+', 'label' => 'Jahre Erfahrung']],
['id' => 'sp', 'type' => 'spacer', 'props' => ['size' => 'large']],
['id' => 'hr', 'type' => 'divider'],
]],
['id' => 'leistungen', 'type' => 'section', 'children' => [
['id' => 'card1', 'type' => 'iconcard', 'props' => ['icon' => 'app', 'title' => 'App-Entwicklung', 'text' => 'Für iOS und Android.', 'href' => '/leistungen']],
['id' => 'card2', 'type' => 'iconcard', 'props' => ['icon' => 'shop', 'title' => 'E-Commerce', 'text' => '', 'href' => '']],
]],
['id' => 'stimmen', 'type' => 'section', 'children' => [
['id' => 'q1', 'type' => 'quote', 'props' => ['text' => 'Kant-IT denkt mit.', 'author' => 'Malerservice Drews', 'role' => 'Zufriedener Kunde', 'rating' => 5]],
['id' => 'logos', 'type' => 'logos', 'children' => [
['id' => 'logo1', 'type' => 'image', 'props' => ['src' => '/assets/image-placeholder.svg', 'alt' => 'Partner', 'decorative' => false, 'width' => 200, 'height' => 80]],
]],
]],
['id' => 'fragen', 'type' => 'accordion', 'children' => [
['id' => 'f1', 'type' => 'accordionitem', 'props' => ['title' => 'Wie schnell antwortet ihr?', 'open' => true], 'children' => [
['id' => 'f1t', 'type' => 'text', 'props' => ['text' => 'In der Regel innerhalb von 24 Stunden.']],
]],
]],
['id' => 'kontakt', 'type' => 'section', 'children' => [
['id' => 'cta1', 'type' => 'cta', 'props' => ['title' => 'Bereit?', 'text' => 'Kostenlose Erstberatung.', 'buttonText' => 'Jetzt anfragen', 'href' => '#kontakt']],
['id' => 'adr', 'type' => 'contact', 'props' => ['phone' => '+49 7221 99 57 150', 'email' => 'info@kant-it.de', 'address' => 'Ooser Hauptstr. 51, Baden-Baden']],
]],
],
];
}
public function testPilotSectionsRoundTripAndRenderWithoutJavaScript(): void
{
$document = self::pilotDocument();
$revision = $this->pages->saveDraft('page-1', $document, 0);
self::assertSame($document, json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]), true));
$this->pages->publish('page-1', $revision, 1);
$html = $this->request('/')->getContent();
self::assertSame(200, $this->request('/')->getStatusCode());
// Every new block reaches the output.
foreach (['block-iconcard', 'block-stat', 'block-quote', 'block-logos', 'block-cta', 'block-contact', 'block-divider', 'block-spacer', 'block-accordionitem'] as $marker) {
self::assertStringContainsString($marker, $html, $marker);
}
// Contact details become real links, with the phone number stripped for the href.
self::assertStringContainsString('href="tel:+4972219957150"', $html);
self::assertStringContainsString('href="mailto:info@kant-it.de"', $html);
// The accordion is a native details element, so it works without scripting.
self::assertStringContainsString('<details id="f1" class="block-accordionitem" open>', $html);
self::assertStringContainsString('<summary>Wie schnell antwortet ihr?</summary>', $html);
// Icons are inline SVG, not an icon font or an external request.
self::assertStringContainsString('<svg class="block-icon"', $html);
self::assertStringNotContainsString('<script', $html);
// The rating is announced for screen readers rather than left as decorative stars.
self::assertStringContainsString('Bewertung: 5 von 5', $html);
}
/**
* @return iterable<string, array{array}>
*/
public static function rejectedV6Blocks(): iterable
{
$wrap = static function (array $block): array {
$document = DemoContent::starter('Titel');
$document['schemaVersion'] = 6;
$document['blocks'][] = $block;
return [$document];
};
yield 'unknown icon' => $wrap(['id' => 'x', 'type' => 'iconcard', 'props' => ['icon' => 'rocket', 'title' => 'T', 'text' => '']]);
yield 'iconcard without title' => $wrap(['id' => 'x', 'type' => 'iconcard', 'props' => ['icon' => 'app', 'title' => ' ', 'text' => '']]);
yield 'iconcard with unsafe link' => $wrap(['id' => 'x', 'type' => 'iconcard', 'props' => ['icon' => 'app', 'title' => 'T', 'text' => '', 'href' => 'javascript:alert(1)']]);
yield 'rating out of range' => $wrap(['id' => 'x', 'type' => 'quote', 'props' => ['text' => 'T', 'author' => 'A', 'role' => '', 'rating' => 6]]);
yield 'rating not a number' => $wrap(['id' => 'x', 'type' => 'quote', 'props' => ['text' => 'T', 'author' => 'A', 'role' => '', 'rating' => '5']]);
yield 'empty contact block' => $wrap(['id' => 'x', 'type' => 'contact', 'props' => ['phone' => '', 'email' => '', 'address' => '']]);
yield 'invalid phone' => $wrap(['id' => 'x', 'type' => 'contact', 'props' => ['phone' => 'ruf mich an', 'email' => '', 'address' => '']]);
yield 'invalid email' => $wrap(['id' => 'x', 'type' => 'contact', 'props' => ['phone' => '', 'email' => 'keine-adresse', 'address' => '']]);
yield 'unknown spacer size' => $wrap(['id' => 'x', 'type' => 'spacer', 'props' => ['size' => 'gigantic']]);
yield 'cta without target' => $wrap(['id' => 'x', 'type' => 'cta', 'props' => ['title' => 'T', 'text' => '', 'buttonText' => 'Los', 'href' => 'ftp://example.com']]);
yield 'logos with a text child' => $wrap(['id' => 'x', 'type' => 'logos', 'children' => [['id' => 'y', 'type' => 'text', 'props' => ['text' => 'kein Logo']]]]);
yield 'accordion with a text child' => $wrap(['id' => 'x', 'type' => 'accordion', 'children' => [['id' => 'y', 'type' => 'text', 'props' => ['text' => 'direkt']]]]);
yield 'accordion entry outside an accordion' => $wrap(['id' => 'x', 'type' => 'accordionitem', 'props' => ['title' => 'T', 'open' => false], 'children' => []]);
yield 'divider with children' => $wrap(['id' => 'x', 'type' => 'divider', 'children' => [['id' => 'y', 'type' => 'text', 'props' => ['text' => 'a']]]]);
}
#[DataProvider('rejectedV6Blocks')]
public function testRejectsInvalidV6BlocksBeforeWriting(array $document): void
{
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
}
public function testV6BlocksNeedSchemaSixAndStayRejectedInOlderDocuments(): void
{
$document = DemoContent::starter('Titel');
$document['schemaVersion'] = 5;
$document['blocks'][] = ['id' => 'karte', 'type' => 'iconcard', 'props' => ['icon' => 'app', 'title' => 'T', 'text' => '']];
try {
$this->pages->saveDraft('page-1', $document, 0);
self::fail('Expected schema v5 to reject a v6 block.');
} catch (InvalidDocument) {
}
$document['schemaVersion'] = 6;
self::assertNotEmpty($this->pages->saveDraft('page-1', $document, 0));
}
private static function tabsDocument(): array
{
$document = DemoContent::starter('Mit Tabs');
$document['schemaVersion'] = 7;
$document['blocks'][] = ['id' => 'tabs', 'type' => 'tabs', 'children' => [
['id' => 'tab-web', 'type' => 'tabitem', 'props' => ['title' => 'Web Design'], 'children' => [
['id' => 'tab-web-text', 'type' => 'text', 'props' => ['text' => 'Inhalt des ersten Tabs.']],
]],
['id' => 'tab-app', 'type' => 'tabitem', 'props' => ['title' => 'App-Entwicklung'], 'children' => [
['id' => 'tab-app-text', 'type' => 'text', 'props' => ['text' => 'Inhalt des zweiten Tabs.']],
]],
]];
return $document;
}
public function testTabsRenderCompletelyWithoutScriptingAndLoadTheEnhancementOnlyWhereNeeded(): void
{
$document = self::tabsDocument();
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$response = $this->request('/');
$html = $response->getContent();
// Without the script the page still carries both panels, their headings and their content.
self::assertStringContainsString('Inhalt des ersten Tabs.', $html);
self::assertStringContainsString('Inhalt des zweiten Tabs.', $html);
self::assertStringContainsString('<h3 class="tabs-heading">Web Design</h3>', $html);
self::assertStringContainsString('<a href="#tab-app">App-Entwicklung</a>', $html);
// Neither panel is hidden server side; hiding is the script's job alone.
self::assertStringContainsString('<section id="tab-web" class="block-tabitem">', $html);
self::assertStringContainsString('<section id="tab-app" class="block-tabitem">', $html);
// The enhancement is an external file; inline script never appears.
self::assertStringContainsString('<script src="/assets/tabs.js" defer></script>', $html);
// Scripting is permitted for this page only, and only from this origin.
self::assertStringContainsString("script-src 'self'", (string) $response->headers->get('Content-Security-Policy'));
// A page without tabs keeps the strict policy and loads no script at all.
$plain = $this->publishedPage('ohne-tabs', 'Ohne Tabs');
self::assertNotNull($this->pages->findForEditor($plain));
$other = $this->request('/ohne-tabs');
self::assertStringNotContainsString('tabs.js', $other->getContent());
self::assertStringContainsString("script-src 'none'", (string) $other->headers->get('Content-Security-Policy'));
self::assertSame(404, $this->request('/gibt-es-nicht')->getStatusCode());
self::assertStringContainsString("script-src 'none'", (string) $this->request('/gibt-es-nicht')->headers->get('Content-Security-Policy'));
}
public function testTabsSurviveSavingAndRejectMisplacedEntries(): void
{
$document = self::tabsDocument();
$revision = $this->pages->saveDraft('page-1', $document, 0);
self::assertSame($document, json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]), true));
// A tab outside a tab group, and a tab group holding anything else, are both refused.
$loose = DemoContent::starter('Titel');
$loose['schemaVersion'] = 7;
$loose['blocks'][] = ['id' => 'x', 'type' => 'tabitem', 'props' => ['title' => 'Allein'], 'children' => []];
try {
$this->pages->saveDraft('page-1', $loose, 1);
self::fail('Expected a tab outside a tab group to be rejected.');
} catch (InvalidDocument) {
}
$wrong = DemoContent::starter('Titel');
$wrong['schemaVersion'] = 7;
$wrong['blocks'][] = ['id' => 'x', 'type' => 'tabs', 'children' => [['id' => 'y', 'type' => 'text', 'props' => ['text' => 'direkt']]]];
try {
$this->pages->saveDraft('page-1', $wrong, 1);
self::fail('Expected a tab group with a text child to be rejected.');
} catch (InvalidDocument) {
}
// And tabs need schema v7.
$old = self::tabsDocument();
$old['schemaVersion'] = 6;
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $old, 1);
}
public function testSitemapListsOnlyPublishedIndexablePagesAndPointsRobotsAtIt(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
$hidden = $this->publishedPage('intern', 'Intern');
$this->posts->saveMetadata($hidden, ['robots' => 'noindex'], [], [], $this->pages->findForEditor($hidden)['version']);
$this->publishedPage('offen', 'Offen');
$this->pages->createPage('entwurf', null, 'page', DemoContent::starter('Entwurf'));
$this->publishedPost('ein-beitrag', 'Ein Beitrag', '2026-02-01', ['Projekte'], []);
$sitemap = $this->request('/sitemap.xml');
self::assertSame(200, $sitemap->getStatusCode());
self::assertStringContainsString('application/xml', (string) $sitemap->headers->get('Content-Type'));
$xml = $sitemap->getContent();
self::assertNotFalse(simplexml_load_string($xml), 'The sitemap must be well-formed XML.');
self::assertStringContainsString('<loc>https://cms.test/</loc>', $xml);
self::assertStringContainsString('<loc>https://cms.test/offen</loc>', $xml);
self::assertStringContainsString('<loc>https://cms.test/beitraege</loc>', $xml);
self::assertStringContainsString('<loc>https://cms.test/kategorie/projekte</loc>', $xml);
// A noindex page and an unpublished page have no business in a sitemap.
self::assertStringNotContainsString('/intern', $xml);
self::assertStringNotContainsString('/entwurf', $xml);
$robots = $this->request('/robots.txt');
self::assertSame(200, $robots->getStatusCode());
self::assertStringContainsString('text/plain', (string) $robots->headers->get('Content-Type'));
self::assertStringContainsString('Disallow: /admin', $robots->getContent());
self::assertStringContainsString('Sitemap: https://cms.test/sitemap.xml', $robots->getContent());
// A page excluded from indexing says so on the page itself as well.
self::assertStringContainsString('<meta name="robots" content="noindex, follow">', $this->request('/intern')->getContent());
self::assertStringNotContainsString('name="robots"', $this->request('/offen')->getContent());
}
public function testRedirectsAnswerBeforeTheNotFoundAndRefuseChainsAndLoops(): void
{
$this->publishedPage('leistungen', 'Leistungen');
$this->redirects->save('alte-seite', 'leistungen', 301);
$this->redirects->save('weg', '', 410);
$moved = $this->request('/alte-seite');
self::assertSame(301, $moved->getStatusCode());
self::assertSame('/leistungen', $moved->headers->get('Location'));
// A deliberately removed address is gone, not merely missing.
self::assertSame(410, $this->request('/weg')->getStatusCode());
self::assertSame(404, $this->request('/nie-dagewesen')->getStatusCode());
foreach ([['alte-seite', 'alte-seite', 301], ['noch-eine', 'alte-seite', 301], ['Gross', 'leistungen', 301], ['ok', 'leistungen', 302]] as [$from, $to, $status]) {
try {
$this->redirects->save($from, $to, $status);
self::fail(sprintf('Expected /%s -> /%s (%d) to be rejected.', $from, $to, $status));
} catch (InvalidPage) {
}
}
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM redirect'));
// An existing page always wins over a redirect with the same address.
$this->redirects->save('leistungen', 'offen', 301);
self::assertSame(200, $this->request('/leistungen')->getStatusCode());
}
public function testStructuredDataComesOnlyFromMaintainedValues(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
// Nothing maintained yet: no empty organisation block is emitted.
self::assertStringNotContainsString('application/ld+json', $this->request('/')->getContent());
$this->site->save([
'legalName' => 'Kant-IT Solutions', 'phone' => '+49 7221 9957150', 'email' => 'info@kant-it.de',
'street' => 'Ooser Hauptstr. 51', 'postalCode' => '76532', 'city' => 'Baden-Baden', 'country' => 'de',
'sameAs' => "https://hausklar.app\nhttps://hausklar.app", 'localBusiness' => '1',
]);
$html = $this->request('/')->getContent();
self::assertStringContainsString('<script type="application/ld+json">', $html);
preg_match('#<script type="application/ld\+json">(.*?)</script>#s', $html, $match);
$data = json_decode(html_entity_decode($match[1], ENT_QUOTES), true);
self::assertSame('LocalBusiness', $data['@type']);
self::assertSame('Kant-IT Solutions', $data['name']);
self::assertSame('Baden-Baden', $data['address']['addressLocality']);
self::assertSame('DE', $data['address']['addressCountry']);
// Duplicates are collapsed, and nothing is invented.
self::assertSame(['https://hausklar.app'], $data['sameAs']);
self::assertArrayNotHasKey('description', $data);
// A post carries Article data built from its own metadata.
$post = $this->publishedPost('ein-beitrag', 'Ein Beitrag', '2026-02-01');
self::assertNotNull($this->pages->findForEditor($post));
$article = $this->request('/ein-beitrag')->getContent();
self::assertStringContainsString('"@type":"Article"', $article);
self::assertStringContainsString('"headline":"Ein Beitrag"', $article);
self::assertStringContainsString('"name":"Umut"', $article);
}
public function testBreadcrumbTrailAndItsStructuredDataFollowTheStoredHierarchy(): void
{
$parent = $this->publishedPage('leistungen', 'Leistungen');
self::assertNotNull($this->pages->findForEditor($parent));
$childId = $this->pages->createPage('webdesign', $parent, 'page', (static function (): array {
$document = DemoContent::starter('Webdesign');
$document['schemaVersion'] = 9;
$document['blocks'][] = ['id' => 'krumen', 'type' => 'breadcrumb'];
return $document;
})());
$child = $this->pages->findForEditor($childId);
$this->pages->publish($childId, $child['draft_revision_id'], $child['version']);
$html = $this->request('/leistungen/webdesign')->getContent();
self::assertStringContainsString('aria-label="Brotkrumen"', $html);
self::assertStringContainsString('<a href="/leistungen">Leistungen</a>', $html);
self::assertStringContainsString('<span aria-current="page">Webdesign</span>', $html);
self::assertStringContainsString('"@type":"BreadcrumbList"', $html);
self::assertStringContainsString('"item":"https://cms.test/leistungen"', $html);
// A page directly under the root gets no trail, so no empty navigation appears.
self::assertStringNotContainsString('aria-label="Brotkrumen"', $this->request('/leistungen')->getContent());
}
public function testLlmsTxtIsOffByDefaultAndListsOnlyPublicIndexableContent(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
self::assertSame(404, $this->request('/llms.txt')->getStatusCode());
$hidden = $this->publishedPage('intern', 'Intern');
$this->posts->saveMetadata($hidden, ['robots' => 'noindex'], [], [], $this->pages->findForEditor($hidden)['version']);
$this->pages->createPage('entwurf', null, 'page', DemoContent::starter('Entwurf'));
$this->site->save(['llmsTxt' => '1', 'legalName' => 'Kant-IT', 'description' => 'Ihr IT-Partner.', 'country' => 'DE']);
$response = $this->request('/llms.txt');
self::assertSame(200, $response->getStatusCode());
$body = $response->getContent();
self::assertStringContainsString('# Kant-IT', $body);
self::assertStringContainsString('> Ihr IT-Partner.', $body);
self::assertStringContainsString('https://cms.test/', $body);
self::assertStringNotContainsString('Intern', $body);
self::assertStringNotContainsString('Entwurf', $body);
}
/**
* @return iterable<string, array{array}>
*/
public static function rejectedSettings(): iterable
{
yield 'invalid email' => [['email' => 'keine-adresse', 'country' => 'DE']];
yield 'invalid phone' => [['phone' => 'ruf an', 'country' => 'DE']];
yield 'invalid country' => [['country' => 'Deutschland']];
yield 'insecure profile' => [['country' => 'DE', 'sameAs' => 'http://example.com']];
yield 'local business without address' => [['country' => 'DE', 'localBusiness' => '1']];
}
#[DataProvider('rejectedSettings')]
public function testRejectsInvalidCompanyDataWithoutWriting(array $input): void
{
try {
$this->site->save($input);
self::fail('Expected the settings to be rejected.');
} catch (InvalidPage) {
}
self::assertNull($this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']));
}
public function testRobotsExtraOnlyAcceptsPlainDirectiveLines(): void
{
$this->site->save([
'country' => 'DE',
'robotsExtra' => "Disallow: /geheim\n# ein Kommentar\n<script>alert(1)</script>\nSitemap: https://fremde.example/sitemap.xml",
]);
$body = $this->request('/robots.txt')->getContent();
self::assertStringContainsString('Disallow: /geheim', $body);
self::assertStringContainsString('# ein Kommentar', $body);
// Neither markup nor a second sitemap declaration survives the filter.
self::assertStringNotContainsString('<script>', $body);
self::assertStringNotContainsString('fremde.example', $body);
self::assertSame(1, substr_count($body, 'Sitemap:'));
}
private static function styledDocument(): array
{
$document = DemoContent::starter('Gestaltet');
$document['schemaVersion'] = 10;
$document['blocks'][0]['style'] = [
'desktop' => [
'backgroundColor' => '@mint',
'color' => '#123456',
'padding' => ['top' => '80px', 'bottom' => '80px'],
'borderRadius' => ['topLeft' => '24px'],
'fontSize' => '42px',
'letterSpacing' => '-1.5px',
'shadowY' => '12px', 'shadowBlur' => '40px', 'shadowColor' => '#00000022',
'hover' => ['backgroundColor' => '#ffffff', 'scale' => 1.02],
],
'mobile' => ['fontSize' => '28px', 'padding' => ['top' => '32px']],
];
return $document;
}
public function testFreeStylesSurviveSavingAndBecomeScopedCss(): void
{
$document = self::styledDocument();
$revision = $this->pages->saveDraft('page-1', $document, 0);
self::assertSame($document, json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]), true));
$this->pages->publish('page-1', $revision, 1);
$html = $this->request('/')->getContent();
// Values become declarations scoped to the block, palette colours become variables.
self::assertStringContainsString('#start-hero{', $html);
self::assertStringContainsString('background-color:var(--k-mint)', $html);
self::assertStringContainsString('color:#123456', $html);
self::assertStringContainsString('padding-top:80px', $html);
self::assertStringContainsString('border-top-left-radius:24px', $html);
self::assertStringContainsString('letter-spacing:-1.5px', $html);
self::assertStringContainsString('box-shadow:0 12px 40px 0 #00000022', $html);
// Hover and the narrow breakpoint get their own rules.
self::assertStringContainsString('#start-hero:hover,#start-hero:focus-within{', $html);
self::assertStringContainsString('transform:scale(1.02)', $html);
self::assertStringContainsString('@media (max-width:767px){#start-hero{font-size:28px', $html);
}
public function testInlineStylesheetIsAllowedByItsOwnHashAndNothingElse(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', self::styledDocument(), 0), 1);
$response = $this->request('/');
$policy = (string) $response->headers->get('Content-Security-Policy');
preg_match('#<style>(.*?)</style>#s', $response->getContent(), $match);
self::assertNotEmpty($match[1] ?? '', 'The page carries a generated stylesheet.');
$expected = base64_encode(hash('sha256', html_entity_decode($match[1], ENT_QUOTES), true));
self::assertStringContainsString(sprintf("'sha256-%s'", $expected), $policy);
// Never a blanket permission for inline styles, and scripting stays off.
self::assertStringNotContainsString('unsafe-inline', $policy);
self::assertStringContainsString("script-src 'none'", $policy);
}
/**
* @return iterable<string, array{array}>
*/
public static function rejectedStyles(): iterable
{
$wrap = static function (array $style): array {
$document = DemoContent::starter('Titel');
$document['schemaVersion'] = 10;
$document['blocks'][0]['style'] = $style;
return [$document];
};
yield 'unknown property' => $wrap(['desktop' => ['zIndexHack' => '9999']]);
yield 'unknown device' => $wrap(['watch' => ['fontSize' => '12px']]);
yield 'colour that is a word' => $wrap(['desktop' => ['color' => 'red']]);
yield 'colour with a function' => $wrap(['desktop' => ['color' => 'rgb(1,2,3)']]);
yield 'length without unit' => $wrap(['desktop' => ['fontSize' => '18']]);
yield 'length with expression' => $wrap(['desktop' => ['fontSize' => 'calc(1px + 2px)']]);
yield 'value with a declaration' => $wrap(['desktop' => ['fontSize' => '18px;position:fixed']]);
yield 'choice outside the list' => $wrap(['desktop' => ['display' => 'contents']]);
yield 'number out of range' => $wrap(['desktop' => ['opacity' => 4]]);
yield 'side given as a single value' => $wrap(['desktop' => ['padding' => '10px']]);
yield 'unknown side' => $wrap(['desktop' => ['padding' => ['middle' => '10px']]]);
yield 'hover on a property without hover' => $wrap(['desktop' => ['hover' => ['fontSize' => '10px']]]);
yield 'font key with quotes' => $wrap(['desktop' => ['fontFamily' => '"Inter",serif']]);
yield 'background that is not a media id' => $wrap(['desktop' => ['backgroundImage' => '/etc/passwd']]);
}
#[DataProvider('rejectedStyles')]
public function testRejectsInvalidStylesBeforeWriting(array $document): void
{
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
}
public function testFreeStylesNeedSchemaTen(): void
{
$document = self::styledDocument();
$document['schemaVersion'] = 9;
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
}
public function testThemeDrivesEveryPageAndGoogleFontsOpenTheirOwnPolicy(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
// Without a stored theme the defaults apply and no third party is contacted.
$plain = $this->request('/');
self::assertStringContainsString('--k-ink:#203b34', $plain->getContent());
self::assertStringNotContainsString('fonts.googleapis.com', $plain->getContent());
self::assertStringNotContainsString('fonts.gstatic.com', (string) $plain->headers->get('Content-Security-Policy'));
$this->themes->save(
[['key' => 'marke', 'name' => 'Marke', 'value' => '#Ff3300'], ['key' => 'ink', 'name' => 'Tinte', 'value' => '#111111']],
[['key' => 'body', 'name' => 'Text', 'family' => 'Inter', 'source' => 'google', 'weights' => [400, 700], 'fallback' => 'sans-serif']],
['fontFamily' => 'body', 'headingFontFamily' => 'body', 'fontSize' => '20px', 'lineHeight' => 1.7, 'color' => '@ink', 'backgroundColor' => '#ffffff', 'linkColor' => '@marke', 'containerWidth' => '1100px', 'headingScale' => 1.3],
);
$response = $this->request('/');
$html = $response->getContent();
self::assertStringContainsString('--k-marke:#ff3300', $html, 'colour values are normalised');
self::assertStringContainsString('--kf-body:"Inter",sans-serif', $html);
self::assertStringContainsString('font-size:var(--k-size)', $html);
self::assertStringContainsString('--k-container:1100px', $html);
self::assertStringContainsString('fonts.googleapis.com/css2?family=Inter:wght@400;700', $html);
$policy = (string) $response->headers->get('Content-Security-Policy');
self::assertStringContainsString('https://fonts.googleapis.com', $policy);
self::assertStringContainsString("font-src 'self' https://fonts.gstatic.com", $policy);
}
/**
* @return iterable<string, array{array, array, array}>
*/
public static function rejectedThemes(): iterable
{
$font = ['key' => 'body', 'name' => 'Text', 'family' => '', 'source' => 'system', 'weights' => [400], 'fallback' => 'system-ui'];
$base = ['fontFamily' => 'body', 'headingFontFamily' => 'body', 'color' => '@ink', 'backgroundColor' => '#ffffff', 'linkColor' => '#000000', 'fontSize' => '18px', 'containerWidth' => '1240px', 'lineHeight' => 1.6, 'headingScale' => 1.25];
yield 'colour that is not hex' => [[['key' => 'ink', 'name' => 'T', 'value' => 'darkgreen']], [$font], $base];
yield 'duplicate colour key' => [[['key' => 'ink', 'name' => 'A', 'value' => '#000000'], ['key' => 'ink', 'name' => 'B', 'value' => '#111111']], [$font], $base];
yield 'font family with a stack' => [[['key' => 'ink', 'name' => 'T', 'value' => '#000000']], [[...$font, 'family' => 'Inter,serif']], $base];
yield 'google font without a name' => [[['key' => 'ink', 'name' => 'T', 'value' => '#000000']], [[...$font, 'source' => 'google']], $base];
yield 'base colour without palette entry' => [[['key' => 'other', 'name' => 'T', 'value' => '#000000']], [$font], $base];
yield 'base size without unit' => [[['key' => 'ink', 'name' => 'T', 'value' => '#000000']], [$font], [...$base, 'fontSize' => '18']];
yield 'line height out of range' => [[['key' => 'ink', 'name' => 'T', 'value' => '#000000']], [$font], [...$base, 'lineHeight' => 9]];
yield 'no colour at all' => [[], [$font], $base];
}
#[DataProvider('rejectedThemes')]
public function testRejectsInvalidThemesWithoutWriting(array $colors, array $fonts, array $base): void
{
$before = $this->db->fetchOne('SELECT theme FROM website WHERE id = ?', ['default']);
try {
$this->themes->save($colors, $fonts, $base);
self::fail('Expected the theme to be rejected.');
} catch (InvalidPage) {
}
self::assertSame($before, $this->db->fetchOne('SELECT theme FROM website WHERE id = ?', ['default']));
self::assertSame(Theme::DEFAULTS['colors'], $this->themes->load()['colors']);
}
public function testMovingAPageCarriesItsSubtreeAndLeavesRedirectsBehind(): void
{
$parent = $this->publishedPage('leistungen', 'Leistungen');
$child = $this->publishedChild('webdesign', 'Webdesign', $parent);
$grandchild = $this->publishedChild('shop', 'Shop', $child);
foreach (['leistungen', 'leistungen/webdesign', 'leistungen/webdesign/shop'] as $path) {
self::assertSame(200, $this->request('/'.$path)->getStatusCode(), $path);
}
$moved = $this->pages->movePage($parent, 'services', null, $this->pages->findForEditor($parent)['version'], $this->redirects);
self::assertSame(3, $moved, 'The page and both descendants change address.');
// Every new address answers and every old one redirects permanently.
foreach ([
'services' => 'leistungen',
'services/webdesign' => 'leistungen/webdesign',
'services/webdesign/shop' => 'leistungen/webdesign/shop',
] as $now => $before) {
self::assertSame(200, $this->request('/'.$now)->getStatusCode(), $now);
$old = $this->request('/'.$before);
self::assertSame(301, $old->getStatusCode(), $before);
self::assertSame('/'.$now, $old->headers->get('Location'), $before);
}
// The grandchild kept its own slug and its parent link.
self::assertSame('shop', $this->db->fetchOne('SELECT slug FROM page WHERE id = ?', [$grandchild]));
self::assertSame($child, $this->db->fetchOne('SELECT parent_id FROM page WHERE id = ?', [$grandchild]));
}
public function testMoveRefusesCyclesCollisionsAndTheHomePage(): void
{
$parent = $this->publishedPage('leistungen', 'Leistungen');
$child = $this->publishedChild('webdesign', 'Webdesign', $parent);
$other = $this->publishedPage('beratung', 'Beratung');
$cases = [
'into its own child' => [$parent, 'leistungen', $child],
'into itself' => [$parent, 'leistungen', $parent],
'onto a taken address' => [$other, 'leistungen', null],
'with a slug that is no slug' => [$other, 'mit leerzeichen', null],
];
foreach ($cases as $name => [$page, $slug, $target]) {
try {
$this->pages->movePage($page, $slug, $target, $this->pages->findForEditor($page)['version'], $this->redirects);
self::fail(sprintf('Expected "%s" to be refused.', $name));
} catch (InvalidPage) {
}
}
// The home page keeps its address; a different start page is a settings matter.
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
try {
$this->pages->movePage('page-1', 'start', null, $this->pages->findForEditor('page-1')['version'], $this->redirects);
self::fail('Expected the home page to be refused.');
} catch (InvalidPage) {
}
// Nothing moved and no redirect was invented.
self::assertSame(['beratung', 'home', 'leistungen', 'leistungen/webdesign', 'private'], $this->db->fetchFirstColumn('SELECT path FROM page ORDER BY path'));
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM redirect'));
}
public function testTrashHidesAPageEverywhereAndRestoreBringsItBack(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
$parent = $this->publishedPage('leistungen', 'Leistungen');
$child = $this->publishedChild('webdesign', 'Webdesign', $parent);
$post = $this->publishedPost('ein-beitrag', 'Ein Beitrag', '2026-02-01', ['Projekte'], []);
$this->menus->save('header', 'Hauptnavigation', [
['label' => 'Leistungen', 'type' => 'page', 'pageId' => $parent, 'anchor' => '', 'children' => []],
], 0);
self::assertStringContainsString('Leistungen', $this->request('/')->getContent());
// Trashing takes the subtree along.
self::assertSame(2, $this->pages->trashPage($parent, $this->pages->findForEditor($parent)['version']));
self::assertSame(404, $this->request('/leistungen')->getStatusCode());
self::assertSame(404, $this->request('/leistungen/webdesign')->getStatusCode());
self::assertStringNotContainsString('Leistungen', $this->request('/')->getContent(), 'A trashed page leaves the navigation.');
self::assertStringNotContainsString('/leistungen', $this->request('/sitemap.xml')->getContent());
self::assertNull($this->pages->findForEditor($parent), 'A trashed page is out of the editor listing.');
self::assertSame([$parent, $child], array_column($this->pages->trash(), 'id'));
// A trashed post disappears from the archive and the feed as well.
$this->pages->trashPage($post, $this->db->fetchOne('SELECT version FROM page WHERE id = ?', [$post]));
self::assertSame(0, $this->posts->published()['total']);
self::assertStringNotContainsString('Ein Beitrag', $this->request('/feed.xml')->getContent());
// A child cannot come back before its parent, because its address needs it.
try {
$this->pages->restorePage($child);
self::fail('Expected the child to wait for its parent.');
} catch (InvalidPage) {
}
$this->pages->restorePage($parent);
$this->pages->restorePage($child);
self::assertSame(200, $this->request('/leistungen/webdesign')->getStatusCode());
// Only the post is left in the bin; restoring one page does not restore another.
self::assertSame([$post], array_column($this->pages->trash(), 'id'));
}
public function testRestoringARevisionKeepsTheWholeHistory(): void
{
$first = DemoContent::starter('Erste Fassung');
$second = DemoContent::starter('Zweite Fassung');
$this->pages->saveDraft('page-1', $first, 0);
$this->pages->saveDraft('page-1', $second, 1);
$revisions = $this->pages->revisions('page-1');
self::assertSame(['Zweite Fassung', 'Erste Fassung'], array_column($revisions, 'title'), 'Newest first.');
self::assertTrue($revisions[0]['draft']);
$oldest = end($revisions);
$this->pages->restoreRevision('page-1', $oldest['id'], 2);
self::assertSame('Erste Fassung', $this->pages->findForEditor('page-1')['document']['title']);
// Restoring adds a revision instead of overwriting one, so nothing is lost.
self::assertCount(3, $this->pages->revisions('page-1'));
self::assertSame('Zweite Fassung', $this->pages->revisions('page-1')[1]['title']);
$foreign = $this->pages->saveDraft('page-2', DemoContent::starter('Fremd'), 0);
$this->expectException(InvalidPage::class);
$this->pages->restoreRevision('page-1', $foreign, 3);
}
public function testScheduledPublicationHappensOnlyWhenItIsDue(): void
{
$id = $this->pages->createPage('geplant', null, 'page', DemoContent::starter('Geplant'));
$this->pages->schedule($id, gmdate('Y-m-d H:i', time() + 86400), $this->pages->findForEditor($id)['version']);
self::assertSame([], $this->pages->publishDue(), 'A future date publishes nothing.');
self::assertSame(404, $this->request('/geplant')->getStatusCode());
$this->pages->schedule($id, gmdate('Y-m-d H:i', time() - 3600), $this->pages->findForEditor($id)['version']);
self::assertSame(['geplant'], $this->pages->publishDue());
self::assertSame(200, $this->request('/geplant')->getStatusCode());
// The schedule is consumed, so a later run does not publish again.
self::assertNull($this->db->fetchOne('SELECT publish_at FROM page WHERE id = ?', [$id]));
self::assertSame([], $this->pages->publishDue());
// A trashed page is never published by the schedule.
$trashed = $this->pages->createPage('versteckt', null, 'page', DemoContent::starter('Versteckt'));
$this->pages->schedule($trashed, gmdate('Y-m-d H:i', time() - 3600), $this->pages->findForEditor($trashed)['version']);
$this->pages->trashPage($trashed, $this->db->fetchOne('SELECT version FROM page WHERE id = ?', [$trashed]));
self::assertSame([], $this->pages->publishDue());
$this->expectException(InvalidPage::class);
$this->pages->schedule($id, '12.09.2026', $this->db->fetchOne('SELECT version FROM page WHERE id = ?', [$id]));
}
private function publishedChild(string $slug, string $title, string $parentId): string
{
$id = $this->pages->createPage($slug, $parentId, 'page', DemoContent::starter($title));
$page = $this->pages->findForEditor($id);
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
return $id;
}
public function testAnyPublishedPageCanBecomeTheRootAndEverythingFollows(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
$start = $this->publishedPage('willkommen', 'Willkommen');
self::assertNotNull($this->pages->findForEditor($start));
$this->site->save(['country' => 'DE', 'homePath' => 'willkommen', 'legalName' => 'Kant-IT', 'street' => 'Ooser Hauptstr. 51', 'city' => 'Baden-Baden']);
// The chosen page answers at the root, and its own address redirects there.
$root = $this->request('/');
self::assertSame(200, $root->getStatusCode());
self::assertStringContainsString('>Willkommen</h1>', $root->getContent());
self::assertStringContainsString('href="https://cms.test/"', $root->getContent());
$own = $this->request('/willkommen');
self::assertSame(301, $own->getStatusCode());
self::assertSame('/', $own->headers->get('Location'));
// The previous root became an ordinary page and keeps its own address.
self::assertSame(200, $this->request('/home')->getStatusCode());
// Sitemap, structured data and the menu follow the same decision.
self::assertStringContainsString('<loc>https://cms.test/</loc>', $this->request('/sitemap.xml')->getContent());
self::assertStringContainsString('<loc>https://cms.test/home</loc>', $this->request('/sitemap.xml')->getContent());
self::assertStringContainsString('"@type":"Organization"', $root->getContent(), 'Organisation data belongs on the root page.');
self::assertStringNotContainsString('schema.org', $this->request('/home')->getContent());
$this->menus->save('header', 'Hauptnavigation', [
['label' => 'Start', 'type' => 'page', 'pageId' => $start, 'anchor' => '', 'children' => []],
], 0);
self::assertStringContainsString('<a href="/" aria-current="page">Start</a>', $this->request('/')->getContent());
// The root is protected from being renamed or binned while it is the root.
foreach ([fn () => $this->pages->movePage($start, 'anders', null, $this->pages->findForEditor($start)['version'], $this->redirects),
fn () => $this->pages->trashPage($start, $this->pages->findForEditor($start)['version'])] as $attempt) {
try {
$attempt();
self::fail('Expected the root page to be protected.');
} catch (InvalidPage) {
}
}
// But the page that used to be the root can now be moved.
self::assertSame(1, $this->pages->movePage('page-1', 'alte-startseite', null, $this->pages->findForEditor('page-1')['version'], $this->redirects));
}
public function testRootMustBeAnExistingPublishedPage(): void
{
$before = $this->site->load()['homePath'];
foreach (['gibt-es-nicht', '', 'Gross'] as $candidate) {
try {
$this->site->save(['country' => 'DE', 'homePath' => $candidate]);
self::fail(sprintf('Expected "%s" to be refused as the root.', $candidate));
} catch (InvalidPage) {
}
}
self::assertSame($before, $this->site->load()['homePath']);
}
public function testHeaderAndFooterAreEditableDocumentsThatReachEveryPage(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
$second = $this->publishedPage('zweite', 'Zweite Seite');
self::assertNotNull($this->pages->findForEditor($second));
// The seeded areas render as ordinary blocks, and the navigation block shows the menu.
$this->menus->save('header', 'Hauptnavigation', [
['label' => 'Start', 'type' => 'page', 'pageId' => 'page-1', 'anchor' => '', 'children' => []],
], 0);
$html = $this->request('/')->getContent();
self::assertStringContainsString('<header class="site-header">', $html);
self::assertStringContainsString('<p id="area-header-name" class="block-text">kantsites</p>', $html);
self::assertStringContainsString('aria-label="Hauptnavigation"', $html);
self::assertStringContainsString('>Start</a>', $html);
// Changing the header reaches every page at once, because it is one shared document.
$header = $this->partials->findByKind('header');
$document = $header['document'];
$document['blocks'][0]['children'][0]['children'][0]['children'][0]['props']['text'] = 'Neue Wortmarke';
$this->partials->save($header['id'], $document, $header['version']);
foreach (['/', '/zweite'] as $path) {
self::assertStringContainsString('Neue Wortmarke', $this->request($path)->getContent(), $path);
self::assertStringNotContainsString('kantsites', $this->request($path)->getContent(), $path);
}
// An emptied area renders nothing rather than leaving a broken shell behind.
$footer = $this->partials->findByKind('footer');
$this->partials->save($footer['id'], [...$footer['document'], 'blocks' => []], $footer['version']);
self::assertStringContainsString('<footer class="site-footer">', $this->request('/')->getContent());
self::assertStringNotContainsString('Viele Möglichkeiten', $this->request('/')->getContent());
}
public function testALinkedSectionChangesOnEveryPageThatEmbedsIt(): void
{
$sectionId = $this->partials->create('section', 'aufruf', 'Aufruf-Band', [
'schemaVersion' => 11, 'title' => 'Aufruf-Band', 'description' => 'Wiederverwendbar.',
'blocks' => [['id' => 'cta-section', 'type' => 'section', 'children' => [
['id' => 'cta-heading', 'type' => 'heading', 'props' => ['text' => 'Bereit?', 'level' => 2]],
]]],
]);
$withRef = static function (string $title): array {
$document = DemoContent::starter($title);
$document['schemaVersion'] = 11;
$document['blocks'][] = ['id' => 'ref', 'type' => 'partialref', 'props' => ['partial' => 'aufruf']];
return $document;
};
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $withRef('Erste'), 0), 1);
$other = $this->pages->createPage('zweite', null, 'page', $withRef('Zweite'));
$page = $this->pages->findForEditor($other);
$this->pages->publish($other, $page['draft_revision_id'], $page['version']);
foreach (['/', '/zweite'] as $path) {
self::assertStringContainsString('Bereit?', $this->request($path)->getContent(), $path);
}
// The page documents carry only the address, never the section's blocks.
self::assertSame(['partial' => 'aufruf'], $this->pages->findPublished('zweite')['document']['blocks'][2]['props']);
// One change in the section reaches both pages.
$section = $this->partials->find($sectionId);
$document = $section['document'];
$document['blocks'][0]['children'][0]['props']['text'] = 'Jetzt anfragen';
$this->partials->save($sectionId, $document, $section['version']);
foreach (['/', '/zweite'] as $path) {
self::assertStringContainsString('Jetzt anfragen', $this->request($path)->getContent(), $path);
self::assertStringNotContainsString('Bereit?', $this->request($path)->getContent(), $path);
}
self::assertSame(['/home', '/zweite'], $this->partials->usedBy('aufruf'));
// Removing the section leaves both pages intact; the reference stays stored.
$this->partials->delete($sectionId);
$stripped = $this->request('/');
self::assertSame(200, $stripped->getStatusCode());
self::assertStringContainsString('<!-- Verknüpfte Sektion aufruf ist nicht vorhanden. -->', $stripped->getContent());
self::assertSame('aufruf', $this->pages->findPublished('home')['document']['blocks'][2]['props']['partial']);
}
public function testSharedAreasRefuseAMainHeadingAndSectionsRefuseToContainThemselves(): void
{
// A page brings its own main heading; an area appearing on every page must not add one.
try {
$this->partials->create('section', 'mit-h1', 'Mit H1', [
'schemaVersion' => 11, 'title' => 'Mit H1', 'description' => 'x',
'blocks' => [['id' => 'h', 'type' => 'heading', 'props' => ['text' => 'Titel', 'level' => 1]]],
]);
self::fail('Expected a main heading in a shared area to be refused.');
} catch (InvalidDocument) {
}
$id = $this->partials->create('section', 'selbst', 'Selbstbezug', [
'schemaVersion' => 11, 'title' => 'Selbstbezug', 'description' => 'x',
'blocks' => [['id' => 's', 'type' => 'section', 'children' => []]],
]);
$cyclic = ['schemaVersion' => 11, 'title' => 'Selbstbezug', 'description' => 'x', 'blocks' => [
['id' => 's', 'type' => 'partialref', 'props' => ['partial' => 'selbst']],
]];
try {
$this->partials->save($id, $cyclic, $this->partials->find($id)['version']);
self::fail('Expected a section containing itself to be refused.');
} catch (InvalidPage) {
}
// Nothing was written, so the section still holds its original block.
self::assertSame('section', $this->partials->find($id)['document']['blocks'][0]['type']);
// Only one header and one footer.
try {
$this->partials->create('header', 'zweiter', 'Zweiter Kopf', ['schemaVersion' => 11, 'title' => 'x', 'description' => 'x', 'blocks' => []]);
self::fail('Expected a second header to be refused.');
} catch (InvalidPage) {
}
// And they cannot be deleted, only emptied.
$this->expectException(InvalidPage::class);
$this->partials->delete($this->partials->findByKind('header')['id']);
}
/**
* @return iterable<string, array{string, string}>
*/
public static function richTextCases(): iterable
{
yield 'keeps allowed formatting' => ['Ganz <strong>fett</strong> und <em>kursiv</em>.', 'Ganz <strong>fett</strong> und <em>kursiv</em>.'];
yield 'keeps lists' => ['<ul><li>eins</li><li>zwei</li></ul>', '<ul><li>eins</li><li>zwei</li></ul>'];
yield 'drops a script and keeps the text after it' => ['<script>alert(1)</script>Text danach', 'Text danach'];
yield 'drops an event handler' => ['<p onclick="steal()">Mit Ereignis</p>', '<p>Mit Ereignis</p>'];
yield 'drops style and class' => ['<div class="x" style="position:fixed">Verpackt</div>', 'Verpackt'];
yield 'turns an unsafe link into text' => ['<a href="javascript:alert(1)">Boese</a>', 'Boese'];
yield 'removes an image with a handler' => ['<img src=x onerror=alert(1)>', ''];
yield 'turns a newline into a break next to markup' => ["<b>Eins</b>\nZwei", '<b>Eins</b><br>Zwei'];
yield 'removes an iframe' => ['<iframe src="https://evil.example"></iframe>ok', 'ok'];
yield 'closes unbalanced markup' => ['<strong>unge<em>schlossen', '<strong>unge<em>schlossen</em></strong>'];
yield 'keeps a local link' => ['<a href="/kontakt">hier</a>', '<a href="/kontakt">hier</a>'];
yield 'marks an external link' => ['<a href="https://example.com">dort</a>', '<a href="https://example.com" rel="noopener">dort</a>'];
yield 'escapes plain text and keeps the line break' => ["Eins <3\nZwei", 'Eins &lt;3<br>Zwei'];
}
#[DataProvider('richTextCases')]
public function testTheAllowlistKeepsFormattingAndRemovesEverythingElse(string $input, string $expected): void
{
self::assertSame($expected, (new RichText())->sanitise($input));
}
public function testFilteringHappensWhenTheDocumentIsStored(): void
{
$document = DemoContent::starter('Formatiert');
$document['schemaVersion'] = 12;
$document['blocks'][1]['children'][1]['props']['text'] = '<b>Fett</b><script>alert(1)</script> und <span style="position:fixed">verpackt</span>';
$revision = $this->pages->saveDraft('page-1', $document, 0);
// The filtering happens on the way in, so the database already holds the safe version and
// no renderer has to remember to escape anything.
$stored = json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]), true);
self::assertSame('<b>Fett</b> und verpackt', $stored['blocks'][1]['children'][1]['props']['text']);
// A text that consists only of disallowed markup ends up empty, and an empty text block
// is refused - the same rule as for a text block somebody clears by hand.
$document['blocks'][1]['children'][1]['props']['text'] = '<img src=x onerror=alert(1)>';
try {
$this->pages->saveDraft('page-1', $document, 1);
self::fail('Expected an emptied text block to be refused.');
} catch (InvalidDocument) {
}
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', ['page-1']));
}
public function testFormattedTextIsPrintedAsMarkupFromVersionTwelveOnAndEscapedBefore(): void
{
$markup = 'Ein <strong>starker</strong> Satz mit <a href="/kontakt">Link</a>.';
// v12: the stored markup reaches the page as markup.
$twelve = DemoContent::starter('Formatiert');
$twelve['schemaVersion'] = 12;
$twelve['blocks'][1]['children'][1]['props']['text'] = $markup;
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $twelve, 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('<strong>starker</strong>', $html);
self::assertStringContainsString('<a href="/kontakt">Link</a>', $html);
// A div, not a p: the allowlist includes lists and paragraphs, which may not nest in one.
self::assertStringContainsString('<div id="start-text" class="block-text">', $html);
// An older document keeps plain text semantics, so nothing there is ever printed as markup.
$old = DemoContent::starter('Einfach');
$old['schemaVersion'] = 3;
$old['blocks'][1]['children'][1]['props']['text'] = $markup;
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $old, 2), 3);
$plain = $this->request('/')->getContent();
self::assertStringContainsString('&lt;strong&gt;starker&lt;/strong&gt;', $plain);
self::assertStringNotContainsString('<strong>starker</strong>', $plain);
self::assertStringContainsString('<p id="start-text" class="block-text">', $plain);
}
public function testFormattedTextInASharedAreaIsFilteredTheSameWay(): void
{
$header = $this->partials->findByKind('header');
$document = $header['document'];
$document['schemaVersion'] = 12;
$document['blocks'][0]['children'][0]['children'][0]['children'][0]['props']['text'] =
'<strong>kant</strong> cms<script>alert(1)</script>';
$this->partials->save($header['id'], $document, $header['version']);
$stored = $this->partials->findByKind('header')['document'];
self::assertSame('<strong>kant</strong> cms', $stored['blocks'][0]['children'][0]['children'][0]['children'][0]['props']['text']);
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('<strong>kant</strong> cms', $html);
self::assertStringNotContainsString('alert(1)', $html);
}
public function testOversizedFormattedTextIsRefused(): void
{
$document = DemoContent::starter('Zu lang');
$document['schemaVersion'] = 12;
$document['blocks'][1]['children'][1]['props']['text'] = str_repeat('a', RichText::MAX_LENGTH + 1);
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
}
/**
* @return iterable<string, array{string, string}>
*/
public static function ownCssCases(): iterable
{
yield 'declarations' => ['color: red; padding: 12px', '#b1{color:red;padding:12px}'];
yield 'nested state' => ['&:hover { color: #000 }', '#b1:hover{color:#000}'];
yield 'descendant keeps its space' => ['& .block-text { font-size: 20px }', '#b1 .block-text{font-size:20px}'];
yield 'child combinator' => ['& > p { margin: 0 }', '#b1 > p{margin:0}'];
yield 'local image' => ['background: url(/bild.png)', '#b1{background:url(/bild.png)}'];
}
#[DataProvider('ownCssCases')]
public function testOwnCssIsScopedToItsElement(string $css, string $expected): void
{
self::assertSame($expected, (new CustomCode())->compileCss('b1', $css));
}
/**
* @return iterable<string, array{string}>
*/
public static function refusedOwnCss(): iterable
{
yield 'escaping the block' => ['color:red} body{display:none'];
yield 'foreign selector' => ['body { color: red }'];
yield 'at rule' => ['@import url(https://evil.example/x.css)'];
yield 'media query' => ['@media (max-width: 100px) { color: red }'];
yield 'javascript url' => ['background: url(javascript:alert(1))'];
yield 'protocol relative url' => ['background: url(//evil.example/x.png)'];
yield 'backslash escape' => ['content: "\\65"'];
yield 'old expression syntax' => ['color: expression(alert(1))'];
yield 'no value' => ['color'];
yield 'nonsense property' => ['co lor: red'];
}
#[DataProvider('refusedOwnCss')]
public function testRefusedOwnCssNeverReachesADocument(string $css): void
{
$document = DemoContent::starter('Mit CSS');
$document['schemaVersion'] = 13;
$document['blocks'][0]['css'] = $css;
try {
$this->pages->saveDraft('page-1', $document, 0);
self::fail(sprintf('Expected "%s" to be refused.', $css));
} catch (InvalidDocument) {
}
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', ['page-1']));
}
public function testOwnCssReachesThePageStylesheetAndOwnScriptItsOwnFile(): void
{
$document = DemoContent::starter('Mit eigenem Code');
$document['schemaVersion'] = 13;
$document['blocks'][0]['css'] = 'letter-spacing: 2px; &:hover { opacity: 0.8 }';
$document['blocks'][0]['script'] = "element.dataset.ready = 'yes';";
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$response = $this->request('/');
$html = $response->getContent();
self::assertStringContainsString('#start-hero{letter-spacing:2px}', $html);
self::assertStringContainsString('#start-hero:hover{opacity:0.8}', $html);
// The script is a file, never inline, and the policy opens only for this origin.
self::assertStringContainsString('<script src="/assets/seite/page-1.js" defer></script>', $html);
self::assertStringNotContainsString('element.dataset.ready', $html);
self::assertStringContainsString("script-src 'self'", (string) $response->headers->get('Content-Security-Policy'));
$script = $this->request('/assets/seite/page-1.js');
self::assertSame(200, $script->getStatusCode());
self::assertStringContainsString('text/javascript', (string) $script->headers->get('Content-Type'));
// Each script runs in its own closure with its element already looked up.
self::assertStringContainsString('document.getElementById("start-hero")', $script->getContent());
self::assertStringContainsString("element.dataset.ready = 'yes';", $script->getContent());
// A page without own script keeps the strict policy and loads no file.
$plain = $this->publishedPage('ohne-code', 'Ohne Code');
self::assertNotNull($this->pages->findForEditor($plain));
$other = $this->request('/ohne-code');
self::assertStringNotContainsString('/assets/seite/', $other->getContent());
self::assertStringContainsString("script-src 'none'", (string) $other->headers->get('Content-Security-Policy'));
self::assertSame(404, $this->request('/assets/seite/'.$plain.'.js')->getStatusCode());
}
public function testADraftScriptIsNotServedToVisitors(): void
{
$document = DemoContent::starter('Nur Entwurf');
$document['schemaVersion'] = 13;
$document['blocks'][0]['script'] = "element.dataset.draft = 'yes';";
$this->pages->saveDraft('page-1', $document, 0);
// Saved but not published, so the script file must not exist yet.
self::assertSame(404, $this->request('/assets/seite/page-1.js')->getStatusCode());
}
public function testOwnCodeNeedsSchemaThirteen(): void
{
$document = DemoContent::starter('Zu alt');
$document['schemaVersion'] = 12;
$document['blocks'][0]['css'] = 'color: red';
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
}
public function testScriptChangesAreComparedAgainstWhatIsStored(): void
{
$code = new CustomCode();
$stored = DemoContent::starter('Mit Skript');
$stored['schemaVersion'] = 13;
$stored['blocks'][0]['script'] = "element.dataset.a = '1';";
// Same scripts: an editor may keep saving the page.
$unchanged = $stored;
$unchanged['title'] = 'Anderer Titel';
$code->assertScriptsUnchanged($unchanged, $stored);
foreach (['changed' => "element.dataset.a = '2';", 'removed' => null] as $case => $script) {
$attempt = $stored;
if (null === $script) {
unset($attempt['blocks'][0]['script']);
} else {
$attempt['blocks'][0]['script'] = $script;
}
try {
$code->assertScriptsUnchanged($attempt, $stored);
self::fail(sprintf('Expected a %s script to be refused.', $case));
} catch (InvalidDocument) {
}
}
// And adding one to a page that had none is refused as well.
$added = DemoContent::starter('Neu');
$added['schemaVersion'] = 13;
$before = $added;
$added['blocks'][0]['script'] = 'alert(1)';
$this->expectException(InvalidDocument::class);
$code->assertScriptsUnchanged($added, $before);
}
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', 'MEDIA_DIRECTORY' => $this->path.'-media'] 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;
}
}
}
}
}