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('getContent()); self::assertStringContainsString('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('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 testTheOtherSpellingOfTheHostIsSentToSiteOrigin(): void { // SITE_ORIGIN der Tests ist https://cms.test - die www-Fassung fuehrt dorthin, mit Pfad // und Abfrage, bevor irgendjemand die Datenbank fragt. $response = $this->request('http://www.cms.test/home?a=1'); self::assertSame(301, $response->getStatusCode()); self::assertSame('https://cms.test/home?a=1', $response->headers->get('Location')); // Ein fremder Host bleibt erreichbar: eine falsch eingetragene Adresse sperrt niemanden aus. self::assertSame(200, $this->request('http://vorschau.hoster.test/robots.txt')->getStatusCode()); } 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', $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 */ 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('', $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 */ public static function richTextCases(): iterable { yield 'keeps allowed formatting' => ['Ganz fett und kursiv.', 'Ganz fett und kursiv.']; yield 'keeps lists' => ['
  • eins
  • zwei
', '
  • eins
  • zwei
']; yield 'drops a script and keeps the text after it' => ['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)}']; // Zweimal derselbe Selektor ist eine Kaskade, kein Widerruf: vorher gewann die letzte // Regel, und was nur in der ersten stand, verschwand ohne Meldung. yield 'same selector twice' => ['& .card { border: 0 } & .card { transform: none }', '#b1 .card{border:0;transform:none}']; yield 'element rule after a nested one' => ['& { color: red } display: flex', '#b1{display:flex;color:red}']; // Ein Pseudo-Element braucht content: leer fuer Striche und Verlaeufe, ein Zeichen fuer ein Wasserzeichen. yield 'empty content' => ["&::after { content: ''; height: 2px }", "#b1::after{content:'';height:2px}"]; yield 'short content' => ['&::before { content: "K" }', '#b1::before{content:"K"}']; } #[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']; yield 'content leaving the style tag' => ['content: "', $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); } public function testImageCornersComeFromTheDesignAreaAndCanBeSquare(): void { // Die Ecken jedes Bildes standen fest im Stylesheet: sichtbar gerundet, in keinem Feld // zu finden, nur durch einen eigenen Wert am Block loszuwerden. Jetzt gehoeren sie dem // Designbereich - mit derselben Vorgabe wie vorher, damit sich nichts veraendert. $theme = $this->themes->load(); self::assertSame('12px', $theme['base']['imageRadius'], 'Die Vorgabe bleibt, was vorher im Stylesheet stand.'); $document = DemoContent::starter('Mit Bild'); $document['blocks'][0]['children'][] = ['id' => 'bild', 'type' => 'image', 'props' => ['src' => '/assets/image-placeholder.svg', 'alt' => 'Platzhalter', 'decorative' => false, 'width' => 1000, 'height' => 600]]; $id = $this->publishedPage('ecken', 'Ecken'); $stand = $this->pages->findForEditor($id); $this->pages->publish($id, $this->pages->saveDraft($id, $document, $stand['version']), $this->pages->findForEditor($id)['version']); self::assertStringContainsString('--k-image-radius:12px', $this->request('/ecken')->getContent()); // Eckig, wie es eine Vorlage ohne Rundungen braucht. $this->themes->save($theme['colors'], $theme['fonts'], [...$theme['base'], 'imageRadius' => '0px']); self::assertStringContainsString('--k-image-radius:0px', $this->request('/ecken')->getContent()); // Und was keine Groessenangabe ist, wird abgewiesen - wie bei der Inhaltsbreite. try { $this->themes->save($theme['colors'], $theme['fonts'], [...$theme['base'], 'imageRadius' => 'rund']); self::fail('„rund“ ist keine Groessenangabe.'); } catch (InvalidPage) { } } public function testSavingTextTwiceKeepsAmpersandsAndLineBreaksAsTheyAre(): void { // Umut: „Beim Startseitenbild wird & umgewandelt, aber nach dem Speichern steht & // da.“ Der Editor schickt HTML - auch ein Text ohne ein einziges Tag. Wurde der nur // escaped statt vorher dekodiert, wuchs die Entity bei jedem Speichern um eine Stufe, // bis sie sichtbar auf der Seite stand. Dasselbe galt fuer den Zeilenumbruch: nl2br() // liess das \n hinter dem
stehen, und der zweite Durchlauf machte zwei daraus. $text = new RichText(); foreach ([ 'Freudenstadt & Umgebung' => 'Freudenstadt & Umgebung', 'Haus & Hof' => 'Haus & Hof', '5 < 7 & mehr' => '5 < 7 & mehr', "Zeile eins\nZeile zwei" => 'Zeile eins
Zeile zwei', '

A & B

' => '

A & B

', '<b>woertlich</b>' => '<b>woertlich</b>', ] as $eingabe => $erwartet) { $einmal = $text->sanitise($eingabe); self::assertSame($erwartet, $einmal, $eingabe); // Und jedes weitere Speichern laesst es, wie es ist. self::assertSame($einmal, $text->sanitise($einmal), 'zweites Speichern: '.$eingabe); self::assertSame($einmal, $text->sanitise($text->sanitise($einmal)), 'drittes Speichern: '.$eingabe); } // Und durch das ganze Dokument, so wie es beim Speichern einer Seite passiert. $document = DemoContent::starter('Kaufmaennisches Und'); $document['schemaVersion'] = 17; $document['blocks'][0]['children'][0]['props']['text'] = 'Freudenstadt & Umgebung'; $document['blocks'][0]['children'][1]['props']['text'] = 'Immobilien & Verwaltung'; $id = $this->publishedPage('und', 'Und'); for ($runde = 0; $runde < 3; ++$runde) { $stand = $this->pages->findForEditor($id); $this->pages->publish($id, $this->pages->saveDraft($id, $document, $stand['version']), $this->pages->findForEditor($id)['version']); $document = $this->pages->findForEditor($id)['document']; } $html = $this->request('/und')->getContent(); self::assertStringContainsString('Freudenstadt & Umgebung', $html); self::assertStringContainsString('Immobilien & Verwaltung', $html); self::assertStringNotContainsString('&amp;', $html, 'Nach drei Speichervorgaengen steht immer noch ein einziges Und da.'); } public function testOwnHtmlReachesThePageUnchangedAndRefusesScriptAndFrame(): void { // Der Baustein „Eigenes HTML“ (Schema v24): Markup des Betreibers, unverändert auf der // Seite. Zwei Tags nimmt er nicht an, weil die Sicherheitsrichtlinie sie ohnehin nicht // ausführt bzw. nicht lädt - dafür gibt es das Skriptfeld und den Baustein „Externer // Inhalt“. $id = $this->publishedPage('angebote', 'Angebote'); $document = $this->pages->findForEditor($id)['document']; $document['schemaVersion'] = 24; $document['blocks'][0]['children'][] = ['id' => 'roh', 'type' => 'html', 'props' => ['html' => '
Mieteab 8 €/m²
']]; $revision = $this->pages->saveDraft($id, $document, $this->pages->findForEditor($id)['version']); $this->pages->publish($id, $revision, $this->pages->findForEditor($id)['version']); $html = $this->request('/angebote')->getContent(); self::assertStringContainsString('
', $html); // Unverändert heißt unverändert: keine Entschärfung, keine Umschrift. self::assertStringContainsString('
Mieteab 8 €/m²
', $html); foreach ([ '' => 'Skript', '' => 'Rahmen', '< script >alert(1)' => 'Skript mit Leerzeichen', ] as $markup => $fall) { $attempt = $document; $attempt['blocks'][0]['children'][2]['props']['html'] = $markup; try { $this->pages->saveDraft($id, $attempt, $this->pages->findForEditor($id)['version']); self::fail(sprintf('%s hätte abgewiesen werden müssen.', $fall)); } catch (InvalidDocument) { } } // Vor v24 gibt es den Baustein nicht. $alt = $document; $alt['schemaVersion'] = 23; try { $this->pages->saveDraft($id, $alt, $this->pages->findForEditor($id)['version']); self::fail('Eigenes HTML vor Schema v24 hätte abgewiesen werden müssen.'); } catch (InvalidDocument) { } } public function testOwnHtmlChangesAreComparedAgainstWhatIsStored(): void { // Dieselbe Rolle wie beim eigenen Skript: ändern darf es nur ein Administrator, und // geprüft wird gegen den gespeicherten Stand - ein Redakteur kann die Seite also weiter // speichern, ohne das HTML anfassen zu können. $code = new CustomCode(); $stored = DemoContent::starter('Mit HTML'); $stored['schemaVersion'] = 24; $stored['blocks'][0]['children'][] = ['id' => 'roh', 'type' => 'html', 'props' => ['html' => '

Original

']]; $unchanged = $stored; $unchanged['title'] = 'Anderer Titel'; $code->assertHtmlUnchanged($unchanged, $stored); foreach (['geändert' => '

Fremd

', 'geleert' => ''] as $fall => $markup) { $attempt = $stored; $attempt['blocks'][0]['children'][2]['props']['html'] = $markup; try { $code->assertHtmlUnchanged($attempt, $stored); self::fail(sprintf('HTML %s: hätte abgewiesen werden müssen.', $fall)); } catch (InvalidDocument) { } } // Und neu hinzufügen ebenso wenig. $ohne = DemoContent::starter('Ohne HTML'); $ohne['schemaVersion'] = 24; $this->expectException(InvalidDocument::class); $code->assertHtmlUnchanged($stored, $ohne); } /** 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('##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('