Files
KantSites/tests/CmsTest.php
T
kantarciandClaude Fable 5.1 3785d3dfef
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Give the header scrolled colours and equal card heights in 1.11.1
Cards, quotes and call-to-action bands side by side in a row are equal in
height again: the stacking margin meant for a column no longer applies inside
a row. The header can carry a second set of colours for the scrolled page,
the Flatsome transparent header: a "scrolled" state next to "hover", limited
to colours, shadow and opacity, marked by a small script only where it is
used, plus a setting that lets the header float over the first section.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 17:44:53 +02:00

3892 lines
226 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Tests;
use App\Content\Consent;
use App\Content\CustomCode;
use App\Content\DemoContent;
use App\Content\DocumentValidator;
use App\Content\FontLibrary;
use App\Content\FormRepository;
use App\Content\MediaLibrary;
use App\Content\MenuRepository;
use App\Content\NotFoundPage;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\PostLayouts;
use App\Content\PostRepository;
use App\Content\RedirectRepository;
use App\Content\RichText;
use App\Content\SiteSettings;
use App\Content\StyleBundle;
use App\Content\StyleCompiler;
use App\Content\StyleSchema;
use App\Content\Theme;
use App\Content\Turnstile;
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\File\UploadedFile;
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, new MediaLibrary($this->db, $this->path.'-media')));
$this->menus = new MenuRepository($this->db, new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
$this->posts = new PostRepository($this->db, new MediaLibrary($this->db, $this->path.'-media'), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
$this->site = new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media'));
$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 testEditorPaginationAndChoicesKeepTypesFiltersAndParentLookup(): void
{
$ids = [];
for ($i = 0; $i < 43; ++$i) {
$document = DemoContent::pages()['home'];
$document['title'] = 'Eintrag '.sprintf('%02d', $i);
$ids[] = $this->pages->createPage('eintrag-'.sprintf('%02d', $i), null, 'post', $document);
}
$first = $this->pages->editorPage('post');
$second = $this->pages->editorPage('post', 2);
self::assertSame(43, $first['total']);
self::assertCount(20, $first['items']);
self::assertCount(20, $second['items']);
self::assertSame([], array_intersect(array_column($first['items'], 'id'), array_column($second['items'], 'id')));
self::assertCount(3, $this->pages->editorPage('post', 999)['items']);
self::assertSame(3, $this->pages->editorPage('post', 999)['page']);
self::assertCount(43, $this->pages->editorPage('post', -1, 50)['items']);
self::assertSame(2, $this->pages->editorPage('page')['total']);
self::assertSame(1, $this->pages->editorPage('post', query: 'Eintrag 42')['total']);
self::assertSame(0, $this->pages->editorPage('post', query: '%')['total'], 'LIKE wildcards are literal search text.');
self::assertSame(0, $this->pages->editorPage('post', query: '_')['total']);
$saved = $this->pages->findForEditor($ids[0]);
$this->pages->publish($ids[0], $saved['draft_revision_id'], $saved['version']);
self::assertSame(1, $this->pages->editorPage('post', status: 'published')['total']);
self::assertSame(42, $this->pages->editorPage('post', status: 'draft')['total']);
$this->pages->saveDraft($ids[0], DemoContent::pages()['home'], $saved['version'] + 1);
self::assertSame(1, $this->pages->editorPage('post', status: 'changed')['total']);
$this->db->update('page', ['deleted_at' => gmdate('Y-m-d H:i:s')], ['id' => $ids[42]]);
self::assertSame(42, $this->pages->editorPage('post')['total']);
self::assertSame(1, $this->pages->editorPage('post', status: 'trash')['total']);
self::assertCount(20, $this->pages->choices(type: 'post'));
self::assertSame([], $this->pages->choices(id: $ids[42]));
self::assertCount(2, $this->pages->choices(parentsOnly: true));
self::assertSame('page-2', $this->pages->choices(id: 'page-2', parentsOnly: true)[0]['id']);
self::assertSame([], $this->pages->choices(id: $ids[1], parentsOnly: true));
}
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 testDefault404UsesTheNormalAreasAndThemeForEveryPublicUrlShape(): void
{
foreach (['/missing', '/Missing/old.html', '/eins/zwei/drei/vier/fuenf', '/__404-vorschau__', '/kategorie/missing'] as $path) {
$response = $this->request($path);
self::assertSame(404, $response->getStatusCode(), $path);
self::assertSame('noindex, follow', $response->headers->get('X-Robots-Tag'));
self::assertFalse($response->headers->has('Location'));
$html = $response->getContent();
self::assertStringContainsString('Seite nicht gefunden', $html);
self::assertStringContainsString('class="site-header', $html);
self::assertStringContainsString('class="site-footer', $html);
self::assertStringContainsString('--k-', $html);
self::assertStringNotContainsString('rel="canonical"', $html);
self::assertStringNotContainsString('application/ld+json', $html);
}
$head = $this->request('/Missing/old.html', 'HEAD');
self::assertSame(404, $head->getStatusCode());
self::assertSame('', $head->getContent());
}
public function testCustom404KeepsDraftPrivateAndSurvivesRenamingAndFallsBackAfterDeletion(): void
{
$document = NotFoundPage::document();
$document['blocks'][0]['children'][1]['props']['text'] = 'Unser individueller Fehlerhinweis';
$id = $this->pages->createPage('fehlerseite', null, 'page', $document);
$this->site->assignNotFoundPage($id, '');
self::assertStringNotContainsString('Unser individueller Fehlerhinweis', $this->request('/unbekannt')->getContent());
$page = $this->pages->findForEditor($id);
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
$this->pages->saveAreas($id, 'none', 'none', 2);
foreach (['/unbekannt', '/fehlerseite', '/alte.Datei.html'] as $url) {
$response = $this->request($url);
self::assertSame(404, $response->getStatusCode());
self::assertStringContainsString('Unser individueller Fehlerhinweis', $response->getContent());
self::assertStringContainsString('class="site-header', $response->getContent());
self::assertStringContainsString('class="site-footer', $response->getContent());
}
$this->site->save(['homePath' => 'home', 'llmsTxt' => true]);
self::assertSame($id, $this->site->load()['notFoundPageId']);
self::assertStringNotContainsString('/fehlerseite', $this->request('/sitemap.xml')->getContent());
self::assertStringNotContainsString('/fehlerseite', $this->request('/llms.txt')->getContent());
$this->pages->movePage($id, 'fehler-umbenannt', null, 3, $this->redirects);
self::assertStringContainsString('Unser individueller Fehlerhinweis', $this->request('/weiterhin-falsch')->getContent());
$document['blocks'][0]['children'][1]['props']['text'] = 'Noch geheimer Entwurf';
$page = $this->pages->findForEditor($id);
$revision = $this->pages->saveDraft($id, $document, $page['version']);
self::assertStringNotContainsString('Noch geheimer Entwurf', $this->request('/weiterhin-falsch')->getContent());
$page = $this->pages->findForEditor($id);
$this->pages->publish($id, $revision, $page['version']);
self::assertStringContainsString('Noch geheimer Entwurf', $this->request('/weiterhin-falsch')->getContent());
$page = $this->pages->findForEditor($id);
$this->pages->trashPage($id, $page['version']);
self::assertStringContainsString('Seite nicht gefunden', $this->request('/weiterhin-falsch')->getContent());
self::assertStringNotContainsString('Noch geheimer Entwurf', $this->request('/weiterhin-falsch')->getContent());
}
public function test404CreationPreservesExistingPagesAndRejectsStaleAssignments(): void
{
$existing = $this->pages->createPage('404', null, 'page', DemoContent::starter('Vorhandene Seite'));
$service = new NotFoundPage($this->site, $this->pages, $this->db);
$id = $service->create('');
$created = $this->pages->findForEditor($id);
self::assertSame('404-2', $created['path']);
self::assertNull($created['published_revision_id']);
self::assertSame('noindex', $created['robots']);
self::assertSame('Vorhandene Seite', $this->pages->findForEditor($existing)['document']['title']);
$count = $this->db->fetchOne('SELECT COUNT(*) FROM page');
try {
$service->create('');
self::fail('Repeated creation must not create another page.');
} catch (InvalidPage) {
self::assertSame($count, $this->db->fetchOne('SELECT COUNT(*) FROM page'));
}
try {
$this->site->assignNotFoundPage('', '');
self::fail('A stale settings form must not reset a newer assignment.');
} catch (StaleWrite) {
self::assertSame($id, $this->site->load()['notFoundPageId']);
}
foreach (['page-1', 'missing', $this->pages->createPage('beitrag', null, 'post', DemoContent::starter('Beitrag', 'post'))] as $invalid) {
try {
$this->site->assignNotFoundPage($invalid, $id);
self::fail('The home page, posts and missing pages cannot serve as the 404 page.');
} catch (InvalidPage) {
self::assertSame($id, $this->site->load()['notFoundPageId']);
}
}
$this->site->assignNotFoundPage('', $id);
self::assertSame('', $this->site->load()['notFoundPageId']);
}
public function testCustom404DoesNotReplaceRedirectsGoneResourcesOrAdminEndpoints(): void
{
$document = NotFoundPage::document();
$document['title'] = 'CUSTOM404MARKER';
$id = $this->pages->createPage('fehler', null, 'page', $document);
$page = $this->pages->findForEditor($id);
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
$this->site->assignNotFoundPage($id, '');
$this->redirects->save('umgezogen', 'home', 301);
$this->redirects->save('entfernt', '', 410);
self::assertSame(301, $this->request('/umgezogen')->getStatusCode());
self::assertSame(410, $this->request('/entfernt')->getStatusCode());
self::assertStringNotContainsString('CUSTOM404MARKER', $this->request('/entfernt')->getContent());
foreach (['/assets/missing.js', '/media/missing', '/admin/api/missing', '/missing.json', '/llms.txt'] as $url) {
self::assertStringNotContainsString('CUSTOM404MARKER', $this->request($url)->getContent(), $url);
}
}
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.
// Desktop and collapsed menu carry the same marker; CSS exposes one variant at a time.
self::assertSame(2, substr_count($html, 'aria-current="page"'));
self::assertMatchesRegularExpression('~<a href="/" aria-current="page">\s*<span class="nav-label"><span>Start</span></span>\s*</a>~', $html);
// On another page the marker moves and nothing else changes.
$other = $this->request('/ueber-uns')->getContent();
self::assertMatchesRegularExpression('~<a href="/ueber-uns" aria-current="page">\s*<span class="nav-label"><span>Ueber uns</span></span>\s*</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, but an archive with nothing to list is a
// dead end - it answers 404 until a post carries the tag again.
self::assertSame(404, $this->request('/schlagwort/wordpress')->getStatusCode());
}
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 testAHeadingCanHighlightOneWordAndAStoredHeadingStaysFiltered(): void
{
// Im Original von kant-it.de ist die zweite Zeile der Hauptüberschrift rot. Dafür darf
// eine Überschrift ab v17 Auszeichnungen tragen - durch denselben Filter wie jeder Text,
// damit über diesen Weg kein Skript in eine Seite gelangt.
$document = DemoContent::starter('Hervorhebung');
$document['schemaVersion'] = 17;
$document['blocks'][0]['children'][0]['props']['text'] = 'Ihr Partner für <mark>digitale Exzellenz</mark><script>alert(1)</script>';
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('<mark>digitale Exzellenz</mark>', $html);
self::assertStringNotContainsString('alert(1)', $html);
self::assertStringNotContainsString('<script>alert', $html);
// Das hervorgehobene Wort nimmt die Akzentfarbe statt eines gelben Textmarkers.
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
self::assertStringContainsString('.kant h1 mark,.kant h2 mark,.kant h3 mark{background:transparent;color:var(--accent)', $css);
// Vor v17 bleibt eine Überschrift wörtlich, damit nichts nachträglich zu Markup wird.
$document['schemaVersion'] = 16;
$document['blocks'][0]['children'][0]['props']['text'] = 'Ganz <mark>ohne</mark> Auszeichnung';
$revision = $this->pages->saveDraft('page-1', $document, $this->pages->findForEditor('page-1')['version']);
$this->pages->publish('page-1', $revision, $this->pages->findForEditor('page-1')['version']);
self::assertStringContainsString('Ganz &lt;mark&gt;ohne&lt;/mark&gt; Auszeichnung', $this->request('/')->getContent());
}
public function testALogoCanLinkToTheStartPageAndTheHeaderCanStick(): void
{
// Zwei Selbstverständlichkeiten, die fehlten: ein Bild ließ sich nicht verlinken - also
// auch kein Logo auf die Startseite - und der Kopfbereich konnte beim Scrollen nicht oben
// bleiben. Beides ist beim Nachbau von kant-it.de aufgefallen.
$document = DemoContent::starter('Mit Logo');
$document['schemaVersion'] = 17;
$document['blocks'][] = ['id' => 'logo', 'type' => 'image', 'props' => [
'src' => '/media/'.str_repeat('a', 32).'/480', 'alt' => 'Firmenlogo', 'decorative' => false,
'width' => 480, 'height' => 120, 'href' => '/',
]];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('<a class="image-link" href="/">', $html);
self::assertStringContainsString('alt="Firmenlogo"', $html);
// Datum, Autor und Kategorie über einem Beitrag lassen sich abschalten - ein Beitrag mit
// eigenem Kopfbild trägt den Titel schon, und die Zeile darüber steht dann im Weg.
$this->publishedPost('projekt', 'Ein Projekt', '2026-02-01', ['Projekte'], []);
self::assertStringContainsString('post-header', $this->request('/projekt')->getContent());
$vorher = $this->site->load();
$this->site->save([...$vorher, 'postMetaHeader' => false, 'sameAs' => '', 'consentGroups' => $vorher['consentGroups']]);
self::assertStringNotContainsString('post-header', $this->request('/projekt')->getContent());
// Der Kopfbereich klebt erst, wenn die Einstellung es sagt.
self::assertStringContainsString('class="site-header"', $html);
$settings = $this->site->load();
$this->site->save([...$settings, 'stickyHeader' => true, 'sameAs' => '', 'consentGroups' => $settings['consentGroups']]);
self::assertStringContainsString('class="site-header is-sticky"', $this->request('/')->getContent());
}
/** @return iterable<string, array{string, bool}> */
public static function imageLinks(): iterable
{
yield 'lokal' => ['/kontakt', true];
yield 'Sprungmarke' => ['#kontakt', true];
yield 'HTTPS' => ['https://beispiel.de/', true];
yield 'Skript' => ['javascript:alert(1)', false];
yield 'Datenadresse' => ['data:text/html,hi', false];
yield 'schemalos' => ['//fremd.test', false];
}
#[DataProvider('imageLinks')]
public function testAnImageLinkFollowsTheSameRulesAsEveryOtherLink(string $href, bool $allowed): void
{
$document = DemoContent::starter('Bildlink');
$document['schemaVersion'] = 17;
$document['blocks'][] = ['id' => 'logo', 'type' => 'image', 'props' => [
'src' => '/bild.webp', 'alt' => 'Logo', 'decorative' => false, 'width' => 400, 'height' => 100, 'href' => $href,
]];
if (!$allowed) {
$this->expectException(InvalidDocument::class);
}
(new DocumentValidator())->validate($document);
if ($allowed) {
self::assertTrue(true, 'Erlaubte Adresse wird angenommen.');
}
}
public function testSliderControlsLoadOnlyForPagesWithSliders(): void
{
$document = DemoContent::starter('Mit Slider');
$document['schemaVersion'] = 17;
$document['blocks'][] = ['id' => 'slider', 'type' => 'slider', 'props' => [
'desktopVisible' => 1, 'tabletVisible' => 1, 'mobileVisible' => 1, 'snap' => true, 'autoplay' => 0,
], 'children' => []];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$ohne = $this->request('/');
self::assertStringContainsString('slider.js?v=', $ohne->getContent());
self::assertStringNotContainsString('data-autoplay=', $ohne->getContent());
self::assertStringContainsString("script-src 'self'", (string) $ohne->headers->get('Content-Security-Policy'));
$document['blocks'][array_key_last($document['blocks'])]['props']['autoplay'] = 7;
$revision = $this->pages->saveDraft('page-1', $document, $this->pages->findForEditor('page-1')['version']);
$this->pages->publish('page-1', $revision, $this->pages->findForEditor('page-1')['version']);
$mit = $this->request('/');
self::assertStringContainsString('<script type="module" src="/assets/slider.js?v=', $mit->getContent());
self::assertStringContainsString('data-autoplay="7"', $mit->getContent());
self::assertStringContainsString("script-src 'self'", (string) $mit->headers->get('Content-Security-Policy'));
// Removing the block removes the public dependency and closes the script policy again.
array_pop($document['blocks']);
$revision = $this->pages->saveDraft('page-1', $document, $this->pages->findForEditor('page-1')['version']);
$this->pages->publish('page-1', $revision, $this->pages->findForEditor('page-1')['version']);
$plain = $this->request('/');
self::assertStringNotContainsString('slider.js', $plain->getContent());
self::assertStringContainsString("script-src 'none'", (string) $plain->headers->get('Content-Security-Policy'));
}
public function testTheSamePostsCanBeShownInThreeWays(): void
{
// Umut wollte auch für die Auflistung mehrere Darstellungen. Es sind dieselben Daten und
// dasselbe Markup - nur die Klasse wechselt, damit eine Darstellung nichts kostet außer
// ein paar Regeln CSS.
$this->publishedPost('projekt-eins', 'Projekt eins', '2026-02-01', ['Projekte'], []);
// Jeder Schritt hebt die Fassung um eins: erst das Speichern, dann das Veröffentlichen.
$version = 0;
foreach (['karten', 'liste', 'kompakt', 'wechsel'] as $art) {
$document = DemoContent::starter('Übersicht');
$document['schemaVersion'] = 18;
$document['blocks'][] = ['id' => 'liste', 'type' => 'postlist', 'props' => [
'count' => 4, 'category' => 'projekte', 'tag' => '', 'layout' => $art,
]];
$revision = $this->pages->saveDraft('page-1', $document, $version);
$this->pages->publish('page-1', $revision, ++$version);
++$version;
$html = $this->request('/')->getContent();
self::assertStringContainsString('post-list post-list-'.$art, $html, $art);
self::assertStringContainsString('Projekt eins', $html, $art);
}
// Wechselseitig kehrt jede zweite Zeile um - die Karte selbst ist das Listenelement,
// also zählt nth-child auf ihr und nicht eine Ebene tiefer.
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
self::assertStringContainsString('.post-list-wechsel .post-card:nth-child(even) .post-image{order:2}', $css);
// Und die Nummer kommt aus einem Zähler, nicht aus dem Inhalt: eine Liste, die auf Seite
// zwei wieder bei 01 beginnt, wäre falsch beschriftet, aber sie wäre ehrlich - der Inhalt
// weiß nicht, der wievielte Beitrag er ist.
self::assertStringContainsString('counter-increment:beitrag', $css);
// Die kompakte Darstellung lässt die Bilder ganz weg - sonst wäre sie nicht kompakt.
$document = DemoContent::starter('Übersicht');
$document['schemaVersion'] = 18;
$document['blocks'][] = ['id' => 'liste', 'type' => 'postlist', 'props' => ['count' => 4, 'category' => 'projekte', 'tag' => '', 'layout' => 'kompakt']];
$revision = $this->pages->saveDraft('page-1', $document, $version);
$this->pages->publish('page-1', $revision, ++$version);
self::assertStringNotContainsString('post-image', $this->request('/')->getContent());
// Und eine erfundene Darstellung wird abgewiesen, statt still auf etwas zurückzufallen.
$document = DemoContent::starter('Übersicht');
$document['schemaVersion'] = 18;
$document['blocks'][] = ['id' => 'liste', 'type' => 'postlist', 'props' => ['count' => 4, 'layout' => 'kacheln']];
$this->expectException(InvalidDocument::class);
(new DocumentValidator())->validate($document);
}
public function testEntranceEffectsLoadOnlyWhenUsedAndKeepTheirSelectionInHtml(): void
{
$document = DemoContent::starter('Mit Bewegung');
$document['schemaVersion'] = 19;
$document['blocks'][0]['layout']['effects']['entrance'] = 'left';
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$response = $this->request('/');
self::assertStringContainsString('fx-entrance-left', $response->getContent());
self::assertStringContainsString('motion.js?v=', $response->getContent());
self::assertStringContainsString("script-src 'self'", (string) $response->headers->get('Content-Security-Policy'));
unset($document['blocks'][0]['layout']['effects']['entrance']);
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 2), 3);
self::assertStringNotContainsString('motion.js?v=', $this->request('/')->getContent());
// Und die drei Beitragsvorlagen bringen die Einblendung von sich aus mit.
foreach (array_keys(PostLayouts::all()) as $key) {
$document = PostLayouts::document($key, 'Ein Beitrag');
$mitEffekt = array_filter($document['blocks'], static fn (array $block): bool => isset($block['layout']['effects']['entrance']));
self::assertCount(count($document['blocks']), $mitEffekt, $key.': jeder Abschnitt blendet ein.');
(new DocumentValidator())->validate($document);
}
}
public function testANewPostStartsFromOneOfThreeLayouts(): void
{
// Umut wollte mehrere Aufbauten zur Auswahl. Ein leeres Dokument ist für eine Seite
// richtig - dort weiß niemand vorher, was daraus wird -, für einen Beitrag fast nie.
self::assertSame(['bildkopf', 'klassisch', 'magazin'], array_keys(PostLayouts::all()));
foreach (PostLayouts::all() as $key => $layout) {
$document = PostLayouts::document($key, 'Ein Beitrag');
// Jeder Aufbau ist ein gültiges Dokument mit genau einer Hauptüberschrift.
(new DocumentValidator())->validate($document);
self::assertSame(18, $document['schemaVersion'], $key);
self::assertNotSame('', $layout['description'], $key);
$id = $this->pages->createPage('beitrag-'.$key, null, 'post', $document);
$page = $this->pages->findForEditor($id);
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
$html = $this->request('/beitrag-'.$key)->getContent();
self::assertStringContainsString('Ein Beitrag', $html, $key);
// Jeder Aufbau endet mit einem Weg weiter, keiner im Nichts.
self::assertStringContainsString('block-postnav', $html, $key);
}
// Ein erfundener Aufbau fällt auf die Voreinstellung zurück statt zu scheitern.
self::assertFalse(PostLayouts::exists('gibtsnicht'));
self::assertSame(
PostLayouts::document(PostLayouts::DEFAULT, 'X'),
PostLayouts::document('gibtsnicht', 'X'),
);
}
public function testTheFirstTextOfASectionIsNoLongerSilentlyTurnedIntoALabel(): void
{
// Diese Regel gestaltete nach Position statt nach Absicht: im Fußbereich wurde aus dem
// Firmennamen roter Versalientext, in einer Beitragsvorlage aus dem Einstiegsabsatz eine
// Kleinbeschriftung. Wer eine Beschriftung will, stellt sie ein.
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
self::assertStringNotContainsString('.section-inner>.block-text:first-child', $css);
self::assertStringNotContainsString('.block-column>.block-text:first-child', $css);
}
public function testAPostCanPointToItsNeighboursWithoutStoringThem(): void
{
// Die Beitragsseite endete bisher im Nichts: gelesen, und dann? Der neue Baustein führt
// zum nächsten Beitrag - innerhalb derselben Kategorie, denn von einem Projekt führt man
// zum nächsten Projekt und nicht zur nächsten Pressemitteilung.
foreach ([['eins', '2026-02-01'], ['zwei', '2026-02-02'], ['drei', '2026-02-03']] as [$slug, $datum]) {
$this->publishedPost('projekt-'.$slug, 'Projekt '.$slug, $datum, ['Projekte'], []);
}
$this->publishedPost('notiz', 'Eine Notiz', '2026-02-04', ['Notizen'], []);
$mitte = $this->db->fetchOne('SELECT id FROM page WHERE path = ?', ['projekt-zwei']);
$document = DemoContent::starter('Projekt zwei', 'post');
$document['schemaVersion'] = 18;
$document['blocks'][] = ['id' => 'weiter', 'type' => 'postnav', 'props' => ['previousLabel' => 'Vorheriges Projekt', 'nextLabel' => 'Nächstes Projekt']];
$this->pages->publish($mitte, $this->pages->saveDraft($mitte, $document, $this->pages->findForEditor($mitte)['version']), $this->pages->findForEditor($mitte)['version']);
$html = $this->request('/projekt-zwei')->getContent();
// Neuer steht im Archiv oben, also ist „vorheriges" der neuere Beitrag.
self::assertStringContainsString('Projekt drei', $html);
self::assertStringContainsString('Projekt eins', $html);
self::assertStringContainsString('Vorheriges Projekt', $html);
// Die Notiz gehört in eine andere Kategorie und taucht nicht auf.
self::assertStringNotContainsString('Eine Notiz', $html);
// Und im gespeicherten Dokument steht kein Beitrag - nur die Beschriftungen.
$stored = $this->pages->findPublished('projekt-zwei')['document'];
self::assertSame(['previousLabel' => 'Vorheriges Projekt', 'nextLabel' => 'Nächstes Projekt'], end($stored['blocks'])['props']);
// Am Rand wird nicht umgebrochen: der neueste Beitrag hat keinen Vorgänger.
$neuestes = $this->db->fetchOne('SELECT id FROM page WHERE path = ?', ['projekt-drei']);
$document['title'] = 'Projekt drei';
$this->pages->publish($neuestes, $this->pages->saveDraft($neuestes, $document, $this->pages->findForEditor($neuestes)['version']), $this->pages->findForEditor($neuestes)['version']);
$rand = $this->request('/projekt-drei')->getContent();
self::assertStringNotContainsString('Vorheriges Projekt', $rand);
self::assertStringContainsString('Nächstes Projekt', $rand);
}
public function testTheCloudflareCheckIsOffUntilItIsSetUp(): void
{
// Umut: „bei Formularen hätte ich gerne optional Cloudflare." Optional heißt: ohne
// Schlüssel steht nichts im Formular, und die Sicherheitsrichtlinie bleibt so eng wie
// vorher - eine Website ohne Spamwelle bindet keinen Dritten ein.
$turnstile = new Turnstile($this->site);
self::assertFalse($turnstile->isActive());
// Ohne eingerichtete Prüfung gilt jede Absendung als bestanden.
self::assertTrue($turnstile->passed(null, '127.0.0.1'));
// Eingeschaltet ohne Schlüssel wäre ein Formular, das jede Anfrage ablehnt.
try {
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'turnstile' => '1']);
self::fail('Ohne Schlüssel darf die Prüfung nicht angehen.');
} catch (InvalidPage) {
}
$this->site->save([
'country' => 'DE', 'homePath' => 'home', 'turnstile' => '1',
'turnstileSiteKey' => '0xAAAAAAAAAAAAAAAAAAAAAA', 'turnstileSecret' => '0xBBBBBBBBBBBBBBBBBBBBBB',
]);
self::assertTrue((new Turnstile($this->site))->isActive());
// Ein fehlender Nachweis wird abgelehnt, ohne dass jemand gefragt werden muss.
self::assertFalse((new Turnstile($this->site))->passed('', '127.0.0.1'));
(new FormRepository($this->db))->save('kontakt', 'Kontakt', [
['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true],
], ['recipient' => 'info@kant-it.de', 'subject' => 'Anfrage', 'replyToField' => 'email', 'success' => 'Danke.'], 0);
$document = DemoContent::starter('Kontakt');
$document['schemaVersion'] = 8;
$document['blocks'][] = ['id' => 'formblock', 'type' => 'form', 'props' => ['form' => 'kontakt']];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$antwort = $this->request('/');
$html = $antwort->getContent();
self::assertStringContainsString('data-sitekey="0xAAAAAAAAAAAAAAAAAAAAAA"', $html);
// Ohne Skript kann man es nicht absenden - das steht dann auch da.
self::assertStringContainsString('<noscript>', $html);
// Skript und Rahmen von Cloudflare sind erlaubt, aber nichts sonst.
$csp = (string) $antwort->headers->get('Content-Security-Policy');
self::assertStringContainsString("script-src 'self' https://challenges.cloudflare.com", $csp);
self::assertStringContainsString('frame-src https://challenges.cloudflare.com', $csp);
}
public function testAPictureCanBeEnlargedAndWorksWithoutScripting(): void
{
// Umut: „Bilder vergrößern Funktion, ein Popup-Viewer - gerne auch auswählbar wie bei
// WordPress." Auswählbar heißt: je Bild entscheidet sich, was ein Klick tut. Und ohne
// Skript bleibt es benutzbar: dann öffnet der Verweis schlicht die große Fassung.
$media = new MediaLibrary($this->db, $this->path.'-media');
$file = tempnam(sys_get_temp_dir(), 'kant-zoom-');
$image = imagecreatetruecolor(1600, 900);
imagepng($image, $file);
imagedestroy($image);
$item = $media->upload(new UploadedFile($file, 'motiv.png', test: true));
@unlink($file);
$quellen = array_map(static fn (array $v): array => ['src' => $v['src'], 'width' => $v['width']], $item['variants']);
$gross = end($quellen)['src'];
$bild = static fn (string $id, array $props): array => ['id' => $id, 'type' => 'image', 'props' => [
'src' => $quellen[0]['src'], 'alt' => 'Ein Motiv', 'decorative' => false,
'width' => 1600, 'height' => 900, 'sources' => $quellen, ...$props,
]];
$document = DemoContent::starter('Startseite');
$document['schemaVersion'] = 19;
$document['blocks'][] = ['id' => 'galerie', 'type' => 'gallery', 'props' => ['lightbox' => true], 'children' => [
$bild('g1', ['click' => 'vergroessern', 'caption' => 'Erstes Bild']),
$bild('g2', ['click' => 'vergroessern']),
]];
$document['blocks'][] = $bild('einzeln', ['click' => 'vergroessern']);
$document['blocks'][] = $bild('ruhig', []);
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$antwort = $this->request('/');
$html = $antwort->getContent();
// Der Verweis zeigt auf die größte Fassung - ohne Skript ist genau das die Vergrößerung.
self::assertStringContainsString('<a class="image-link image-zoom" href="'.$gross.'" data-zoom', $html);
self::assertStringContainsString('data-caption="Erstes Bild"', $html);
// Die Galerie ist als Gruppe gekennzeichnet, damit sich darin blättern lässt.
self::assertStringContainsString('data-zoom-group', $html);
// Ein Bild ohne Klickwirkung bleibt, was es war.
self::assertMatchesRegularExpression('#<figure id="ruhig".{0,200}<img#s', $html);
// Drei Bilder mit Klickwirkung, das vierte bleibt unberührt.
self::assertSame(3, substr_count($html, 'image-zoom'));
// Das Skript kommt nur auf Seiten mit Betrachter, und die Richtlinie erlaubt es dort.
self::assertStringContainsString('<script src="/assets/lightbox.js?v=', $html);
self::assertStringContainsString("script-src 'self'", (string) $antwort->headers->get('Content-Security-Policy'));
// Eine Seite ohne vergrößerbares Bild lädt es nicht und bleibt ohne Skript.
$ohne = DemoContent::starter('Startseite');
$ohne['schemaVersion'] = 19;
$ohne['blocks'][] = $bild('ruhig', []);
$version = $this->pages->findForEditor('page-1')['version'];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $ohne, $version), $version + 1);
$zweite = $this->request('/');
self::assertStringNotContainsString('lightbox.js', $zweite->getContent());
self::assertStringContainsString("script-src 'none'", (string) $zweite->headers->get('Content-Security-Policy'));
}
public function testLegalLinksAppearOnlyWhereAnAuthorPlacesThem(): void
{
// Die hinterlegten Adressen erzeugen keine zusätzliche Leiste unter dem Footer.
$datenschutz = $this->pages->createPage('datenschutz', null, 'page', DemoContent::starter('Datenschutz'));
$seite = $this->pages->findForEditor($datenschutz);
$this->pages->publish($datenschutz, $seite['draft_revision_id'], $seite['version']);
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'privacyPath' => 'datenschutz', 'imprintPath' => 'home']);
$document = DemoContent::starter('Startseite');
$document['schemaVersion'] = 19;
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
// Ohne selbst platzierte Links bleibt die Seite ohne Rechtszeile.
$html = $this->request('/')->getContent();
self::assertStringNotContainsString('<nav class="legal-bar"', $html);
self::assertStringNotContainsString('<a href="/datenschutz">', $html);
// Mit Baustein: nur noch dieser, und zwar an seiner Stelle im Dokument.
$document['blocks'][] = ['id' => 'rechtszeile', 'type' => 'legal', 'props' => []];
$version = $this->pages->findForEditor('page-1')['version'];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, $version), $version + 1);
$mit = $this->request('/')->getContent();
self::assertStringContainsString('<nav id="rechtszeile" class="block-legal"', $mit);
self::assertStringNotContainsString('<nav class="legal-bar"', $mit);
self::assertStringContainsString('Datenschutz', $mit);
}
public function testTheAdministrationCanLiveSomewhereElseAndIsNotAdvertised(): void
{
// Umut: „Erstelle mal Möglichkeit, Backend anzupassen. Also URL, Admin auf irgendwas."
// /admin ist die erste Adresse, die ein Scanner probiert. Wer sie umbenennt, nimmt der
// Maschinerie das Ziel - aber nur, wenn der neue Name nirgends öffentlich steht.
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::starter('Startseite'), 0), 1);
// Beim Standardnamen bleibt die Zeile in der robots.txt: diese Adresse kennt ohnehin jeder.
self::assertStringContainsString('Disallow: /admin', $this->request('/robots.txt')->getContent());
// Bei geändertem Namen darf er dort nicht auftauchen.
$vorher = $_SERVER['ADMIN_PATH'] ?? null;
$_SERVER['ADMIN_PATH'] = $_ENV['ADMIN_PATH'] = 'stiller-eingang';
try {
$robots = $this->request('/robots.txt')->getContent();
self::assertStringNotContainsString('stiller-eingang', $robots, 'robots.txt ist öffentlich - der Name gehört dort nicht hinein.');
self::assertStringNotContainsString('Disallow: /admin', $robots);
self::assertStringContainsString('Disallow: /formular/', $robots, 'Der Rest der Datei bleibt, wie er war.');
} finally {
if (null === $vorher) {
unset($_SERVER['ADMIN_PATH'], $_ENV['ADMIN_PATH']);
} else {
$_SERVER['ADMIN_PATH'] = $_ENV['ADMIN_PATH'] = $vorher;
}
}
// Und keine Route trägt die Adresse fest im Text: sonst zöge eine Umbenennung die Hälfte
// der Verwaltung nicht mit.
foreach (glob(__DIR__.'/../src/Controller/*.php') ?: [] as $datei) {
$inhalt = (string) file_get_contents($datei);
self::assertDoesNotMatchRegularExpression(
"#\#\[Route\('/admin#",
$inhalt,
basename($datei).' schreibt /admin fest, statt den eingestellten Pfad zu nehmen.',
);
}
}
public function testTheReleaseArchiveCarriesEveryFileTheApplicationNeeds(): void
{
// Auf dem Hosting gefunden, nicht hier: „The configuration file does not exist" beim
// Anlegen der Tabellen. Im Releasearchiv fehlte migrations.php - die Datei, die Doctrine
// sagt, wo die Migrationen liegen. Sie stand weder in der Mitnahmeliste noch in der
// Ausschlussliste, also fiel niemandem auf, dass sie fehlt.
$skript = (string) file_get_contents(__DIR__.'/../scripts/build-release.php');
preg_match('/\$include = \[(.+?)\];/s', $skript, $mit);
preg_match('/\$skip = \[(.+?)\];/s', $skript, $ohne);
self::assertNotEmpty($mit, 'Die Mitnahmeliste des Releaseskripts ist nicht auffindbar.');
$liste = static fn (string $roh): array => array_map(
static fn (string $eintrag): string => trim($eintrag, " '
"),
explode(',', preg_replace('#//[^
]*#', '', $roh) ?? ''),
);
$eingeordnet = [...$liste($mit[1]), ...$liste($ohne[1] ?? ''), '.env', '.env.local', 'LICENSE'];
foreach (scandir(__DIR__.'/..') ?: [] as $eintrag) {
if (in_array($eintrag, ['.', '..'], true) || str_starts_with($eintrag, '.git')) {
continue;
}
self::assertContains(
$eintrag,
$eingeordnet,
sprintf('„%s" steht im Projektstamm, aber weder in der Mitnahme- noch in der Ausschlussliste von build-release.php. Ein Release ohne diese Datei fällt erst auf dem Hosting auf.', $eintrag),
);
}
}
public function testTheAdministrationDrawsItsOwnIconsInsteadOfBorrowingCharacters(): void
{
// Umut: „Machst du auch gleich eigene Icons statt die Web-Dinger zu nutzen – auf dem
// Smartphone kommen sonst Emojis.“ Genau so ist es: ein Telefon ersetzt ein Zeichen wie
// das Zahnrad durch ein buntes Emoji in fremdem Stil, und die Navigation sieht aus wie
// zusammengewürfelt. Eigene Pfade sind eine Linienstärke, eine Farbe und kein Request.
$shell = (string) file_get_contents(__DIR__.'/../templates/admin/shell.html.twig');
self::assertDoesNotMatchRegularExpression(
'/[\\x{2190}-\\x{2BFF}\\x{1F300}-\\x{1FAFF}]/u',
$shell,
'In der Navigation steht ein Zeichen, das ein Telefon zum Emoji macht.',
);
self::assertStringContainsString('admin/icon.html.twig', $shell);
$icons = (string) file_get_contents(__DIR__.'/../templates/admin/icon.html.twig');
// Jedes verwendete Symbol muss es geben - sonst zeichnet die Vorlage stillschweigend das
// Ersatzsymbol, und zwei Einträge sehen gleich aus.
foreach (['pages', 'partials', 'menus', 'media', 'forms', 'statistics', 'theme', 'settings', 'system', 'users', 'external'] as $name) {
self::assertStringContainsString($name.": 'M", $icons, $name.' fehlt in der Symbolsammlung.');
}
preg_match_all("/'(M[^']+)'/", $icons, $pfade);
self::assertSame(count($pfade[1]), count(array_unique($pfade[1])), 'Zwei Symbole tragen denselben Pfad.');
}
public function testNoStylesheetHasADanglingSelector(): void
{
// Gefunden, als der Einwilligungsdialog plötzlich ungestaltet unter dem Fuß stand: beim
// Entfernen alter Regeln war der Selektorrest stehen geblieben - „.site-header" allein in
// einer Zeile. CSS liest dann weiter, hängt den nächsten Selektor an und verschluckt
// dessen Regel. Vier Veröffentlichungen lang hat das niemand bemerkt; ein Stylesheet
// beschwert sich nicht, es wirkt nur nicht mehr.
foreach (glob(__DIR__.'/../public/assets/*.css') ?: [] as $datei) {
$ohneKommentare = preg_replace('#/\*.*?\*/#s', '', (string) file_get_contents($datei)) ?? '';
foreach (preg_split('/\R/', $ohneKommentare) ?: [] as $nummer => $zeile) {
$zeile = rtrim($zeile);
if ('' === trim($zeile)) {
continue;
}
// Eine Zeile, die wie ein Selektor beginnt, muss auch wie einer enden: mit „{",
// mit einem Komma (weitere Selektoren folgen) oder mit einer Deklaration.
$beginntWieSelektor = 1 === preg_match('/^[.#a-zA-Z\[:*]/', trim($zeile));
$endetSauber = 1 === preg_match('/[{},;]$/', $zeile) || str_contains($zeile, '{');
self::assertTrue(
!$beginntWieSelektor || $endetSauber,
sprintf('%s Zeile %d endet mitten im Selektor: „%s“', basename($datei), $nummer + 1, trim($zeile)),
);
}
}
}
public function testEveryPublicAnswerCarriesItsProtectiveHeaders(): void
{
// Diese Kopfzeilen fehlen lautlos, wenn jemand eine Antwort an der Stelle vorbeibaut, die
// sie setzt. Ein Test ist billiger als die Entdeckung im Betrieb.
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::starter('Startseite'), 0), 1);
foreach (['/', '/gibt-es-nicht'] as $adresse) {
$antwort = $this->request($adresse);
$csp = (string) $antwort->headers->get('Content-Security-Policy');
self::assertStringContainsString("default-src 'self'", $csp, $adresse);
self::assertStringContainsString("object-src 'none'", $csp, $adresse);
// Ein eingeschleustes <base> würde jeden Verweis der Seite umlenken.
self::assertStringContainsString("base-uri 'none'", $csp, $adresse);
self::assertStringContainsString("frame-ancestors 'none'", $csp, $adresse);
self::assertSame('nosniff', $antwort->headers->get('X-Content-Type-Options'), $adresse);
self::assertStringContainsString('camera=()', (string) $antwort->headers->get('Permissions-Policy'), $adresse);
}
// Und der Vordereingang verrät nicht, welche PHP-Fassung hier läuft.
$index = (string) file_get_contents(__DIR__.'/../public/index.php');
self::assertStringContainsString("header_remove('X-Powered-By')", $index);
self::assertStringContainsString('Header always unset X-Powered-By', (string) file_get_contents(__DIR__.'/../public/.htaccess'));
}
public function testABackgroundPictureShrinksForSmallScreensEvenWhenSetOnce(): void
{
// Gemessen auf der nachgebauten Startseite: das Telefon lud das Hintergrundbild mit 1920
// Pixeln und 189 KB, weil die Regel nur unter „Desktop" stand und ein kleines Gerät sie
// erbt. Sichtbar war davon nichts - bezahlt wurde es trotzdem.
$media = new MediaLibrary($this->db, $this->path.'-media');
$file = tempnam(sys_get_temp_dir(), 'kant-hintergrund-');
$image = imagecreatetruecolor(2000, 1200);
imagepng($image, $file);
imagedestroy($image);
$item = $media->upload(new UploadedFile($file, 'hintergrund.png', test: true));
@unlink($file);
$document = DemoContent::starter('Startseite');
$document['schemaVersion'] = 18;
$document['blocks'][0]['style'] = ['desktop' => [
'backgroundImage' => $item['id'],
'backgroundSize' => 'cover',
'overlayColor' => '#0a192f',
'overlayOpacity' => 0.6,
]];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
$id = $document['blocks'][0]['id'];
preg_match('/@media \(max-width:767px\)\{#'.$id.'\{([^}]*)}/', $html, $mobil);
preg_match('/#'.$id.'\{([^}]*)}/', $html, $desktop);
self::assertNotEmpty($mobil, 'Für kleine Geräte muss eine eigene Regel entstehen.');
self::assertStringContainsString('/960', $mobil[1], 'Das Telefon bekommt die kleine Variante.');
self::assertStringContainsString('/2000', $desktop[1], 'Der Schreibtisch die große.');
// Die Abdunkelung gehört zur selben Regel - sonst stünde heller Text plötzlich auf
// einem ungedämpften Bild.
self::assertStringContainsString('#0a192f 60%', $mobil[1]);
self::assertStringContainsString('background-size:cover', $mobil[1]);
// Eine eigene Regel für kleine Geräte wird nie überschrieben.
$document['blocks'][0]['style']['mobile'] = ['backgroundColor' => '#ffffff'];
$version = $this->pages->findForEditor('page-1')['version'];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, $version), $version + 1);
preg_match('/@media \(max-width:767px\)\{#'.$id.'\{([^}]*)}/', $this->request('/')->getContent(), $eigen);
self::assertStringContainsString('background-color:#ffffff', $eigen[1]);
self::assertStringContainsString('/960', $eigen[1], 'Das Bild fehlt dort nicht, nur weil eine andere Eigenschaft gesetzt ist.');
}
public function testTheBigPictureLoadsFirstAndSmallOnesAskForSmallVariants(): void
{
// Zwei Fehler auf einmal: die Eile lag auf dem Logo im Kopfbereich statt auf dem großen
// Bild darunter, und jedes Bild fragte nach einer Variante für 800 px Breite - auch ein
// Logo, das 190 px breit ist. Das waren 32 KB für nichts.
$media = new MediaLibrary($this->db, $this->path.'-media');
$file = tempnam(sys_get_temp_dir(), 'kant-bild-');
$image = imagecreatetruecolor(1200, 800);
imagepng($image, $file);
imagedestroy($image);
$item = $media->upload(new UploadedFile($file, 'bild.png', test: true));
@unlink($file);
$variants = array_column($item['variants'], 'src', 'width');
$bild = static fn (string $id, array $style): array => [
'id' => $id,
'type' => 'image',
'props' => ['src' => $variants[960], 'alt' => 'Ein Bild', 'decorative' => false, 'width' => 960, 'height' => 640, 'sources' => [['src' => $variants[480], 'width' => 480], ['src' => $variants[960], 'width' => 960]]],
'style' => $style,
];
$document = DemoContent::starter('Startseite');
$document['schemaVersion'] = 18;
$document['blocks'] = [
['id' => 'oben', 'type' => 'section', 'props' => [], 'children' => [
['id' => 'titel', 'type' => 'heading', 'props' => ['text' => 'Startseite', 'level' => 1]],
$bild('gross', []),
$bild('logo', ['desktop' => ['maxWidth' => '190px']]),
]],
['id' => 'unten', 'type' => 'section', 'props' => [], 'children' => [$bild('weiter-unten', [])]],
];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
// Genau ein Bild wird vorgezogen, und zwar das erste im ersten Abschnitt.
self::assertSame(1, substr_count($html, 'fetchpriority="high"'));
self::assertMatchesRegularExpression('#<figure id="gross".{0,400}fetchpriority="high"#s', $html);
self::assertMatchesRegularExpression('#<figure id="weiter-unten".{0,400}loading="lazy"#s', $html);
// Ein Block mit fester Breite fragt nach genau dieser Breite.
self::assertMatchesRegularExpression('#<figure id="logo".{0,500}sizes="190px"#s', $html);
self::assertMatchesRegularExpression('#<figure id="gross".{0,500}sizes="\(max-width: 650px\) 100vw#s', $html);
}
public function testFontsAreReadFromGoogleStylesheetAndServedFromHere(): void
{
// Eine Schrift von Google kostet zwei fremde Verbindungen hintereinander und schickt die
// IP jedes Besuchers nach Kalifornien. Der Parser holt aus dem Stylesheet genau das, was
// eine deutschsprachige Website braucht - Kyrillisch bleibt liegen.
$fonts = new FontLibrary($this->path.'-fonts');
$css = <<<'CSS'
/* cyrillic */
@font-face{font-family:'Lato';font-style:normal;font-weight:400;src:url(https://fonts.gstatic.com/s/lato/kyrillisch.woff2) format('woff2');unicode-range:U+0301, U+0400-045F}
/* latin */
@font-face{font-family:'Lato';font-style:normal;font-weight:400;src:url(https://fonts.gstatic.com/s/lato/latein.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131}
/* latin */
@font-face{font-family:'Lato';font-style:normal;font-weight:700;src:url(https://fonts.gstatic.com/s/lato/latein-fett.woff2) format('woff2');unicode-range:U+0000-00FF}
CSS;
$faces = $fonts->parse($css, 'Lato');
self::assertCount(2, $faces, 'Kyrillisch gehört nicht dazu.');
self::assertSame([400, 700], array_column($faces, 'weight'));
self::assertSame('https://fonts.gstatic.com/s/lato/latein.woff2', $faces[0]['url']);
self::assertStringContainsString('U+0131', $faces[0]['range']);
// Ohne heruntergeladene Dateien bleibt es beim Verweis auf Google - und das Theme weiß es.
$theme = new Theme($this->db);
$gespeichert = $theme->load();
$theme->save($gespeichert['colors'], [
['key' => 'body', 'name' => 'Fließtext', 'family' => 'Lato', 'source' => 'google', 'weights' => [400, 700], 'fallback' => 'system-ui'],
['key' => 'heading', 'name' => 'Überschriften', 'family' => 'Lato', 'source' => 'google', 'weights' => [700], 'fallback' => 'system-ui'],
], $gespeichert['base']);
self::assertFalse($fonts->hasLocal($theme->load()));
self::assertSame(['Lato 400', 'Lato 700'], $fonts->missing($theme->load()));
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::starter('Startseite'), 0), 1);
self::assertStringContainsString('fonts.googleapis.com', $this->request('/')->getContent());
// Mit Dateien: kein Wort mehr von Google, dafür die eigenen Dateien und ein Vorabholen.
mkdir($this->path.'-fonts', 0o775, true);
foreach (['lato-400-latin.woff2', 'lato-700-latin.woff2'] as $datei) {
file_put_contents($this->path.'-fonts/'.$datei, 'wOF2test');
}
file_put_contents($this->path.'-fonts/fonts.css', "@font-face{font-family:'Lato'}");
self::assertTrue($fonts->hasLocal($theme->load()));
self::assertSame(['/assets/fonts/lato-400-latin.woff2', '/assets/fonts/lato-700-latin.woff2'], $fonts->preload($theme->load()));
}
public function testTheStylesheetsGoOutAsOneFileWithoutComments(): void
{
// Vier Stylesheets sind vier Anfragen, und jede einzelne hält das Zeichnen der Seite auf.
$assets = $this->path.'-assets';
mkdir($assets, 0o775, true);
file_put_contents($assets.'/site.css', '/* Das Grundgerüst. */
.kant {
color: red;
}
');
file_put_contents($assets.'/design.css', '/* Gestaltung. */
.block-row { gap: 10px }
');
$bundle = new StyleBundle($assets);
$url = $bundle->url(false);
self::assertNotNull($url);
self::assertStringStartsWith('/assets/build/kant-', $url);
$inhalt = (string) file_get_contents($assets.'/build/'.basename((string) $url));
self::assertStringNotContainsString('Das Grundgerüst', $inhalt, 'Kommentare erklären den Quelltext, nicht den Browser.');
self::assertStringContainsString('color: red;', $inhalt, 'Leerzeichen innerhalb einer Zeile bleiben - in calc() tragen sie Bedeutung.');
self::assertStringContainsString('.block-row { gap: 10px }', $inhalt);
// Derselbe Inhalt ergibt denselben Namen; eine Änderung einen neuen.
self::assertSame($url, (new StyleBundle($assets))->url(false));
touch($assets.'/site.css', time() + 10);
clearstatcache();
self::assertNotSame($url, (new StyleBundle($assets))->url(false));
}
public function testTheTitleInSearchResultsCanDifferFromTheTitleOnThePage(): void
{
// Auf der Seite steht "Leistungen", im Suchergebnis hilft der lange Titel mit Ort und
// Firma. Bisher war beides dasselbe Feld, also musste man sich entscheiden.
$document = DemoContent::starter('Leistungen');
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('<title>Leistungen</title>', $html);
$this->posts->saveMetadata('page-1', ['seoTitle' => 'IT-Leistungen für Mittelbaden – Kant-IT Solutions'], [], [], (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['page-1']));
$html = $this->request('/')->getContent();
self::assertStringContainsString('<title>IT-Leistungen für Mittelbaden – Kant-IT Solutions</title>', $html);
self::assertStringContainsString('<meta property="og:title" content="IT-Leistungen für Mittelbaden – Kant-IT Solutions">', $html);
// Die Überschrift auf der Seite bleibt, was sie war.
self::assertStringContainsString('>Leistungen</h1>', $html);
// Wieder geleert heißt: der Titel der Seite gilt.
$this->posts->saveMetadata('page-1', ['seoTitle' => ''], [], [], (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['page-1']));
self::assertStringContainsString('<title>Leistungen</title>', $this->request('/')->getContent());
try {
$this->posts->saveMetadata('page-1', ['seoTitle' => str_repeat('a', 161)], [], [], (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['page-1']));
self::fail('Ein Titel über 160 Zeichen muss abgelehnt werden.');
} catch (InvalidPage) {
}
}
public function testThePreviewPictureForSharingActuallyReachesTheHead(): void
{
// Das Feld „Vorschaubild beim Teilen" gab es seit dem SEO-Paket - ausgegeben wurde es
// nie. Eine Einstellung, die speichert und nichts tut, ist schlimmer als keine.
$media = new MediaLibrary($this->db, $this->path.'-media');
$file = tempnam(sys_get_temp_dir(), 'kant-teilen-');
$image = imagecreatetruecolor(1800, 1000);
imagepng($image, $file);
imagedestroy($image);
$item = $media->upload(new UploadedFile($file, 'vorschau.png', test: true));
@unlink($file);
$media->update($item['id'], ['title' => 'Vorschau', 'alt' => 'Ein Blick auf die Website', 'archived' => false, 'version' => 1]);
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::starter('Startseite'), 0), 1);
// Ohne Bild: die kleine Karte, kein Verweis auf ein Bild, das es nicht gibt.
$ohne = $this->request('/')->getContent();
self::assertStringNotContainsString('og:image', $ohne);
self::assertStringContainsString('<meta name="twitter:card" content="summary">', $ohne);
self::assertStringContainsString('<meta property="og:locale" content="de_DE">', $ohne);
$this->db->update('page', ['og_media_id' => $item['id']], ['id' => 'page-1']);
$html = $this->request('/')->getContent();
// Absolut, weil die Adresse in fremden Diensten landet, und höchstens 1600 px breit.
self::assertStringContainsString('<meta property="og:image" content="https://cms.test/media/'.$item['id'].'/1600">', $html);
self::assertStringContainsString('<meta property="og:image:alt" content="Ein Blick auf die Website">', $html);
self::assertStringContainsString('<meta name="twitter:card" content="summary_large_image">', $html);
// Ein Beitrag ohne eigenes Vorschaubild nimmt sein Beitragsbild.
$id = $this->publishedPost('mit-bild', 'Mit Bild', '2026-02-01', ['Projekte'], []);
$this->posts->saveMetadata($id, ['featuredMediaId' => $item['id'], 'publishedAt' => '2026-02-01'], ['Projekte'], [], $this->pages->findForEditor($id)['version']);
$page = $this->pages->findForEditor($id);
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
$beitrag = $this->request('/mit-bild')->getContent();
self::assertStringContainsString('<meta property="og:image" content="https://cms.test/media/'.$item['id'].'/1600">', $beitrag);
// Und dasselbe Bild steht im strukturierten Datensatz des Beitrags.
self::assertStringContainsString('"image":"https://cms.test/media/'.$item['id'].'/1600"', $beitrag);
}
public function testTheSiteIconIsMadeFromAPictureOfTheLibrary(): void
{
// Umut: „dann brauchen wir Favicon-Möglichkeit zum Festlegen“. Aus einem Bild der
// Mediathek entstehen beim Speichern zwei quadratische PNG-Dateien; ohne Einstellung
// bleibt es beim mitgelieferten Zeichen.
self::assertStringContainsString('<link rel="icon" href="/favicon.svg"', $this->request('/')->getContent());
self::assertSame(404, $this->request('/favicon.png')->getStatusCode());
$media = new MediaLibrary($this->db, $this->path.'-media');
$file = tempnam(sys_get_temp_dir(), 'kant-symbol-');
// Breit und flach: der Ausschnitt muss quadratisch werden, nicht gestaucht.
$image = imagecreatetruecolor(600, 200);
imagepng($image, $file);
imagedestroy($image);
$item = $media->upload(new UploadedFile($file, 'logo.png', test: true));
@unlink($file);
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'faviconMediaId' => $item['id']]);
self::assertSame($item['id'], $this->site->load()['faviconMediaId']);
$html = $this->request('/')->getContent();
self::assertStringContainsString('<link rel="icon" href="/favicon.png?v='.substr($item['id'], 0, 8).'" type="image/png" sizes="32x32">', $html);
self::assertStringContainsString('apple-touch-icon.png', $html);
self::assertStringNotContainsString('favicon.svg', $html);
$symbol = $this->request('/favicon.png');
self::assertSame(200, $symbol->getStatusCode());
self::assertSame('image/png', $symbol->headers->get('Content-Type'));
$size = getimagesizefromstring((string) file_get_contents($this->path.'-media/favicon-32.png'));
self::assertSame([32, 32], [$size[0], $size[1]], 'Quadratisch, nicht gestaucht.');
self::assertSame(200, $this->request('/apple-touch-icon.png')->getStatusCode());
// Ein winziges quadratisches Zeichen hat keine einzige der vier Regelbreiten - genau so
// sieht ein echtes Favicon aus, und genau daran ist die erste Fassung gescheitert.
$klein = tempnam(sys_get_temp_dir(), 'kant-zeichen-');
$zeichen = imagecreatetruecolor(192, 192);
imagepng($zeichen, $klein);
imagedestroy($zeichen);
$kleines = $media->upload(new UploadedFile($klein, 'zeichen.png', test: true));
@unlink($klein);
self::assertSame([192], array_column($kleines['variants'], 'width'));
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'faviconMediaId' => $kleines['id']]);
self::assertSame(200, $this->request('/favicon.png')->getStatusCode());
// Ein Bild, das es nicht gibt, wird nicht gespeichert - sonst zeigte der Kopf ins Leere.
try {
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'faviconMediaId' => str_repeat('a', 32)]);
self::fail('Eine unbekannte Bildkennung muss abgelehnt werden.');
} catch (InvalidPage) {
}
self::assertSame($kleines['id'], $this->site->load()['faviconMediaId']);
}
public function testUnderlinedLinksAreAChoiceAndOnlyApplyToRunningText(): void
{
// Umut: „links haben alle underlined“. Im Menü, auf Karten und über Bildern hat ein
// Unterstrich nichts zu suchen; im Fließtext ist er die einzige Kennzeichnung neben der
// Farbe - deshalb dort ein Schalter statt einer stillen Entscheidung.
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
self::assertStringContainsString('.post-image{display:block;position:relative;overflow:hidden;text-decoration:none}', $css);
self::assertStringNotContainsString('[aria-current=page]{text-decoration:underline}', $css);
$theme = new Theme($this->db);
self::assertTrue($theme->load()['base']['linkUnderline'], 'Standard: unterstrichen.');
self::assertStringContainsString('.kant .block-text a,.kant .block-list a,.kant .post-excerpt a{color:var(--k-link);text-decoration:underline}', $theme->css($theme->load()));
$gespeichert = $theme->load();
$theme->save($gespeichert['colors'], $gespeichert['fonts'], [...$gespeichert['base'], 'linkUnderline' => null]);
self::assertFalse($theme->load()['base']['linkUnderline']);
$css = $theme->css($theme->load());
self::assertStringContainsString('text-decoration:none}', $css);
// Beim Überfahren bleibt der Strich in jedem Fall - sonst ist ein Link im Text nicht zu finden.
self::assertStringContainsString('.kant .block-text a:hover,.kant .block-list a:hover,.kant .post-excerpt a:hover{text-decoration:underline}', $css);
}
public function testAWebsiteWithoutPostsNeverMentionsPosts(): void
{
// Umut: „Können wir Beiträge optional machen? Wenn es keine gibt, überall ausblenden.“
// Teilweise war es das schon - die Sitemap ließ das Archiv weg. Das Archiv selbst, der
// Feed, der Verweis im Kopf und der Kasten „hier erscheinen Beiträge“ blieben.
$document = DemoContent::starter('Ohne Blog');
$document['schemaVersion'] = 18;
$document['blocks'][] = ['id' => 'liste', 'type' => 'postlist', 'props' => ['count' => 4, 'category' => '', 'tag' => '']];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
// Ohne einen einzigen Beitrag: kein Archiv, kein Feed.
self::assertSame(404, $this->request('/beitraege')->getStatusCode());
self::assertSame(404, $this->request('/feed.xml')->getStatusCode());
$html = $this->request('/')->getContent();
self::assertStringNotContainsString('application/rss+xml', $html, 'Kein Verweis auf einen Feed, den es nicht gibt.');
self::assertStringNotContainsString('post-empty', $html, 'Kein Kasten, der Beiträge ankündigt.');
self::assertStringNotContainsString('Hier erscheinen die Beiträge', $html);
// Die Sitemap führt das Archiv ebenfalls nicht.
self::assertStringNotContainsString('/beitraege', $this->request('/sitemap.xml')->getContent());
// Sobald ein Beitrag veröffentlicht ist, ist alles davon wieder da.
$this->publishedPost('erster', 'Erster Beitrag', '2026-02-01', ['Projekte'], []);
self::assertSame(200, $this->request('/beitraege')->getStatusCode());
self::assertSame(200, $this->request('/feed.xml')->getStatusCode());
$mit = $this->request('/')->getContent();
self::assertStringContainsString('application/rss+xml', $mit);
self::assertStringContainsString('Erster Beitrag', $mit);
self::assertStringContainsString('/beitraege', $this->request('/sitemap.xml')->getContent());
// Im Editor bleibt der Hinweis stehen, sonst sucht jemand den Fehler an der leeren Stelle.
$template = (string) file_get_contents(__DIR__.'/../templates/blocks/post-list.html.twig');
self::assertStringContainsString('{% if editing|default(false) %}<p class="post-empty">', $template);
}
public function testAPostListCanPageThroughEverythingInsteadOfCuttingOff(): void
{
// Umut: „fehlt die Pagination bei Projekte? Es werden nur 12 angezeigt.“ Richtig: eine
// Auswahl zeigt die ersten n, eine Übersicht muss blättern - sonst verschweigt sie, dass
// es mehr gibt. Das ist die eigene Regel aus Arbeitspaket M, die für die Beitragsliste
// eines Dokuments bis hierher nicht galt.
for ($i = 1; $i <= 7; ++$i) {
$this->publishedPost('projekt-'.$i, 'Projekt '.$i, sprintf('2026-02-%02d', $i), ['Projekte'], []);
}
$document = DemoContent::starter('Übersicht');
$document['schemaVersion'] = 17;
$document['blocks'][] = ['id' => 'liste', 'type' => 'postlist', 'props' => [
'count' => 3, 'category' => 'projekte', 'tag' => '', 'paginate' => true,
]];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$erste = $this->request('/')->getContent();
self::assertSame(3, substr_count($erste, 'post-card'));
self::assertStringContainsString('Seite 1 von 3', $erste);
self::assertStringContainsString('7 Beiträge', $erste);
self::assertStringNotContainsString('Neuere', $erste, 'Auf der ersten Seite gibt es nichts Neueres.');
$dritte = $this->request('/?seite=3')->getContent();
self::assertStringContainsString('Seite 3 von 3', $dritte);
self::assertSame(1, substr_count($dritte, 'post-card'));
self::assertStringNotContainsString('Ältere', $dritte);
// Eine Seitenzahl jenseits des Endes landet auf der letzten Seite statt im Leeren.
self::assertStringContainsString('Seite 3 von 3', $this->request('/?seite=99')->getContent());
// Ohne den Schalter bleibt es eine Auswahl: drei Karten, keine Leiste.
$document['blocks'][array_key_last($document['blocks'])]['props']['paginate'] = false;
$revision = $this->pages->saveDraft('page-1', $document, $this->pages->findForEditor('page-1')['version']);
$this->pages->publish('page-1', $revision, $this->pages->findForEditor('page-1')['version']);
$auswahl = $this->request('/?seite=2')->getContent();
self::assertSame(3, substr_count($auswahl, 'post-card'));
self::assertStringNotContainsString('Seite 1 von', $auswahl);
}
public function testAPostCardReactsToThePointerAndToTheKeyboard(): void
{
// Umut: „es gibt keinen Mausover-Effekt, das fehlt“. Eine Karte, die sich beim Zeigen
// nicht rührt, wirkt tot. Der Hinweis liegt über dem Bild, ist für Vorlesesoftware aber
// nichts Neues - der Titel darunter führt zum selben Ziel.
$this->publishedPost('projekt-eins', 'Projekt eins', '2026-02-01', ['Projekte'], []);
// Der Hinweis liegt über dem Beitragsbild, also braucht der Beitrag eines.
$media = new MediaLibrary($this->db, $this->path.'-media');
$file = tempnam(sys_get_temp_dir(), 'kant-karte-');
$image = imagecreatetruecolor(600, 400);
imagepng($image, $file);
imagedestroy($image);
$item = $media->upload(new UploadedFile($file, 'karte.png', test: true));
@unlink($file);
$id = $this->db->fetchOne('SELECT id FROM page WHERE path = ?', ['projekt-eins']);
$this->posts->saveMetadata($id, ['featuredMediaId' => $item['id'], 'publishedAt' => '2026-02-01'], ['Projekte'], [], (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', [$id]));
$document = DemoContent::starter('Startseite');
$document['schemaVersion'] = 17;
$document['blocks'][] = ['id' => 'raster', 'type' => 'postlist', 'props' => ['count' => 5, 'category' => 'projekte', 'tag' => '']];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('<span class="post-view" aria-hidden="true">Ansehen</span>', $html);
// Die Bewegung spielt für alle; beachtet die Website die Browservorgabe, nimmt ein
// Überschreibblock hinter der Bewegungsschranke nur die Fahrt weg, der Hinweis bleibt.
$effects = (string) file_get_contents(__DIR__.'/../public/assets/enhancements.css');
self::assertStringContainsString('.post-card:hover, .post-card:focus-within {', $effects);
self::assertStringContainsString('transform: scale(1.06)', $effects);
$schranke = explode('@media (prefers-reduced-motion: reduce) {', $effects);
self::assertCount(2, $schranke, 'Genau ein Überschreibblock für „Bewegung reduzieren“.');
self::assertStringContainsString('html.motion-respect', $schranke[1]);
self::assertStringContainsString('transform: none', $schranke[1]);
self::assertStringNotContainsString('html.motion-respect', $schranke[0], 'Ohne Schranke gilt die Klasse nicht.');
// Und über die Tastatur erreichbar: der Zustand hängt auch an :focus-within.
$site = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
self::assertStringContainsString('.post-card:hover .post-view,.post-card:focus-within .post-view{opacity:1}', $site);
}
public function testAPostListCanLookLikeAProjectGridInsteadOfABlogArchive(): void
{
// Beim Nachbau von kant-it.de fehlte genau das: das Projektraster dort zeigt Bild und
// Titel, die Beitragsliste zeigte zusätzlich Datum, Autor, Auszug und Kategorie und sah
// damit aus wie ein Blog. Was eine Karte zeigt, ist jetzt einstellbar.
$this->publishedPost('projekt-eins', 'Projekt eins', '2026-02-01', ['Projekte'], []);
$id = $this->db->fetchOne('SELECT id FROM page WHERE path = ?', ['projekt-eins']);
$this->posts->saveMetadata($id, ['excerpt' => 'Ein Auszug, der im Raster stört.', 'author' => 'Umut', 'publishedAt' => '2026-02-01'], ['Projekte'], [], (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', [$id]));
$document = DemoContent::starter('Startseite');
$document['schemaVersion'] = 17;
$document['blocks'][] = ['id' => 'raster', 'type' => 'postlist', 'props' => [
'count' => 5, 'category' => 'projekte', 'tag' => '',
'showMeta' => false, 'showExcerpt' => false, 'showTerms' => false,
], 'children' => []];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('Projekt eins', $html, 'Der Titel bleibt.');
self::assertStringNotContainsString('Ein Auszug, der im Raster stört.', $html);
self::assertStringNotContainsString('von Umut', $html);
self::assertStringNotContainsString('post-terms', $html);
// Und die Gegenprobe: ohne die Schalter bleibt alles wie vorher. Das ist der Fall, der
// fast danebengegangen wäre - „false|default(true)" ist in Twig true, weil der Filter auch
// auf leere Werte greift. Die Einstellung hätte sich speichern lassen und nichts getan.
$document['blocks'][array_key_last($document['blocks'])]['props'] = ['count' => 5, 'category' => 'projekte', 'tag' => ''];
$revision = $this->pages->saveDraft('page-1', $document, $this->pages->findForEditor('page-1')['version']);
$this->pages->publish('page-1', $revision, $this->pages->findForEditor('page-1')['version']);
$html = $this->request('/')->getContent();
self::assertStringContainsString('Ein Auszug, der im Raster stört.', $html);
self::assertStringContainsString('von Umut', $html);
self::assertStringContainsString('post-terms', $html);
}
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'));
// Der Begriff existiert noch, sein Archiv hat aber nichts mehr zu zeigen: eine leere Liste
// unter eigener Adresse ist eine Sackgasse, also 404 statt einer Seite mit Überschrift.
self::assertSame(404, $this->request('/kategorie/projekte')->getStatusCode());
self::assertSame(0, $this->posts->published(1, $this->posts->findTerm('category', 'projekte')['id'])['total']);
// Ein Beitrag in einer anderen Kategorie bringt deren Archiv zurück, dieses hier nicht.
$this->publishedPost('bleibt', 'Bleibt', '2026-01-02', ['Technik'], []);
self::assertSame(200, $this->request('/kategorie/technik')->getStatusCode());
self::assertSame(404, $this->request('/kategorie/projekte')->getStatusCode());
}
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.
// Die Fassung haengt als Parameter an: nach einem Update laedt niemand ein altes Skript
// aus dem Zwischenspeicher.
self::assertStringContainsString('<script src="/assets/tabs.js?v=', $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 testAHeaderCanChangeItsColoursOnceThePageHasScrolled(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', self::styledDocument(), 0), 1);
$header = $this->partials->findByKind('header');
$document = $header['document'];
// Glass over the hero, solid once the reader has moved: the Flatsome transparent header.
$document['blocks'][0]['style'] = ['desktop' => ['backgroundColor' => '#ffffff00', 'scrolled' => ['backgroundColor' => '#ffffff', 'color' => '#123456']]];
$this->partials->save($header['id'], $document, (int) $header['version']);
$response = $this->request('/');
$html = $response->getContent();
self::assertStringContainsString('#area-header{background-color:#ffffff00}', $html);
self::assertStringContainsString('#area-header{transition:background-color .3s,color .3s,border-color .3s,box-shadow .3s,opacity .3s}html.is-scrolled #area-header{background-color:#ffffff;color:#123456}', $html);
// The small script that marks the scrolled page comes only with such a header, and the
// policy opens for it exactly then.
self::assertStringContainsString('/assets/scrolled.js?v=', $html);
self::assertStringContainsString("script-src 'self'", (string) $response->headers->get('Content-Security-Policy'));
// Nur Farben, Schatten und Deckkraft dürfen sich beim Scrollen ändern; eine wachsende
// Schrift würde die Seite unter dem Finger verschieben.
$document['blocks'][0]['style']['desktop']['scrolled'] = ['fontSize' => '40px'];
$this->expectException(InvalidDocument::class);
$this->partials->save($header['id'], $document, (int) $header['version'] + 1);
}
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);
// The history is paged now, so it says how many there are instead of stopping silently.
$revisions = $this->pages->revisions('page-1');
self::assertSame(['Zweite Fassung', 'Erste Fassung'], array_column($revisions['items'], 'title'), 'Newest first.');
self::assertSame(2, $revisions['total']);
self::assertSame(1, $revisions['pages']);
self::assertTrue($revisions['items'][0]['draft']);
$oldest = end($revisions['items']);
$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::assertSame(3, $this->pages->revisions('page-1')['total']);
self::assertSame('Zweite Fassung', $this->pages->revisions('page-1')['items'][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('<span class="nav-label"><span>Start</span></span>', $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']);
// A second header is allowed now that pages can choose one - it keeps its own address,
// while the first one stays "default" and is what a page without a choice gets.
$this->partials->create('header', 'landing', 'Kopf für Landingpages', ['schemaVersion' => 11, 'title' => 'x', 'description' => 'x', 'blocks' => []]);
self::assertSame(['default' => 'Kopfbereich', 'landing' => 'Kopf für Landingpages'], $this->partials->choices('header'));
self::assertSame('Kopf für Landingpages', $this->partials->findByKind('header', 'landing')['name']);
// The same address twice stays refused.
try {
$this->partials->create('header', 'landing', 'Noch einer', ['schemaVersion' => 11, 'title' => 'x', 'description' => 'x', 'blocks' => []]);
self::fail('Expected a duplicate address to be refused.');
} catch (InvalidPage) {
}
// The standard area cannot be deleted, only emptied.
$this->expectException(InvalidPage::class);
$this->partials->delete($this->partials->findByKind('header')['id']);
}
public function testNewTabLinksPersistForBlocksAndNestedMenus(): void
{
$document = DemoContent::starter('Links');
$document['schemaVersion'] = 20;
$types = [
'button' => ['text' => 'Öffnen'],
'image' => ['src' => '/bild.png', 'width' => 120, 'height' => 80, 'alt' => 'Bild', 'decorative' => false, 'click' => 'link'],
'iconcard' => ['title' => 'Karte', 'text' => '', 'icon' => 'star'],
'cta' => ['title' => 'Aufruf', 'text' => '', 'buttonText' => 'Öffnen'],
'icon' => ['icon' => 'star', 'size' => 24, 'decorative' => false, 'label' => 'Öffnen'],
];
foreach ($types as $type => $props) {
$document['blocks'][] = ['id' => 'new-'.$type, 'type' => $type, 'props' => [...$props, 'href' => '/kontakt', 'newTab' => true]];
}
$revision = $this->pages->saveDraft('page-1', $document, 0);
$this->pages->publish('page-1', $revision, 1);
$this->menus->save('header', 'Links', [['label' => 'Eltern', 'type' => 'url', 'url' => '/', 'newTab' => true, 'children' => [['label' => 'Kind', 'type' => 'url', 'url' => '/kontakt', 'newTab' => true]]]], 0);
$crawler = new \Symfony\Component\DomCrawler\Crawler($this->request('/')->getContent());
foreach (['#new-button', '#new-image a', '#new-iconcard a', '#new-cta a', '#new-icon'] as $selector) {
self::assertSame('_blank', $crawler->filter($selector)->attr('target'));
self::assertSame('noopener', $crawler->filter($selector)->attr('rel'));
}
self::assertCount(2, $crawler->filter('.site-header .block-menu > .menu-list a[target="_blank"][rel="noopener"]'));
self::assertCount(2, $crawler->filter('.site-header .menu-burger a[target="_blank"][rel="noopener"]'));
$document['blocks'][array_key_last($document['blocks'])]['props']['newTab'] = 'yes';
$this->expectException(InvalidDocument::class);
(new DocumentValidator())->validate($document);
}
public function testNewTabAndRemovedBackgroundRequireSchemaTwenty(): void
{
foreach ([['type' => 'button', 'props' => ['text' => 'Link', 'href' => '/', 'newTab' => true]], ['type' => 'section', 'style' => ['tablet' => ['backgroundImage' => 'none']], 'children' => []]] as $block) {
$document = DemoContent::starter('Schema');
$document['schemaVersion'] = 19;
$document['blocks'][] = ['id' => 'new-option', ...$block];
try {
(new DocumentValidator())->validate($document);
self::fail('An explicit schema upgrade is required.');
} catch (InvalidDocument) {
$document['schemaVersion'] = 20;
(new DocumentValidator())->validate($document);
self::assertSame(20, $document['schemaVersion']);
}
}
}
public function testRemovingAnInheritedBackgroundAlsoRemovesItOnNarrowerDevices(): void
{
$id = str_repeat('a', 32);
$this->db->insert('media', ['id' => $id, 'title' => 'Klein', 'alt' => '', 'variants' => json_encode([['src' => '/media/'.$id.'/192', 'width' => 192, 'height' => 64, 'bytes' => 1]]), 'archived' => 0, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')]);
$compiler = new StyleCompiler(new MediaLibrary($this->db, $this->path.'-media'));
$document = ['blocks' => [['id' => 'background', 'style' => ['desktop' => ['backgroundImage' => $id], 'tablet' => ['backgroundImage' => 'none']]]]];
$css = $compiler->compileDocument($document);
self::assertStringContainsString('#background{background-image:url("/media/'.$id.'/192")}', $css);
self::assertStringContainsString('@media (max-width:1024px){#background{background-image:none}}', $css);
self::assertStringContainsString('@media (max-width:767px){#background{background-image:none}}', $css);
$document['blocks'][0]['style']['desktop'] += ['gradientFrom' => '#000000', 'gradientTo' => '#ffffff', 'gradientAngle' => 90];
$gradientCss = $compiler->compileDocument($document);
self::assertStringContainsString('@media (max-width:1024px){#background{background-image:linear-gradient(90deg,#000000,#ffffff)}}', $gradientCss);
self::assertStringContainsString('@media (max-width:767px){#background{background-image:linear-gradient(90deg,#000000,#ffffff)}}', $gradientCss);
unset($document['blocks'][0]['style']['tablet']);
self::assertStringNotContainsString('background-image:none', $compiler->compileDocument($document));
}
/**
* @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 'new local tab has no opener' => ['<a href="/kontakt" target="_blank" rel="opener">hier</a>', '<a href="/kontakt" target="_blank" rel="noopener">hier</a>'];
yield 'rejects named frames' => ['<a href="/kontakt" target="_top">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);
}
/** Turns on the consent question and publishes a privacy page it can point to. */
private function askConsent(array $groups = ['external']): void
{
$this->publishedPage('datenschutz', 'Datenschutz');
$this->site->save([
'homePath' => 'home',
'consentBanner' => '1',
'consentGroups' => $groups,
'privacyPath' => 'datenschutz',
]);
}
public function testAnImageMayCarryAsManySourcesAsThePipelineProduces(): void
{
// This is the test for a bug the browser check found: the validator allowed three sources,
// the media library produced four, and every document with a picture in it was rejected -
// silently, because a rejected save looks like nothing happening.
$sources = [];
foreach (MediaLibrary::WIDTHS as $width) {
$sources[] = ['src' => '/media/'.str_repeat('a', 32).'/'.$width, 'width' => $width];
}
$image = ['id' => 'bild', 'type' => 'image', 'props' => [
'src' => end($sources)['src'], 'alt' => 'Ein Bild', 'decorative' => false,
'width' => end($sources)['width'], 'height' => 800, 'sources' => $sources,
]];
$document = DemoContent::pages()['home'];
$document['schemaVersion'] = max(3, $document['schemaVersion']);
$document['blocks'][0]['children'][] = $image;
(new DocumentValidator())->validate($document);
self::assertCount(count(MediaLibrary::WIDTHS), $sources, 'Vier Quellen sind erlaubt, nicht drei.');
// One more than the pipeline can produce stays a reason to refuse.
$last = array_key_last($document['blocks'][0]['children']);
$document['blocks'][0]['children'][$last]['props']['sources'][] = ['src' => '/media/'.str_repeat('a', 32).'/3200', 'width' => 3200];
$this->expectException(InvalidDocument::class);
(new DocumentValidator())->validate($document);
}
public function testAnUploadBecomesWebpInSeveralWidths(): void
{
$media = new MediaLibrary($this->db, $this->path.'-media');
$file = tempnam(sys_get_temp_dir(), 'kant-upload-');
// The upload guard compares against the memory limit, and by this point in a full suite
// run a good part of it is already used. The limit is raised for this test, because the
// subject here is the image pipeline and not the guard.
$limit = ini_get('memory_limit');
ini_set('memory_limit', '512M');
try {
// A source wide enough for every step.
$image = imagecreatetruecolor(2600, 1600);
imagejpeg($image, $file, 92);
imagedestroy($image);
$item = $media->upload(new UploadedFile($file, 'foto.jpg', test: true));
// Four widths, all WebP, none larger than the source.
self::assertSame([480, 960, 1600, 2400], array_column($item['variants'], 'width'));
foreach ($item['variants'] as $variant) {
$path = $this->path.'-media/'.$item['id'].'-'.$variant['width'].'.webp';
self::assertSame('image/webp', getimagesize($path)['mime'], 'Jede Variante ist WebP.');
// The aspect ratio survives the resizing.
self::assertSame((int) round($variant['width'] * 1600 / 2600), $variant['height']);
self::assertGreaterThan(0, $variant['bytes']);
}
// A narrow source is not blown up: it yields fewer variants, not bigger ones.
$small = imagecreatetruecolor(700, 500);
imagepng($small, $file);
imagedestroy($small);
$narrow = $media->upload(new UploadedFile($file, 'klein.png', test: true));
self::assertSame([480, 700], array_column($narrow['variants'], 'width'));
} finally {
@unlink($file);
ini_set('memory_limit', (string) $limit);
}
}
public function testABackgroundImageUsesASmallerVariantOnSmallerScreens(): void
{
$media = new MediaLibrary($this->db, $this->path.'-media');
$file = tempnam(sys_get_temp_dir(), 'kant-bg-');
try {
// Smaller than the upload test above: both run in the same process, and the memory
// guard counts what is already allocated.
$image = imagecreatetruecolor(2000, 1200);
imagejpeg($image, $file, 92);
imagedestroy($image);
$item = $media->upload(new UploadedFile($file, 'hintergrund.jpg', test: true));
$compiler = new StyleCompiler($media);
$css = $compiler->compileDocument(['blocks' => [['id' => 'hero', 'type' => 'section', 'style' => [
'desktop' => ['backgroundImage' => $item['id']],
'mobile' => ['backgroundImage' => $item['id']],
]]]]);
// CSS has no srcset, but the rules are written per device - so the device decides.
self::assertStringContainsString('#hero{background-image:url("/media/'.$item['id'].'/2000")}', $css);
self::assertStringContainsString('@media (max-width:767px){#hero{background-image:url("/media/'.$item['id'].'/960")}}', $css);
} finally {
@unlink($file);
}
}
public function testEveryLongListSaysHowMuchThereIsAndLetsYouReachIt(): void
{
// Thirty revisions: a fixed cap would show some and say nothing about the rest.
for ($index = 1; $index <= 30; ++$index) {
$this->pages->saveDraft('page-1', DemoContent::starter('Fassung '.$index), $index - 1);
}
$first = $this->pages->revisions('page-1', 1, 25);
self::assertCount(25, $first['items']);
self::assertSame(30, $first['total']);
self::assertSame(2, $first['pages']);
self::assertSame('Fassung 30', $first['items'][0]['title'], 'Newest first.');
$second = $this->pages->revisions('page-1', 2, 25);
self::assertCount(5, $second['items']);
self::assertSame('Fassung 1', end($second['items'])['title']);
// A page beyond the end shows the last one instead of an empty screen.
self::assertSame(2, $this->pages->revisions('page-1', 99, 25)['page']);
// The same for redirects, which may run into the thousands.
for ($index = 1; $index <= 12; ++$index) {
$this->redirects->save('alt-'.$index, 'home', 301);
}
$redirects = $this->redirects->all(1, 5);
self::assertCount(5, $redirects['items']);
self::assertSame(12, $redirects['total']);
self::assertSame(3, $redirects['pages']);
self::assertCount(2, $this->redirects->all(3, 5)['items']);
}
public function testTheFirstImageOfAPageIsNotLazy(): void
{
$document = DemoContent::starter('Mit Bildern');
$document['schemaVersion'] = 3;
foreach (['erstes', 'zweites'] as $id) {
$document['blocks'][0]['children'][] = [
'id' => $id, 'type' => 'image',
'props' => ['src' => '/assets/image-placeholder.svg', 'alt' => 'Ein Bild', 'decorative' => false, 'width' => 800, 'height' => 600],
];
}
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
// The first one is usually the largest thing above the fold; deferring it delays exactly
// the pixel a visitor waits for.
self::assertMatchesRegularExpression('/<img[^>]*id="erstes"|id="erstes"[^>]*>/', $html);
$first = $this->imageTag($html, 'erstes');
$second = $this->imageTag($html, 'zweites');
self::assertStringContainsString('loading="eager"', $first);
self::assertStringContainsString('fetchpriority="high"', $first);
self::assertStringContainsString('loading="lazy"', $second);
self::assertStringNotContainsString('fetchpriority', $second);
}
public function testTheBackgroundOfTheFirstSectionIsFetchedBeforeTheStylesheetFindsIt(): void
{
// Auf der Startseite des Piloten ist das größte Element über der Falz kein `<img>`, sondern
// der Hintergrund des ersten Abschnitts. Ein Hintergrundbild steht nur im Stylesheet, und
// das kommt nach dem HTML - der Browser erfährt also erst vom Bild, wenn er das CSS
// geparst hat. Gemessen: 1,3 s statt 0,9 s bis zum größten Element.
$media = new MediaLibrary($this->db, $this->path.'-media');
$datei = tempnam(sys_get_temp_dir(), 'kant-hero-');
$limit = ini_get('memory_limit');
ini_set('memory_limit', '512M');
try {
$bild = imagecreatetruecolor(2600, 1600);
imagejpeg($bild, $datei, 80);
imagedestroy($bild);
$item = $media->upload(new UploadedFile($datei, 'hero.jpg', test: true));
$version = static fn (Connection $db): int => (int) $db->fetchOne('SELECT version FROM page WHERE id = ?', ['page-1']);
$ohne = DemoContent::starter('Ohne Hintergrund');
$entwurf = $this->pages->saveDraft('page-1', $ohne, $version($this->db));
$this->pages->publish('page-1', $entwurf, $version($this->db));
$sparsam = (int) $this->request('/')->headers->get('X-Kant-Queries');
$mit = DemoContent::starter('Mit Hintergrund');
$mit['schemaVersion'] = max(10, $mit['schemaVersion'] ?? 10);
$mit['blocks'][0]['style']['desktop']['backgroundImage'] = $item['id'];
$entwurf = $this->pages->saveDraft('page-1', $mit, $version($this->db));
$this->pages->publish('page-1', $entwurf, $version($this->db));
$antwort = $this->request('/');
$html = (string) $antwort->getContent();
// Drei Verweise, einer je Gerät - und genau einer trifft zu.
preg_match_all('/<link rel="preload" as="image" href="([^"]+)" media="([^"]+)"[^>]*>/', $html, $treffer, PREG_SET_ORDER);
self::assertCount(3, $treffer, 'Der Hintergrund des ersten Abschnitts wird nicht vorab geholt.');
self::assertSame(
['(min-width:1025px)', '(min-width:768px) and (max-width:1024px)', '(max-width:767px)'],
array_column($treffer, 2),
);
self::assertSame(
['/media/'.$item['id'].'/2400', '/media/'.$item['id'].'/1600', '/media/'.$item['id'].'/960'],
array_column($treffer, 1),
'Jedes Gerät bekommt die Variante, die es auch ohne Vorabholen bekäme.',
);
foreach ($treffer as $verweis) {
// Der Punkt der Übung: Vorabverweis und Regel müssen dieselbe Datei meinen. Sonst
// lädt der Browser zwei Bilder statt einem, und das Vorabholen kostet mehr als es
// bringt.
self::assertStringContainsString('url("'.$verweis[1].'")', $html, 'Der Vorabverweis meint eine andere Datei als die Regel.');
self::assertStringContainsString('fetchpriority="high"', $verweis[0]);
}
// Dasselbe Bild wird während einer Ausgabe mehrfach gesucht: je Gerät eine Regel, dazu
// der Kopf. Gefragt wird trotzdem nur einmal.
$mitBild = (int) $antwort->headers->get('X-Kant-Queries');
self::assertLessThanOrEqual($sparsam + 1, $mitBild, sprintf(
'Ein Hintergrundbild kostet eine Abfrage, nicht vier (%d statt %d).',
$mitBild,
$sparsam,
));
} finally {
@unlink($datei);
ini_set('memory_limit', (string) $limit);
}
}
public function testAPageDoesNotAskTheDatabaseOverAndOver(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
$response = $this->request('/');
$queries = (int) $response->headers->get('X-Kant-Queries');
// A ceiling, not an exact number: the point is that settings and theme are read once per
// request instead of once per caller. Before that they were read eighteen times.
self::assertGreaterThan(0, $queries, 'The counter has to be wired up at all.');
self::assertLessThanOrEqual(12, $queries, sprintf('Die Startseite braucht %d Abfragen.', $queries));
}
/** The img tag belonging to one block id, so the attributes can be read for that one image. */
private function imageTag(string $html, string $id): string
{
$figure = strpos($html, 'id="'.$id.'"');
self::assertNotFalse($figure, 'Bild '.$id.' fehlt.');
$start = strpos($html, '<img', $figure);
self::assertNotFalse($start);
return substr($html, $start, (int) strpos($html, '>', $start) - $start);
}
public function testAPageCanUseAnotherHeaderOrNoneAtAll(): void
{
// The seeded areas plus a second header for landing pages.
$this->partials->create('header', 'landing', 'Landing-Kopf', [
'schemaVersion' => 11, 'title' => 'Landing', 'description' => 'x',
'blocks' => [['id' => 'landing-text', 'type' => 'text', 'props' => ['text' => 'Nur ein Logo']]],
]);
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
// By default the standard header is used.
$html = $this->request('/')->getContent();
self::assertStringContainsString('area-header-name', $html);
self::assertStringNotContainsString('landing-text', $html);
// The page can ask for the other one.
$version = (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['page-1']);
$this->pages->saveAreas('page-1', 'landing', null, $version);
$html = $this->request('/')->getContent();
self::assertStringContainsString('landing-text', $html);
self::assertStringNotContainsString('area-header-name', $html);
// Or for none at all - a landing page without navigation.
$this->pages->saveAreas('page-1', 'none', 'none', $version + 1);
$html = $this->request('/')->getContent();
self::assertStringNotContainsString('site-header', $html);
self::assertStringNotContainsString('site-footer', $html);
self::assertStringContainsString('Deine Inhalte', $html, 'The page itself is still there.');
// An address that no longer exists falls back to the standard area instead of silently
// losing the header.
$this->pages->saveAreas('page-1', 'weg', null, $version + 2);
self::assertStringContainsString('area-header-name', $this->request('/')->getContent());
// A stale version is refused, and an invalid address too.
$this->expectException(StaleWrite::class);
$this->pages->saveAreas('page-1', null, null, $version);
}
public function testAnInvalidAreaAddressIsRefused(): void
{
$this->expectException(InvalidPage::class);
$this->pages->saveAreas('page-1', 'Kein Slug!', null, 0);
}
/** @return iterable<string, array{string, bool}> */
public static function menuLayouts(): iterable
{
yield 'horizontal' => ['horizontal', true];
yield 'vertical' => ['vertical', true];
yield 'burger without a script' => ['burger', true];
yield 'invented' => ['mega', false];
}
#[DataProvider('menuLayouts')]
public function testAMenuCanBeLaidOutInThreeWays(string $layout, bool $accepted): void
{
$document = DemoContent::starter('Mit Menü');
$document['schemaVersion'] = 16;
$document['blocks'][0]['children'][] = ['id' => 'nav', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => $layout]];
if (!$accepted) {
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
return;
}
$this->menus->save('header', 'Hauptnavigation', [['label' => 'Start', 'type' => 'page', 'level' => 1, 'pageId' => 'page-1', 'position' => 10]], 0);
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('class="block-menu menu-'.$layout, $html);
// The burger opens with a details element, so it works on a page that runs no script.
$crawler = new \Symfony\Component\DomCrawler\Crawler($html);
self::assertCount('vertical' === $layout ? 0 : 1, $crawler->filter('#nav > details.menu-burger'));
self::assertStringContainsString("script-src 'none'", (string) $this->request('/')->headers->get('Content-Security-Policy'));
}
public function testAMenuLayoutNeedsSchemaSixteen(): void
{
$document = DemoContent::starter('Zu alt');
$document['schemaVersion'] = 15;
$document['blocks'][0]['children'][] = ['id' => 'nav', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'burger']];
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
}
public function testTheThemeColoursTheBlocksAndNotOnlyTheText(): void
{
// Beim Nachbau von kant-it.de blieben Symbole, Knöpfe, Rahmen und der Einwilligungsdialog
// im Grün der Vorlage, obwohl die Palette rot und marineblau war: site.css färbte sie aus
// festen Token, die das Theme nie gesetzt hat. Die Farben ließen sich einstellen und taten
// nichts - dieselbe Familie wie Rahmenstärke und Bildrundung.
$theme = $this->themes->load();
$this->themes->save(
[...$theme['colors'], ['key' => 'firma', 'name' => 'Firmenrot', 'value' => '#c1121f']],
$theme['fonts'],
[...$theme['base'], 'color' => '#0a192f', 'backgroundColor' => '#ffffff', 'linkColor' => '@firma'],
);
$css = $this->themes->css($this->themes->load());
// Die Token, aus denen site.css Knöpfe, Symbole, Linien und ruhigen Text färbt.
self::assertStringContainsString('--ink:#0a192f', $css);
self::assertStringContainsString('--paper:#ffffff', $css);
self::assertStringContainsString('--accent:var(--k-firma)', $css);
self::assertStringContainsString('--muted:var(--k-muted,color-mix(in srgb,#0a192f 80%,#ffffff))', $css);
self::assertStringContainsString('--line:var(--k-line,color-mix(in srgb,#0a192f 20%,#ffffff))', $css);
// Und die Gegenprobe in der Datei selbst: keine feste Farbe mehr für Symbol, Knopf und
// Einwilligungsdialog - Fehlerrot bleibt fest, denn ein Fehler ist rot, kein Firmenton.
$site = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
self::assertStringContainsString('.block-icon{display:inline-flex;align-items:center;justify-content:center;color:var(--accent)', $site);
self::assertStringContainsString('background:var(--accent)', $site);
self::assertStringNotContainsString('#2c6b4f', $site, 'Der Einwilligungsdialog nimmt die Farbe der Website.');
// Und er lässt sich eigens färben: eine Palettenfarbe mit dem Schlüssel „consent“ gewinnt
// über die Primärfarbe, ohne dass es dafür eine weitere Einstellung braucht.
self::assertStringContainsString('background:var(--k-consent,var(--accent))', $site);
self::assertStringNotContainsString('outline:3px solid #b85c12', $site, 'Auch der Fokusring folgt der Palette.');
}
public function testTextForScreenReadersStaysOutOfSight(): void
{
// Der peinlichste Fund der Reihe: die Klasse für ausschließlich vorgelesenen Text gab es
// in der Verwaltung, aber nicht auf den öffentlichen Seiten. Damit stand „Bewertung: 5
// von 5“ neben den Sternen und „(Pflichtfeld)“ hinter jedem Feld sichtbar auf der Seite.
// axe hat das nie gemeldet, weil es prüft, ob etwas angesagt wird - nicht, ob etwas
// fälschlich zu sehen ist.
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
self::assertStringContainsString('.visually-hidden{position:absolute', $css);
self::assertStringContainsString('clip-path:inset(50%)', $css);
// Und die Stellen, die sich darauf verlassen, gibt es wirklich.
$document = DemoContent::starter('Mit Stimme');
$document['schemaVersion'] = 17;
$document['blocks'][] = ['id' => 'stimme', 'type' => 'quote', 'props' => ['text' => 'Ein Satz.', 'author' => 'Name', 'role' => '', 'rating' => 5]];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
self::assertStringContainsString('<span class="visually-hidden">Bewertung: 5 von 5</span>', $this->request('/')->getContent());
}
public function testAColumnIsALayoutDeviceAndNotABox(): void
{
// Eine Spalte brachte Rahmen, Ecken und 30 px Innenabstand mit. Wer zwei Texte
// nebeneinander stellte, bekam zwei Kästchen. Die Karte ist eine Einstellung, kein
// Standard - und der Knopf im Aufruf-Band nimmt die Akzentfarbe, weil er auf einem
// dunklen Band vorher Tinte auf Tinte war.
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
self::assertStringContainsString('.block-column{min-width:0}', $css);
self::assertStringNotContainsString('.block-column{border:1px solid', $css);
self::assertStringContainsString('.kant .block-cta .button{background:var(--accent)', $css);
self::assertStringContainsString('.block-iconcard + .block-iconcard', $css, 'Gestapelte Karten brauchen Abstand.');
}
public function testHeadingsAreNotHyphenated(): void
{
// Beim Nachbau von kant-it.de stand in der Überschrift „di-gitale Exzellenz“: `hyphens:auto`
// galt für h1 bis h3. Im Fließtext ist Trennung nützlich, in einer Überschrift sieht sie
// nach einem Fehler aus - und genau dort fällt sie auf, weil die Schrift groß ist.
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
self::assertStringContainsString('h1,h2,h3,h4,h5,h6{hyphens:manual}', $css);
self::assertStringContainsString('p{hyphens:auto}', $css);
self::assertStringNotContainsString('hyphens:auto}', explode('p{hyphens:auto}', $css)[0], 'Vor der Regel für Absätze trennt nichts.');
}
public function testTheGlobalContentWidthCanMakeThePageWiderNotOnlyNarrower(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
// The stylesheet takes the number from the theme instead of a hard 1180px, which capped
// every larger value and made the setting look broken.
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
self::assertStringContainsString('.shell{width:min(var(--k-container,1180px),calc(100% - 64px))', $css);
$theme = $this->themes->load();
$this->themes->save($theme['colors'], $theme['fonts'], [...$theme['base'], 'containerWidth' => '1440px']);
$html = $this->request('/')->getContent();
self::assertStringContainsString('--k-container:1440px', $html);
}
/** @return iterable<string, array{string, bool}> */
public static function sectionWidths(): iterable
{
yield 'normal' => ['normal', true];
yield 'narrow' => ['narrow', true];
yield 'wide' => ['wide', true];
yield 'edge to edge' => ['full', true];
yield 'invented' => ['riesig', false];
}
#[DataProvider('sectionWidths')]
public function testASectionCanRunEdgeToEdge(string $width, bool $accepted): void
{
$document = DemoContent::starter('Breite');
$document['blocks'][0]['layout'] = ['tone' => 'paper', 'width' => $width];
if (!$accepted) {
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
return;
}
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
self::assertStringContainsString('width-'.$width, $this->request('/')->getContent());
}
/** @return iterable<string, array{string, bool}> */
public static function heights(): iterable
{
// 100vh is wrong on a phone - it means the viewport without the address bar. The unit
// people actually want is dvh, so it has to be allowed.
yield 'dynamic viewport height' => ['100dvh', true];
yield 'small viewport height' => ['100svh', true];
yield 'large viewport height' => ['100lvh', true];
yield 'classic viewport height' => ['100vh', true];
yield 'pixels' => ['640px', true];
yield 'invented unit' => ['100qh', false];
yield 'no unit' => ['100', false];
}
#[DataProvider('heights')]
public function testAFullHeightSectionIsPossibleWithTheUnitThatWorksOnPhones(string $height, bool $accepted): void
{
$document = DemoContent::starter('Hoch');
$document['schemaVersion'] = 10;
$document['blocks'][0]['style'] = ['desktop' => ['minHeight' => $height, 'display' => 'flex', 'alignItems' => 'center']];
if (!$accepted) {
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
return;
}
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('min-height:'.$height, $html);
// Full height alone would put the content at the top; the flex properties centre it.
self::assertStringContainsString('display:flex;align-items:center', $html);
}
public function testAFullBleedImageFillsTheSection(): void
{
$document = DemoContent::starter('Bildfläche');
$document['schemaVersion'] = 15;
$document['blocks'][0]['layout'] = ['tone' => 'paper', 'width' => 'full'];
$document['blocks'][0]['style'] = ['desktop' => ['padding' => ['top' => '0', 'bottom' => '0']]];
$document['blocks'][0]['children'][] = [
'id' => 'flaeche', 'type' => 'image',
'props' => ['src' => '/assets/image-placeholder.svg', 'alt' => 'Weite Landschaft', 'decorative' => false, 'width' => 2400, 'height' => 1200],
'style' => ['desktop' => ['width' => '100%', 'objectFit' => 'cover', 'imageHeight' => '100dvh']],
];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('width-full', $html);
self::assertStringContainsString('#flaeche{width:100%}', $html);
// The height and the crop belong on the image itself, not on its figure.
self::assertStringContainsString('#flaeche img{object-fit:cover;height:100dvh}', $html);
}
/** @return iterable<string, array{string, array<string, mixed>, list<string>}> */
public static function newBlocks(): iterable
{
yield 'a decorative icon is hidden from screen readers' => [
'icon', ['icon' => 'star', 'size' => 48, 'decorative' => true, 'label' => '', 'href' => ''],
['class="block-icon icon-size-48', 'aria-hidden="true"'],
];
yield 'a meaningful icon carries its label as text' => [
'icon', ['icon' => 'phone', 'size' => 32, 'decorative' => false, 'label' => 'Anrufen', 'href' => '/kontakt'],
['<a id="i1"', 'href="/kontakt"', '<span class="visually-hidden">Anrufen</span>'],
];
yield 'a progress bar states its value in the markup' => [
'progress', ['label' => 'Fortschritt', 'value' => 70, 'showValue' => true],
['role="meter"', 'aria-valuenow="70"', 'progress-fill-70', '70 %'],
];
yield 'a slider is a scroll container with a class per device' => [
'slider', ['desktopVisible' => 3, 'tabletVisible' => 2, 'mobileVisible' => 1, 'snap' => true],
['class="block-slider slide-desktop-3 slide-tablet-2 slide-mobile-1 slider-snap"', 'tabindex="0"', 'role="group"'],
];
}
#[DataProvider('newBlocks')]
public function testTheNewBlocksRenderWhatTheyPromise(string $type, array $props, array $expected): void
{
$document = DemoContent::starter('Mit '.$type);
$document['schemaVersion'] = 15;
$document['blocks'][] = ['id' => 'i1', 'type' => $type, 'props' => $props];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
foreach ($expected as $needle) {
self::assertStringContainsString($needle, $html, $type);
}
// Sliders receive controls; the other blocks keep a script-free public page.
self::assertStringContainsString('slider' === $type ? "script-src 'self'" : "script-src 'none'", (string) $this->request('/')->headers->get('Content-Security-Policy'));
}
/** @return iterable<string, array{string, array<string, mixed>}> */
public static function refusedNewBlocks(): iterable
{
// A size without a class would leave the icon without dimensions, so only the listed ones.
yield 'icon size off the list' => ['icon', ['icon' => 'star', 'size' => 37, 'decorative' => true, 'label' => '']];
yield 'unknown icon' => ['icon', ['icon' => 'erfunden', 'size' => 48, 'decorative' => true, 'label' => '']];
yield 'meaningful icon without a label' => ['icon', ['icon' => 'star', 'size' => 48, 'decorative' => false, 'label' => '']];
yield 'decorative icon with a label' => ['icon', ['icon' => 'star', 'size' => 48, 'decorative' => true, 'label' => 'Doch etwas']];
yield 'progress beyond a hundred' => ['progress', ['label' => 'X', 'value' => 140, 'showValue' => true]];
yield 'progress with a text value' => ['progress', ['label' => 'X', 'value' => '70', 'showValue' => true]];
yield 'slider with seven visible' => ['slider', ['desktopVisible' => 7, 'tabletVisible' => 2, 'mobileVisible' => 1, 'snap' => true]];
yield 'slider missing a device' => ['slider', ['desktopVisible' => 3, 'tabletVisible' => 2, 'snap' => true]];
}
#[DataProvider('refusedNewBlocks')]
public function testRefusedNewBlocksNeverReachADocument(string $type, array $props): void
{
$document = DemoContent::starter('Mit '.$type);
$document['schemaVersion'] = 15;
$document['blocks'][] = ['id' => 'i1', 'type' => $type, 'props' => $props];
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
}
public function testTheNewBlocksNeedSchemaFifteen(): void
{
$document = DemoContent::starter('Zu alt');
$document['schemaVersion'] = 14;
$document['blocks'][] = ['id' => 'i1', 'type' => 'icon', 'props' => ['icon' => 'star', 'size' => 48, 'decorative' => true, 'label' => '']];
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
}
/** @return iterable<string, array{array<string, mixed>, string}> */
public static function styleDeclarations(): iterable
{
// A border width per side is spelled border-top-width, not border-width-top. Written the
// wrong way the browser drops the declaration, so per-side borders had no effect at all.
yield 'border width per side' => [['borderWidth' => ['top' => '2px', 'left' => '4px']], '#b1{border-top-width:2px;border-left-width:4px}'];
yield 'padding keeps its own spelling' => [['padding' => ['top' => '12px']], '#b1{padding-top:12px}'];
yield 'corners' => [['borderRadius' => ['topLeft' => '18px']], '#b1{border-top-left-radius:18px}'];
yield 'outline' => [['outlineWidth' => '3px', 'outlineColor' => '#2c6b4f', 'outlineOffset' => '2px'], '#b1{outline-width:3px;outline-color:#2c6b4f;outline-offset:2px}'];
yield 'position and layer' => [['position' => 'absolute', 'top' => '0', 'right' => '12px', 'zIndex' => '5'], '#b1{position:absolute;top:0;right:12px;z-index:5}'];
yield 'flex item' => [['alignSelf' => 'center', 'flexGrow' => '2', 'flexBasis' => '40%'], '#b1{align-self:center;flex-grow:2;flex-basis:40%}', 'column'];
yield 'separate gaps' => [['rowGap' => '8px', 'columnGap' => '24px'], '#b1{row-gap:8px;column-gap:24px}'];
yield 'inner shadow' => [['shadowY' => '4px', 'shadowBlur' => '12px', 'shadowInset' => 'innen'], '#b1{box-shadow:inset 0 4px 12px 0 #00000026}'];
yield 'outer shadow stays outer' => [['shadowY' => '4px'], '#b1{box-shadow:0 4px 0 0 #00000026}'];
yield 'text shadow' => [['textShadowY' => '2px', 'textShadowBlur' => '6px', 'textShadowColor' => '#00000080'], '#b1{text-shadow:0 2px 6px #00000080}'];
// The filter order is fixed by the compiler, not by the order someone set the values in.
yield 'filters in a fixed order' => [['grayscale' => '0.4', 'blur' => '2', 'brightness' => '1.1'], '#b1{filter:blur(2px) brightness(1.1) grayscale(0.4)}'];
yield 'hue rotation' => [['hueRotate' => '-30'], '#b1{filter:hue-rotate(-30deg)}'];
yield 'blend mode' => [['mixBlendMode' => 'multiply'], '#b1{mix-blend-mode:multiply}'];
// These belong on the image inside the block: object-fit on a figure does nothing.
yield 'image properties target the image' => [['objectFit' => 'cover', 'imageRatio' => '16/9'], '#b1 img{object-fit:cover;aspect-ratio:16/9}'];
yield 'image radius and border' => [['imageRadius' => ['topLeft' => '12px'], 'imageBorderWidth' => ['top' => '3px'], 'imageBorderColor' => '#2c6b4f'], '#b1 img{border-top-left-radius:12px;border-top-width:3px;border-color:#2c6b4f}'];
yield 'both at once, in two rules' => [['padding' => ['top' => '8px'], 'objectFit' => 'contain'], '#b1{padding-top:8px}#b1 img{object-fit:contain}'];
}
#[DataProvider('styleDeclarations')]
public function testEveryStylePropertyCompilesToTheDeclarationItPromises(array $style, string $expected, string $type = 'image'): void
{
$compiler = new StyleCompiler();
self::assertSame($expected, $compiler->compileDocument([
'blocks' => [['id' => 'b1', 'type' => $type, 'style' => ['desktop' => $style]]],
]));
// And what compiles has to survive the validator, or the editor could offer it in vain.
$document = DemoContent::starter('Mit Stil');
$document['schemaVersion'] = 10;
$document['blocks'][0]['style'] = ['desktop' => $style];
$this->pages->saveDraft('page-1', $document, 0);
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', ['page-1']));
}
public function testTheStyleSchemaAndTheEditorPanelKnowTheSameGroups(): void
{
$groups = [];
foreach (StyleSchema::properties() as $property) {
$groups[$property['group']] = true;
}
$panel = (string) file_get_contents(__DIR__.'/../public/assets/design-panel.js');
foreach (array_keys($groups) as $group) {
// A group the panel does not know would hold its properties in an unnamed accordion.
self::assertMatchesRegularExpression('/^\h*'.preg_quote($group, '/').':\h/m', $panel, sprintf('Die Gruppe „%s“ fehlt im Panel.', $group));
}
}
public function testALegalPathMustPointAtAPageThatExists(): void
{
foreach (['privacyPath', 'imprintPath'] as $key) {
try {
$this->site->save(['homePath' => 'home', $key => 'gibt-es-nicht']);
self::fail('Expected '.$key.' to be refused.');
} catch (InvalidPage) {
}
self::assertSame('', $this->site->load()[$key]);
}
// A page that exists is accepted; placing its link remains an editorial choice.
$this->publishedPage('datenschutz', 'Datenschutz');
$this->site->save(['homePath' => 'home', 'privacyPath' => 'datenschutz']);
self::assertSame('datenschutz', $this->site->load()['privacyPath']);
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
self::assertStringNotContainsString('<a href="/datenschutz">Datenschutz</a>', $this->request('/')->getContent());
}
public function testADeliveredPageIncrementsOneCounterAndTheAdministrationDoesNot(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
$this->request('/');
$this->request('/');
// A page that does not exist counts as a missing address, not as a page view.
$this->request('/gibt-es-nicht');
// Everything that is not a page of the site stays out of the numbers.
$this->request('/admin');
$this->request('/feed.xml');
$this->request('/robots.txt');
$rows = $this->db->fetchAllAssociative('SELECT path, status, count FROM stat_hit ORDER BY status');
self::assertSame([
['path' => '/', 'status' => 200, 'count' => 2],
['path' => '/gibt-es-nicht', 'status' => 404, 'count' => 1],
], array_map(static fn (array $row): array => ['path' => $row['path'], 'status' => (int) $row['status'], 'count' => (int) $row['count']], $rows));
}
public function testWithoutEmbedsThereIsNoConsentQuestion(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
$html = $this->request('/')->getContent();
// Nothing is loaded from anywhere else, so there is nothing to ask about - and a banner
// without a reason is only in the way.
self::assertStringNotContainsString('consent-bar', $html);
self::assertStringNotContainsString('Einwilligung ändern', $html);
self::assertSame(404, $this->request('/einwilligung')->getStatusCode());
}
public function testTheQuestionAppearsUntilItIsAnsweredAndCanBeChangedAgain(): void
{
$document = DemoContent::pages()['home'];
$document['schemaVersion'] = 19;
$document['blocks'][] = ['id' => 'consent-links', 'type' => 'legal', 'props' => []];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$this->askConsent();
$response = $this->request('/');
$html = $response->getContent();
self::assertStringContainsString('consent-bar', $html);
// Accepting and refusing are both buttons of the same form, so neither is hidden away.
self::assertStringContainsString('value="all"', $html);
self::assertStringContainsString('value="none"', $html);
self::assertStringContainsString('/datenschutz', $html);
// No script anywhere: the strict policy stays as it is.
self::assertStringContainsString("script-src 'none'", (string) $response->headers->get('Content-Security-Policy'));
$saved = $this->request('/einwilligung', 'POST', ['choice' => 'all', 'back' => '/']);
self::assertSame(302, $saved->getStatusCode());
self::assertSame('/', $saved->headers->get('Location'));
$cookie = $saved->headers->getCookies()[0];
self::assertSame(Consent::COOKIE, $cookie->getName());
self::assertTrue($cookie->isHttpOnly());
self::assertSame('lax', $cookie->getSameSite());
self::assertStringContainsString('external', (string) $cookie->getValue());
// With a decision in hand the banner is gone, but the way back to it is not.
$decided = $this->request('/', 'GET', [], [Consent::COOKIE => (string) $cookie->getValue()]);
self::assertStringNotContainsString('consent-bar', $decided->getContent());
self::assertStringContainsString('/einwilligung', $decided->getContent());
self::assertSame(200, $this->request('/einwilligung', 'GET', [], [Consent::COOKIE => (string) $cookie->getValue()])->getStatusCode());
}
/** @return iterable<string, array{string, bool}> */
public static function consentCookies(): iterable
{
yield 'granted' => ['1|external|'.time(), true];
yield 'refused' => ['1|<EMPTY>|'.time(), false];
yield 'other group only' => ['1|statistics|'.time(), false];
yield 'expired' => ['1|external|'.(time() - Consent::MAX_AGE - 60), false];
yield 'older question' => ['0|external|'.time(), false];
yield 'nonsense' => ['ganz-egal', false];
yield 'unknown group' => ['1|erfunden|'.time(), false];
}
#[DataProvider('consentCookies')]
public function testAnEmbedLoadsOnlyWithAValidDecisionForItsGroup(string $cookie, bool $expected): void
{
$document = DemoContent::starter('Mit Karte');
$document['schemaVersion'] = 14;
$document['blocks'][] = [
'id' => 'karte', 'type' => 'embed',
'props' => ['group' => 'external', 'title' => 'Anfahrt', 'provider' => 'OpenStreetMap', 'url' => 'https://karten.example/einbetten?ort=kant', 'hint' => '', 'height' => 400],
];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$this->askConsent();
$response = $this->request('/', 'GET', [], [Consent::COOKIE => str_replace('<EMPTY>', '', $cookie)]);
$html = $response->getContent();
$policy = (string) $response->headers->get('Content-Security-Policy');
if ($expected) {
self::assertStringContainsString('<iframe src="https://karten.example/einbetten?ort=kant"', $html);
self::assertStringContainsString('title="Anfahrt"', $html);
self::assertStringContainsString('frame-src https://karten.example', $policy);
} else {
// Not a hidden iframe, not a lazy one: the address does not appear at all.
self::assertStringNotContainsString('<iframe', $html);
self::assertStringNotContainsString('karten.example/einbetten', $html);
self::assertStringContainsString('OpenStreetMap', $html, 'The placeholder names the provider.');
self::assertStringContainsString("frame-src 'none'", $policy);
}
}
public function testTheEmbedButtonGrantsOnlyItsOwnGroupAndKeepsWhatWasGrantedBefore(): void
{
$document = DemoContent::starter('Mit Karte');
$document['schemaVersion'] = 14;
$document['blocks'][] = [
'id' => 'karte', 'type' => 'embed',
'props' => ['group' => 'external', 'title' => 'Anfahrt', 'provider' => 'Karten', 'url' => 'https://karten.example/x', 'hint' => '', 'height' => 300],
];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$this->askConsent(['external', 'statistics']);
// Statistics were granted earlier; loading the map must not take that away.
$before = '1|statistics|'.time();
$html = $this->request('/', 'GET', [], [Consent::COOKIE => $before])->getContent();
self::assertStringContainsString('name="groups[]" value="statistics"', $html);
self::assertStringContainsString('name="groups[]" value="external"', $html);
$saved = $this->request('/einwilligung', 'POST', ['choice' => 'some', 'groups' => ['statistics', 'external'], 'back' => '/']);
$value = (string) $saved->headers->getCookies()[0]->getValue();
self::assertStringContainsString('statistics', $value);
self::assertStringContainsString('external', $value);
}
public function testTheConsentFormNeverSendsAnyoneElsewhere(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
$this->askConsent();
foreach (['https://fremd.example/', '//fremd.example/', 'fremd.example', ''] as $target) {
$response = $this->request('/einwilligung', 'POST', ['choice' => 'none', 'back' => $target]);
self::assertSame('/', $response->headers->get('Location'), $target);
}
// A local path is kept, so the decision returns to the page it was made on.
self::assertSame('/datenschutz', $this->request('/einwilligung', 'POST', ['choice' => 'none', 'back' => '/datenschutz'])->headers->get('Location'));
}
public function testSwitchingTheQuestionOffLetsTheEmbedLoadAgain(): void
{
$document = DemoContent::starter('Mit Karte');
$document['schemaVersion'] = 14;
$document['blocks'][] = [
'id' => 'karte', 'type' => 'embed',
'props' => ['group' => 'external', 'title' => 'Anfahrt', 'provider' => 'Karten', 'url' => 'https://karten.example/x', 'hint' => '', 'height' => 300],
];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
// No question asked means nothing is withheld: otherwise turning the banner off would
// silently empty every page that embeds something.
$html = $this->request('/')->getContent();
self::assertStringContainsString('<iframe', $html);
self::assertStringNotContainsString('consent-bar', $html);
}
/** @return iterable<string, array{array<string, mixed>}> */
public static function refusedEmbeds(): iterable
{
yield 'http' => [['group' => 'external', 'title' => 'X', 'provider' => '', 'url' => 'http://karten.example/x', 'hint' => '', 'height' => 300]];
yield 'protocol relative' => [['group' => 'external', 'title' => 'X', 'provider' => '', 'url' => '//karten.example/x', 'hint' => '', 'height' => 300]];
yield 'javascript' => [['group' => 'external', 'title' => 'X', 'provider' => '', 'url' => 'javascript:alert(1)', 'hint' => '', 'height' => 300]];
yield 'data url' => [['group' => 'external', 'title' => 'X', 'provider' => '', 'url' => 'data:text/html,<script>alert(1)</script>', 'hint' => '', 'height' => 300]];
yield 'with a port' => [['group' => 'external', 'title' => 'X', 'provider' => '', 'url' => 'https://karten.example:8443/x', 'hint' => '', 'height' => 300]];
yield 'with credentials' => [['group' => 'external', 'title' => 'X', 'provider' => '', 'url' => 'https://nutzer:geheim@karten.example/x', 'hint' => '', 'height' => 300]];
yield 'host with a space' => [['group' => 'external', 'title' => 'X', 'provider' => '', 'url' => 'https://karten example/x', 'hint' => '', 'height' => 300]];
yield 'no title' => [['group' => 'external', 'title' => '', 'provider' => '', 'url' => 'https://karten.example/x', 'hint' => '', 'height' => 300]];
yield 'unknown group' => [['group' => 'erfunden', 'title' => 'X', 'provider' => '', 'url' => 'https://karten.example/x', 'hint' => '', 'height' => 300]];
yield 'height too small' => [['group' => 'external', 'title' => 'X', 'provider' => '', 'url' => 'https://karten.example/x', 'hint' => '', 'height' => 10]];
}
#[DataProvider('refusedEmbeds')]
public function testRefusedEmbedsNeverReachADocument(array $props): void
{
$document = DemoContent::starter('Mit Karte');
$document['schemaVersion'] = 14;
$document['blocks'][] = ['id' => 'karte', 'type' => 'embed', 'props' => $props];
try {
$this->pages->saveDraft('page-1', $document, 0);
self::fail('Expected the embed to be refused: '.json_encode($props['url']));
} catch (InvalidDocument) {
}
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', ['page-1']));
}
public function testAnEmbedNeedsSchemaFourteen(): void
{
$document = DemoContent::starter('Zu alt');
$document['schemaVersion'] = 13;
$document['blocks'][] = [
'id' => 'karte', 'type' => 'embed',
'props' => ['group' => 'external', 'title' => 'X', 'provider' => '', 'url' => 'https://karten.example/x', 'hint' => '', 'height' => 300],
];
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
}
public function testAConsentBannerNeedsAPrivacyPageThatExists(): void
{
foreach ([
'without a privacy page' => ['consentBanner' => '1', 'consentGroups' => ['external'], 'privacyPath' => ''],
'pointing nowhere' => ['consentBanner' => '1', 'consentGroups' => ['external'], 'privacyPath' => 'gibt-es-nicht'],
'without a group' => ['consentBanner' => '1', 'consentGroups' => [], 'privacyPath' => 'datenschutz'],
] as $case => $input) {
try {
$this->site->save([...$input, 'homePath' => 'home']);
self::fail('Expected '.$case.' to be refused.');
} catch (InvalidPage) {
}
self::assertFalse($this->site->load()['consentBanner'], $case);
}
}
private function request(string $url, string $method = 'GET', array $body = [], array $cookies = []): Response
{
$previous = [];
foreach (['APP_SECRET' => 'test-only', 'DATABASE_URL' => 'sqlite:///'.str_replace('\\', '/', $this->path), 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media', 'FONT_DIRECTORY' => $this->path.'-fonts', 'ASSET_DIRECTORY' => $this->path.'-assets'] as $key => $value) {
$previous[$key] = [$_SERVER[$key] ?? null, $_ENV[$key] ?? null];
$_SERVER[$key] = $_ENV[$key] = $value;
}
$kernel = new Kernel('test', false);
try {
$request = Request::create($url, $method, $body, $cookies);
$response = $kernel->handle($request);
// Counting happens on kernel.terminate, so a test that does not terminate would
// silently test a different application than the one that runs.
$kernel->terminate($request, $response);
return $response;
} 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;
}
}
}
}
}