Files
KantSites/tests/CmsTest.php
T
kantarciandClaude Opus 5 a40c2bb27e SEO, Weiterleitungen und strukturierte Daten (Arbeitspaket E)
Migration 20260912000800 ergänzt page.robots, page.og_media_id,
website.settings und die Tabelle redirect.

Alles Maschinenlesbare wird aus dem veröffentlichten Bestand
abgeleitet, nichts separat gepflegt: die Sitemap listet nur
veröffentlichte, indexierbare Seiten samt Beitragsübersicht und
Begriffsarchiven, und dieselbe Einstellung setzt auch das
noindex-Meta - beide Aussagen können nicht auseinanderlaufen.

Strukturierte Daten entstehen nur aus gepflegten Werten: ohne
Angaben wird kein leerer Organization-Block ausgegeben. JSON-LD ist
ein Datenblock, kein Skript, daher bleibt script-src 'none'
unberührt.

Weiterleitungen greifen vor dem 404, aber nach der Seitensuche: eine
vorhandene Seite gewinnt immer, sonst könnte ein alter Eintrag eine
neue Seite unerreichbar machen. Ketten werden abgewiesen. robots.txt
nimmt zusätzliche Zeilen nur nach festem Muster an, damit ein
Eintrag die Datei nicht umdeuten kann. llms.txt ist standardmäßig
aus.

Geprüft: 128 PHP-Tests / 507 Assertions unter PHP 8.2.12 und 8.4.22,
14 JavaScript-Modelltests, Lint/Format/Container. Dazu eine reine
Sichtprüfung im Browser auf einer lokalen Demoseite - weiterhin
keine axe- oder Lighthouse-Messung.

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

1078 lines
59 KiB
PHP

<?php
namespace App\Tests;
use App\Content\DemoContent;
use App\Content\DocumentValidator;
use App\Content\MediaLibrary;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PostRepository;
use App\Content\RedirectRepository;
use App\Content\SiteSettings;
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\Version20260912000300;
use DoctrineMigrations\Version20260912000400;
use DoctrineMigrations\Version20260912000500;
use DoctrineMigrations\Version20260912000600;
use DoctrineMigrations\Version20260912000700;
use DoctrineMigrations\Version20260912000800;
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 PostRepository $posts;
private SiteSettings $site;
private RedirectRepository $redirects;
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 Version20260912000300($this->db, new NullLogger()))->up($schema);
(new Version20260912000400($this->db, new NullLogger()))->up($schema);
(new Version20260912000500($this->db, new NullLogger()))->up($schema);
(new Version20260912000600($this->db, new NullLogger()))->up($schema);
(new Version20260912000700($this->db, new NullLogger()))->up($schema);
(new Version20260912000800($this->db, new NullLogger()))->up($schema);
foreach ($schema->toSql($this->db->getDatabasePlatform()) as $sql) {
$this->db->executeStatement($sql);
}
Version20260912000400::applyIndexes($this->db);
Version20260912000600::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);
$this->posts = new PostRepository($this->db, new MediaLibrary($this->db, $this->path.'-media'));
$this->site = new SiteSettings($this->db);
$this->redirects = new RedirectRepository($this->db);
}
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 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;
}
}
}
}
}