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'] = '';
$id = $this->pages->saveDraft('page-1', $document, 0);
$this->pages->publish('page-1', $id, 1);
$response = $this->request('/');
self::assertSame(200, $response->getStatusCode());
self::assertStringContainsString('<script>', $response->getContent());
self::assertStringNotContainsString('';
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('digitale Exzellenz', $html);
self::assertStringNotContainsString('alert(1)', $html);
self::assertStringNotContainsString('#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('Leistungen', $html);
self::assertStringContainsString('Webdesign', $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
*/
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\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('Text danach', 'Text danach'];
yield 'drops an event handler' => ['
Mit Ereignis
', '
Mit Ereignis
'];
yield 'drops style and class' => ['
Verpackt
', 'Verpackt'];
yield 'turns an unsafe link into text' => ['Boese', 'Boese'];
yield 'removes an image with a handler' => ['', ''];
yield 'turns a newline into a break next to markup' => ["Eins\nZwei", 'Eins Zwei'];
yield 'removes an iframe' => ['ok', 'ok'];
yield 'closes unbalanced markup' => ['ungeschlossen', 'ungeschlossen'];
yield 'keeps a local link' => ['hier', 'hier'];
yield 'new local tab has no opener' => ['hier', 'hier'];
yield 'rejects named frames' => ['hier', 'hier'];
yield 'marks an external link' => ['dort', 'dort'];
yield 'escapes plain text and keeps the line break' => ["Eins <3\nZwei", 'Eins <3 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'] = 'Fett und verpackt';
$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('Fett 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'] = '';
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 starker Satz mit Link.';
// 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('starker', $html);
self::assertStringContainsString('Link', $html);
// A div, not a p: the allowlist includes lists and paragraphs, which may not nest in one.
self::assertStringContainsString('
', $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('<strong>starker</strong>', $plain);
self::assertStringNotContainsString('starker', $plain);
self::assertStringContainsString('
', $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'] =
'kant cms';
$this->partials->save($header['id'], $document, $header['version']);
$stored = $this->partials->findByKind('header')['document'];
self::assertSame('kant 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('kant 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
*/
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
*/
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('', $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('/]*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 ``, 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('/]*>/', $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, '', $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 */
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'));
}
/** @return iterable, list}> */
public static function burgerLabels(): iterable
{
yield 'symbol and text' => ['both', ['Menü'], []];
yield 'only text' => ['text', ['Menü'], ['burger-lines']];
yield 'only the symbol, the name stays for screen readers' => ['icon', ['Menü'], []];
}
/**
* @param list $enthalten
* @param list $fehlen
*/
#[DataProvider('burgerLabels')]
public function testTheMenuButtonCanBeSymbolTextOrBoth(string $variante, array $enthalten, array $fehlen): void
{
$document = DemoContent::starter('Mit Menü');
$document['schemaVersion'] = 16;
$document['blocks'][0]['children'][] = ['id' => 'nav', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'burger', 'burgerLabel' => $variante]];
$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);
// Nur der eigene Knopf, nicht der des Kopfbereichs aus der Startvorlage.
$crawler = new \Symfony\Component\DomCrawler\Crawler($this->request('/')->getContent());
$summary = $crawler->filter('#nav > details.menu-burger > summary')->html();
foreach ($enthalten as $teil) {
self::assertStringContainsString($teil, $summary);
}
foreach ($fehlen as $teil) {
self::assertStringNotContainsString($teil, $summary);
}
}
public function testAnInventedMenuButtonVariantIsRefused(): void
{
$document = DemoContent::starter('Erfunden');
$document['schemaVersion'] = 16;
$document['blocks'][0]['children'][] = ['id' => 'nav', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'burger', 'burgerLabel' => 'gross']];
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
}
/** Die Farben des Menü-Knopfs liegen am Block und müssen am summary ankommen. */
public function testTheMenuButtonTakesItsOwnColours(): void
{
$document = DemoContent::starter('Farbiger Knopf');
$document['schemaVersion'] = 16;
$document['blocks'][0]['children'][] = [
'id' => 'nav', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'burger'],
'style' => ['desktop' => ['burgerColor' => '#294655', 'burgerBackgroundColor' => '#ffffff', 'burgerBorderColor' => '#d8e0d1', 'burgerBorderRadius' => '3px', 'burgerFontSize' => '14px']],
];
$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('#nav .menu-burger>summary{color:#294655;background-color:#ffffff;border-color:#d8e0d1;border-radius:3px;font-size:14px}', $html);
}
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.
// Der Palettenschlüssel `consent` bleibt die Zwischenstufe hinter der eigenen Einstellung.
self::assertStringContainsString('background:var(--kant-consent-accent,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('Bewertung: 5 von 5', $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 */
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 */
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, list}> */
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'],
['Anrufen'],
];
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}> */
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]];
}
public function testSocialProfilesAreMaintainedOnceAndShownWhereverTheBlockStands(): void
{
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'sameAs' => 'https://kant-it.de', 'social' => [
'instagram' => 'https://www.instagram.com/elektrobernhardgmbh/', 'facebook' => 'https://www.facebook.com/elektrobernhardgmbh',
'x' => '',
]]);
self::assertSame(['facebook' => 'https://www.facebook.com/elektrobernhardgmbh', 'instagram' => 'https://www.instagram.com/elektrobernhardgmbh/'], $this->site->load()['social']);
$document = DemoContent::starter('Mit Profilen');
$document['schemaVersion'] = 23;
$document['blocks'][] = ['id' => 'so1', 'type' => 'social', 'props' => ['size' => 32, 'shape' => 'circle', 'layout' => 'horizontal', 'labels' => false, 'networks' => []]];
$document['blocks'][] = ['id' => 'so2', 'type' => 'social', 'props' => ['size' => 20, 'shape' => 'plain', 'layout' => 'vertical', 'labels' => true, 'networks' => ['instagram'], 'newTab' => false]];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
// Die Reihenfolge ist die der festen Liste, nicht die der Eingabe; jeder Link trägt seinen Namen.
self::assertMatchesRegularExpression('#
]*>.*facebook\.com.*instagram\.com.*
#s', $html);
self::assertStringContainsString('Facebook', $html);
self::assertStringContainsString('href="https://www.facebook.com/elektrobernhardgmbh" target="_blank" rel="noopener"', $html);
// Der zweite Block zeigt nur Instagram, beschriftet, im selben Tab.
preg_match('#
]*>(.*?)
#s', $html, $second);
self::assertStringNotContainsString('facebook', $second[1]);
self::assertStringContainsString('Instagram', $second[1]);
self::assertStringNotContainsString('target="_blank"', $second[1]);
self::assertStringContainsString('social-labels', $html);
// Und die Profile stehen ohne zweite Pflege in den strukturierten Daten.
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'legalName' => 'Elektro Bernhard GmbH', 'sameAs' => 'https://kant-it.de', 'social' => ['facebook' => 'https://www.facebook.com/elektrobernhardgmbh']]);
preg_match('##s', $this->request('/')->getContent(), $match);
self::assertSame(['https://kant-it.de', 'https://www.facebook.com/elektrobernhardgmbh'], json_decode(html_entity_decode($match[1], ENT_QUOTES), true)['sameAs']);
// Ohne Profile bleibt die Seite still: kein leerer Baustein, keine Liste.
$this->site->save(['country' => 'DE', 'homePath' => 'home']);
self::assertStringNotContainsString('block-social', $this->request('/')->getContent());
}
/** @return iterable}> */
public static function refusedSocialBlocks(): iterable
{
yield 'size off the list' => [['size' => 30, 'shape' => 'plain', 'layout' => 'horizontal', 'labels' => false, 'networks' => []]];
yield 'unknown shape' => [['size' => 24, 'shape' => 'hexagon', 'layout' => 'horizontal', 'labels' => false, 'networks' => []]];
yield 'unknown network' => [['size' => 24, 'shape' => 'plain', 'layout' => 'horizontal', 'labels' => false, 'networks' => ['myspace']]];
yield 'labels as text' => [['size' => 24, 'shape' => 'plain', 'layout' => 'horizontal', 'labels' => 'ja', 'networks' => []]];
}
#[DataProvider('refusedSocialBlocks')]
public function testASocialBlockOnlyStoresWhatTheRendererKnows(array $props): void
{
$document = DemoContent::starter('Profile');
$document['schemaVersion'] = 23;
$document['blocks'][] = ['id' => 'so1', 'type' => 'social', 'props' => $props];
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
}
public function testOpeningHoursAndTheServiceAreaReachTheStructuredData(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
$hours = [];
foreach (['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'] as $day) {
$hours[$day] = ['opens' => '07:30', 'closes' => '17:00'];
}
$hours['Saturday'] = ['opens' => '09:00', 'closes' => '12:00'];
$hours['Sunday'] = ['opens' => '', 'closes' => ''];
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'legalName' => 'Elektro Bernhard GmbH', 'street' => 'Ooser Hauptstr. 51', 'city' => 'Baden-Baden', 'localBusiness' => '1',
'hours' => $hours, 'areaServed' => "Baden-Baden\nRastatt\n\nKarlsruhe, Offenburg\nRastatt"]);
preg_match('##s', $this->request('/')->getContent(), $match);
$data = json_decode(html_entity_decode($match[1], ENT_QUOTES), true);
// Gleiche Zeiten werden zu einer Angabe, der geschlossene Sonntag fehlt, Orte sind eindeutig.
self::assertSame([
['@type' => 'OpeningHoursSpecification', 'dayOfWeek' => ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'], 'opens' => '07:30', 'closes' => '17:00'],
['@type' => 'OpeningHoursSpecification', 'dayOfWeek' => ['Saturday'], 'opens' => '09:00', 'closes' => '12:00'],
], $data['openingHoursSpecification']);
self::assertSame(['Baden-Baden', 'Rastatt', 'Karlsruhe', 'Offenburg'], $data['areaServed']);
}
public function testReSavingLoadedSettingsKeepsHoursAndServiceArea(): void
{
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'hours' => ['Monday' => ['opens' => '07:30', 'closes' => '17:00']], 'areaServed' => 'Bühl
Achern']);
// So gibt das Anlegen der Rechtsseiten die Einstellungen zurück: geladen, unverändert.
$loaded = $this->site->load();
$this->site->save([...$loaded, 'sameAs' => '', 'consentGroups' => $loaded['consentGroups']]);
self::assertSame([['day' => 'Monday', 'opens' => '07:30', 'closes' => '17:00']], $this->site->load()['openingHours']);
self::assertSame(['Bühl', 'Achern'], $this->site->load()['areaServed']);
}
/** @return iterable}> */
public static function refusedOpeningHours(): iterable
{
yield 'closes before opens' => [['Monday' => ['opens' => '17:00', 'closes' => '07:30']]];
yield 'not a time' => [['Monday' => ['opens' => '7.30', 'closes' => '17:00']]];
yield 'only one half' => [['Monday' => ['opens' => '07:30', 'closes' => '']]];
}
#[DataProvider('refusedOpeningHours')]
public function testOpeningHoursMustBeAPairOfTimes(array $hours): void
{
$this->expectException(InvalidPage::class);
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'hours' => $hours]);
}
public function testAnAccordionMarkedAsFaqBecomesAFaqPage(): void
{
$document = DemoContent::starter('Fragen');
$document['schemaVersion'] = 23;
$item = static fn (string $id, string $q, string $a): array => ['id' => $id, 'type' => 'accordionitem', 'props' => ['title' => $q, 'open' => false], 'children' => '' === $a ? [] : [['id' => $id.'-t', 'type' => 'text', 'props' => ['text' => $a]]]];
$document['blocks'][] = ['id' => 'faq', 'type' => 'accordion', 'props' => ['faq' => true], 'children' => [
$item('f1', 'Wo sind Sie im Einsatz?', 'In Baden-Baden & Umgebung.'),
$item('f2', 'Ohne Antwort?', ''),
]];
$document['blocks'][] = ['id' => 'plain', 'type' => 'accordion', 'children' => [$item('p1', 'Kein FAQ', 'Nur ein Akkordeon.')]];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
preg_match_all('##s', $this->request('/')->getContent(), $matches);
$faq = array_values(array_filter(array_map(static fn (string $json): array => json_decode(html_entity_decode($json, ENT_QUOTES), true), $matches[1]), static fn (array $d): bool => 'FAQPage' === $d['@type']));
self::assertCount(1, $faq);
// Nur das gekennzeichnete Akkordeon, nur Einträge mit Antwort, ohne Auszeichnungen im Text.
self::assertSame([['@type' => 'Question', 'name' => 'Wo sind Sie im Einsatz?', 'acceptedAnswer' => ['@type' => 'Answer', 'text' => 'In Baden-Baden & Umgebung.']]], $faq[0]['mainEntity']);
// Ohne Schema v23 wird die Kennzeichnung abgewiesen.
$document['schemaVersion'] = 22;
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 1);
}
public function testASocialProfileMustBeAnHttpsAddress(): void
{
$this->expectException(InvalidPage::class);
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'social' => ['facebook' => 'http://facebook.com/x']]);
}
#[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}> */
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('Datenschutz', $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 testTheConsentQuestionTakesLayoutShapeAndColoursFromTheSettings(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
$this->askConsent();
// Standard: Karte, abgerundet, Themefarben - im Kopf stehen nur die Radien.
$html = $this->request('/')->getContent();
self::assertStringContainsString('consent-layout-karte', $html);
self::assertStringContainsString('--kant-consent-radius:18px;--kant-consent-button-radius:10px', $html);
self::assertStringNotContainsString('--kant-consent-bg', $html);
self::assertStringContainsString('Externe Inhalte und Einwilligung', $html);
// Eigene Werte: Palette wird zur Variablen, ein unbekannter Schlüssel fällt weg statt
// auf nichts zu zeigen, die Überschrift steht escaped im Markup.
$this->site->save(['homePath' => 'home', 'consentBanner' => '1', 'consentGroups' => ['external'], 'privacyPath' => 'datenschutz',
'consentLayout' => 'leiste', 'consentShape' => 'rund', 'consentTitle' => 'Cookies & Co.',
'consentColor' => '#101820', 'consentInk' => '#ffffff', 'consentAccent' => '@ink', 'consentAccentInk' => '@nope']);
$response = $this->request('/');
$html = $response->getContent();
self::assertStringContainsString('consent-layout-leiste', $html);
self::assertStringContainsString('--kant-consent-radius:28px;--kant-consent-button-radius:999px;--kant-consent-bg:#101820;--kant-consent-ink:#ffffff;--kant-consent-accent:var(--k-ink)}', $html);
self::assertStringNotContainsString('--kant-consent-accent-ink', $html);
self::assertStringContainsString('Cookies & Co.', $html);
self::assertStringContainsString("style-src 'self' 'sha256-", (string) $response->headers->get('Content-Security-Policy'));
// Die Einwilligungsseite trägt dieselben Werte, erlaubt per Hash und sonst nichts.
$page = $this->request('/einwilligung');
self::assertSame(200, $page->getStatusCode());
self::assertStringContainsString('--kant-consent-bg:#101820', $page->getContent());
$policy = (string) $page->headers->get('Content-Security-Policy');
self::assertStringContainsString("style-src 'self' 'sha256-", $policy);
self::assertStringContainsString("script-src 'none'", $policy);
// Eine Farbe, die weder Hex noch Palettenschlüssel ist, wird nicht gespeichert.
$this->expectException(InvalidPage::class);
$this->site->save(['homePath' => 'home', 'consentBanner' => '1', 'consentGroups' => ['external'], 'privacyPath' => 'datenschutz', 'consentColor' => 'red']);
}
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 */
public static function consentCookies(): iterable
{
yield 'granted' => ['1|external|'.time(), true];
yield 'refused' => ['1||'.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('', '', $cookie)]);
$html = $response->getContent();
$policy = (string) $response->headers->get('Content-Security-Policy');
if ($expected) {
self::assertStringContainsString('