4510 lines
265 KiB
PHP
4510 lines
265 KiB
PHP
<?php
|
||
|
||
namespace App\Tests;
|
||
|
||
use App\Content\Consent;
|
||
use App\Content\CustomCode;
|
||
use App\Content\DemoContent;
|
||
use App\Content\DocumentLibrary;
|
||
use App\Content\DocumentValidator;
|
||
use App\Content\FontLibrary;
|
||
use App\Content\FormRepository;
|
||
use App\Content\MediaLibrary;
|
||
use App\Content\MenuRepository;
|
||
use App\Content\NotFoundPage;
|
||
use App\Content\PageRepository;
|
||
use App\Content\PartialRepository;
|
||
use App\Content\PostLayouts;
|
||
use App\Content\PostRepository;
|
||
use App\Content\RedirectRepository;
|
||
use App\Content\RichText;
|
||
use App\Content\SiteSettings;
|
||
use App\Content\StyleBundle;
|
||
use App\Content\StyleCompiler;
|
||
use App\Content\StyleSchema;
|
||
use App\Content\Theme;
|
||
use App\Content\Turnstile;
|
||
use App\Domain\InvalidDocument;
|
||
use App\Domain\InvalidMenu;
|
||
use App\Domain\InvalidPage;
|
||
use App\Domain\StaleWrite;
|
||
use App\Infrastructure\ConnectionFactory;
|
||
use App\Kernel;
|
||
use Doctrine\DBAL\Connection;
|
||
use PHPUnit\Framework\Attributes\DataProvider;
|
||
use PHPUnit\Framework\TestCase;
|
||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||
use Symfony\Component\HttpFoundation\Request;
|
||
use Symfony\Component\HttpFoundation\Response;
|
||
|
||
final class CmsTest extends TestCase
|
||
{
|
||
private Connection $db;
|
||
private PageRepository $pages;
|
||
private MenuRepository $menus;
|
||
private PostRepository $posts;
|
||
private SiteSettings $site;
|
||
private RedirectRepository $redirects;
|
||
private Theme $themes;
|
||
private PartialRepository $partials;
|
||
private string $path;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
$this->path = tempnam(sys_get_temp_dir(), 'kant-test-');
|
||
$this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->path));
|
||
TestSchema::build($this->db);
|
||
$this->db->insert('website', ['id' => 'default', 'name' => 'KantSites', 'default_locale' => 'de', 'theme' => '{}']);
|
||
foreach (['page-1' => 'home', 'page-2' => 'private'] as $id => $slug) {
|
||
$this->db->insert('page', ['id' => $id, 'slug' => $slug, 'path' => $slug, 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]);
|
||
}
|
||
$this->pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
|
||
$this->menus = new MenuRepository($this->db, new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
|
||
$this->posts = new PostRepository($this->db, new MediaLibrary($this->db, $this->path.'-media'), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
|
||
$this->site = new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media'));
|
||
$this->redirects = new RedirectRepository($this->db);
|
||
$this->themes = new Theme($this->db);
|
||
$this->partials = new PartialRepository($this->db, new DocumentValidator());
|
||
// A real installation has a header and a footer; the navigation lives in the header area.
|
||
foreach (DemoContent::areas() as $kind => $area) {
|
||
$this->partials->create($kind, 'default', $area['name'], $area['document']);
|
||
}
|
||
}
|
||
|
||
protected function tearDown(): void
|
||
{
|
||
$this->db->close();
|
||
gc_collect_cycles();
|
||
// Each request() boots its own kernel with its own connection to the same file. On Windows
|
||
// the handle is sometimes released a moment after the kernel is gone, so a single unlink
|
||
// can fail on a database that is otherwise finished with. Retry briefly, then leave the
|
||
// temporary file to the operating system rather than failing a passing test.
|
||
for ($attempt = 0; $attempt < 20 && file_exists($this->path); ++$attempt) {
|
||
if (@unlink($this->path)) {
|
||
return;
|
||
}
|
||
usleep(25000);
|
||
}
|
||
}
|
||
|
||
public function testEditorPaginationAndChoicesKeepTypesFiltersAndParentLookup(): void
|
||
{
|
||
$ids = [];
|
||
for ($i = 0; $i < 43; ++$i) {
|
||
$document = DemoContent::pages()['home'];
|
||
$document['title'] = 'Eintrag '.sprintf('%02d', $i);
|
||
$ids[] = $this->pages->createPage('eintrag-'.sprintf('%02d', $i), null, 'post', $document);
|
||
}
|
||
$first = $this->pages->editorPage('post');
|
||
$second = $this->pages->editorPage('post', 2);
|
||
self::assertSame(43, $first['total']);
|
||
self::assertCount(20, $first['items']);
|
||
self::assertCount(20, $second['items']);
|
||
self::assertSame([], array_intersect(array_column($first['items'], 'id'), array_column($second['items'], 'id')));
|
||
self::assertCount(3, $this->pages->editorPage('post', 999)['items']);
|
||
self::assertSame(3, $this->pages->editorPage('post', 999)['page']);
|
||
self::assertCount(43, $this->pages->editorPage('post', -1, 50)['items']);
|
||
self::assertSame(2, $this->pages->editorPage('page')['total']);
|
||
self::assertSame(1, $this->pages->editorPage('post', query: 'Eintrag 42')['total']);
|
||
self::assertSame(0, $this->pages->editorPage('post', query: '%')['total'], 'LIKE wildcards are literal search text.');
|
||
self::assertSame(0, $this->pages->editorPage('post', query: '_')['total']);
|
||
$saved = $this->pages->findForEditor($ids[0]);
|
||
$this->pages->publish($ids[0], $saved['draft_revision_id'], $saved['version']);
|
||
self::assertSame(1, $this->pages->editorPage('post', status: 'published')['total']);
|
||
self::assertSame(42, $this->pages->editorPage('post', status: 'draft')['total']);
|
||
$this->pages->saveDraft($ids[0], DemoContent::pages()['home'], $saved['version'] + 1);
|
||
self::assertSame(1, $this->pages->editorPage('post', status: 'changed')['total']);
|
||
$this->db->update('page', ['deleted_at' => gmdate('Y-m-d H:i:s')], ['id' => $ids[42]]);
|
||
self::assertSame(42, $this->pages->editorPage('post')['total']);
|
||
self::assertSame(1, $this->pages->editorPage('post', status: 'trash')['total']);
|
||
self::assertCount(20, $this->pages->choices(type: 'post'));
|
||
self::assertSame([], $this->pages->choices(id: $ids[42]));
|
||
self::assertCount(2, $this->pages->choices(parentsOnly: true));
|
||
self::assertSame('page-2', $this->pages->choices(id: 'page-2', parentsOnly: true)[0]['id']);
|
||
self::assertSame([], $this->pages->choices(id: $ids[1], parentsOnly: true));
|
||
}
|
||
|
||
public function testDocumentRoundTripAndDraftPrivacy(): void
|
||
{
|
||
$document = DemoContent::pages()['home'];
|
||
$id = $this->pages->saveDraft('page-1', $document, 0);
|
||
self::assertNull($this->pages->findPublished('home'));
|
||
self::assertSame($document, json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$id]), true));
|
||
$this->pages->publish('page-1', $id, 1);
|
||
self::assertSame($document, $this->pages->findPublished('home')['document']);
|
||
$document['title'] = 'Private change';
|
||
$this->pages->saveDraft('page-1', $document, 2);
|
||
self::assertNotSame('Private change', $this->pages->findPublished('home')['document']['title']);
|
||
}
|
||
|
||
public function testStaleSaveRollsBackWithoutOrphanRevision(): void
|
||
{
|
||
$this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0);
|
||
try {
|
||
$this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0);
|
||
self::fail('Stale write was accepted.');
|
||
} catch (StaleWrite) {
|
||
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision'));
|
||
self::assertSame(1, (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['page-1']));
|
||
}
|
||
}
|
||
|
||
public function testForeignRevisionCannotBePublished(): void
|
||
{
|
||
$id = $this->pages->saveDraft('page-2', DemoContent::pages()['home'], 0);
|
||
$this->expectException(\InvalidArgumentException::class);
|
||
$this->pages->publish('page-1', $id, 0);
|
||
}
|
||
|
||
public function testPublicationRejectsStaleVersion(): void
|
||
{
|
||
$id = $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0);
|
||
$this->expectException(StaleWrite::class);
|
||
$this->pages->publish('page-1', $id, 0);
|
||
}
|
||
|
||
public function testPublishedRevisionCanBeRestoredAndImmediatelyChangesOutput(): void
|
||
{
|
||
$original = DemoContent::pages()['home'];
|
||
$first = $this->pages->saveDraft('page-1', $original, 0);
|
||
$this->pages->publish('page-1', $first, 1);
|
||
$changed = $original;
|
||
$changed['title'] = 'Second revision';
|
||
$second = $this->pages->saveDraft('page-1', $changed, 2);
|
||
$this->pages->publish('page-1', $second, 3);
|
||
self::assertSame('Second revision', $this->pages->findPublished('home')['document']['title']);
|
||
$this->pages->publish('page-1', $first, 4);
|
||
self::assertSame($original, $this->pages->findPublished('home')['document']);
|
||
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision'));
|
||
}
|
||
|
||
public static function invalidDocuments(): iterable
|
||
{
|
||
$base = DemoContent::pages()['home'];
|
||
$bad = $base;
|
||
$bad['schemaVersion'] = 999;
|
||
yield 'future schema' => [$bad];
|
||
$bad = $base;
|
||
$bad['blocks'][] = $base['blocks'][0];
|
||
yield 'duplicate IDs' => [$bad];
|
||
foreach (['javascript:alert(1)', 'data:text/html,hi', '//evil.test', '/\\evil.test', "https://example.test/\nfoo"] as $url) {
|
||
$bad = $base;
|
||
$bad['blocks'][0]['children'][3]['props']['href'] = $url;
|
||
yield $url => [$bad];
|
||
}
|
||
$bad = $base;
|
||
$bad['blocks'][0]['layout']['tone'] = '" onclick="alert(1)';
|
||
yield 'style injection' => [$bad];
|
||
foreach ([['desktop' => ['paddingY' => 'url(evil)']], ['watch' => []], ['mobile' => ['columns' => 'two']], ['desktop' => ['unknown' => 'small']], ['mobile' => ['paddingY' => []]]] as $design) {
|
||
$bad = $base;
|
||
$bad['schemaVersion'] = 2;
|
||
$bad['blocks'][0]['layout']['design'] = $design;
|
||
yield 'invalid responsive design '.json_encode($design) => [$bad];
|
||
}
|
||
$bad = $base;
|
||
$bad['blocks'][0]['layout']['design'] = ['desktop' => ['paddingY' => 'large']];
|
||
yield 'v1 cannot silently use v2 settings' => [$bad];
|
||
foreach ([2, 3] as $version) {
|
||
$bad = $base;
|
||
$bad['schemaVersion'] = $version;
|
||
$bad['blocks'][0]['layout']['effects'] = ['shadow' => 2 === $version ? 'soft' : 'evil'];
|
||
yield 'invalid effect version '.$version => [$bad];
|
||
}
|
||
$bad = $base;
|
||
$bad['schemaVersion'] = 3;
|
||
$bad['blocks'][] = ['id' => 'gallery', 'type' => 'gallery', 'children' => [$base['blocks'][0]]];
|
||
yield 'gallery forbids containers' => [$bad];
|
||
$bad = $base;
|
||
$bad['blocks'][] = ['id' => 'gallery', 'type' => 'gallery', 'children' => []];
|
||
yield 'gallery requires v3' => [$bad];
|
||
foreach (['javascript:alert(1)', '/image,400w'] as $source) {
|
||
$bad = $base;
|
||
$bad['schemaVersion'] = 3;
|
||
$bad['blocks'][] = ['id' => 'unsafe-image', 'type' => 'image', 'props' => ['src' => '/safe.png', 'alt' => 'Test', 'decorative' => false, 'width' => 400, 'height' => 300, 'sources' => [['src' => $source, 'width' => 400]]]];
|
||
yield 'unsafe srcset '.$source => [$bad];
|
||
}
|
||
$bad = $base;
|
||
$bad['blocks'][0]['children'][1]['props']['level'] = 9;
|
||
yield 'invalid heading' => [$bad];
|
||
$bad = $base;
|
||
$bad['blocks'][0]['children'][0]['type'] = 'unknown-plugin';
|
||
yield 'unnamespaced unknown type stays rejected' => [$bad];
|
||
foreach (['Acme.Slider', 'acme.', '.slider', 'acme..slider', 'acme.slider.extra', 'acme/slider'] as $type) {
|
||
$bad = $base;
|
||
$bad['schemaVersion'] = 4;
|
||
$bad['blocks'][0]['children'][0]['type'] = $type;
|
||
yield 'malformed plugin type '.$type => [$bad];
|
||
}
|
||
$bad = $base;
|
||
$bad['blocks'][0]['children'][0]['type'] = 'acme.slider';
|
||
yield 'plugin block needs schema v4' => [$bad];
|
||
$bad = $base;
|
||
$bad['blocks'][0]['children'][] = ['id' => 'image', 'type' => 'image', 'props' => ['src' => '/image.png', 'alt' => '', 'decorative' => false, 'width' => 100, 'height' => 100]];
|
||
yield 'missing meaningful alt' => [$bad];
|
||
}
|
||
|
||
#[DataProvider('invalidDocuments')]
|
||
public function testRejectsInvalidDocumentsBeforeWriting(array $document): void
|
||
{
|
||
$this->expectException(InvalidDocument::class);
|
||
$this->pages->saveDraft('page-1', $document, 0);
|
||
}
|
||
|
||
public function testInitialHtmlEscapesContentAndHasNoEditorScripts(): void
|
||
{
|
||
$document = DemoContent::pages()['home'];
|
||
$document['blocks'][0]['children'][0]['props']['text'] = '<script>alert("xss")</script>';
|
||
$id = $this->pages->saveDraft('page-1', $document, 0);
|
||
$this->pages->publish('page-1', $id, 1);
|
||
$response = $this->request('/');
|
||
self::assertSame(200, $response->getStatusCode());
|
||
self::assertStringContainsString('<script>', $response->getContent());
|
||
self::assertStringNotContainsString('<script', $response->getContent());
|
||
self::assertStringContainsString('<h1', $response->getContent());
|
||
self::assertStringContainsString('rel="canonical" href="https://cms.test/"', $response->getContent());
|
||
self::assertStringContainsString('no-store', $response->headers->get('Cache-Control'));
|
||
}
|
||
|
||
public function testResponsiveRevisionPreservesOriginalAndRendersSafeClasses(): void
|
||
{
|
||
$original = DemoContent::pages()['home'];
|
||
$old = $this->pages->saveDraft('page-1', $original, 0);
|
||
$this->pages->publish('page-1', $old, 1);
|
||
$document = $original;
|
||
$document['schemaVersion'] = 2;
|
||
$document['blocks'][0]['layout']['design'] = ['desktop' => ['paddingY' => 'huge'], 'mobile' => ['paddingY' => 'small']];
|
||
$document['blocks'][0]['children'][1]['layout']['design'] = ['desktop' => ['fontSize' => 'hero'], 'mobile' => ['fontSize' => 'title']];
|
||
$new = $this->pages->saveDraft('page-1', $document, 2);
|
||
self::assertSame($original, $this->pages->findPublished('home')['document']);
|
||
self::assertSame($document, $this->pages->findForEditor('page-1')['document']);
|
||
$this->pages->publish('page-1', $new, 3);
|
||
$html = $this->request('/')->getContent();
|
||
self::assertStringContainsString('d-mobile-paddingY-small', $html);
|
||
self::assertStringContainsString('d-desktop-fontSize-hero d-mobile-fontSize-title', $html);
|
||
self::assertStringContainsString('/assets/design.css', $html);
|
||
self::assertStringNotContainsString('style=', $html);
|
||
self::assertStringNotContainsString('<script', $html);
|
||
$this->pages->publish('page-1', $old, 4);
|
||
self::assertSame($original, $this->pages->findPublished('home')['document']);
|
||
self::assertStringNotContainsString('/assets/design.css', $this->request('/')->getContent());
|
||
}
|
||
|
||
public function testDraftAndUnknownUrlsAre404AndWritesAreNotExposed(): void
|
||
{
|
||
$this->pages->saveDraft('page-2', DemoContent::pages()['home'], 0);
|
||
foreach (['/private', '/missing', '/editor'] as $url) {
|
||
self::assertSame(404, $this->request($url)->getStatusCode());
|
||
}
|
||
self::assertSame(405, $this->request('/', 'POST')->getStatusCode());
|
||
}
|
||
|
||
public function testDefault404UsesTheNormalAreasAndThemeForEveryPublicUrlShape(): void
|
||
{
|
||
foreach (['/missing', '/Missing/old.html', '/eins/zwei/drei/vier/fuenf', '/__404-vorschau__', '/kategorie/missing'] as $path) {
|
||
$response = $this->request($path);
|
||
self::assertSame(404, $response->getStatusCode(), $path);
|
||
self::assertSame('noindex, follow', $response->headers->get('X-Robots-Tag'));
|
||
self::assertFalse($response->headers->has('Location'));
|
||
$html = $response->getContent();
|
||
self::assertStringContainsString('Seite nicht gefunden', $html);
|
||
self::assertStringContainsString('class="site-header', $html);
|
||
self::assertStringContainsString('class="site-footer', $html);
|
||
self::assertStringContainsString('--k-', $html);
|
||
self::assertStringNotContainsString('rel="canonical"', $html);
|
||
self::assertStringNotContainsString('application/ld+json', $html);
|
||
}
|
||
$head = $this->request('/Missing/old.html', 'HEAD');
|
||
self::assertSame(404, $head->getStatusCode());
|
||
self::assertSame('', $head->getContent());
|
||
}
|
||
|
||
public function testCustom404KeepsDraftPrivateAndSurvivesRenamingAndFallsBackAfterDeletion(): void
|
||
{
|
||
$document = NotFoundPage::document();
|
||
$document['blocks'][0]['children'][1]['props']['text'] = 'Unser individueller Fehlerhinweis';
|
||
$id = $this->pages->createPage('fehlerseite', null, 'page', $document);
|
||
$this->site->assignNotFoundPage($id, '');
|
||
self::assertStringNotContainsString('Unser individueller Fehlerhinweis', $this->request('/unbekannt')->getContent());
|
||
$page = $this->pages->findForEditor($id);
|
||
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
|
||
$this->pages->saveAreas($id, 'none', 'none', 2);
|
||
foreach (['/unbekannt', '/fehlerseite', '/alte.Datei.html'] as $url) {
|
||
$response = $this->request($url);
|
||
self::assertSame(404, $response->getStatusCode());
|
||
self::assertStringContainsString('Unser individueller Fehlerhinweis', $response->getContent());
|
||
self::assertStringContainsString('class="site-header', $response->getContent());
|
||
self::assertStringContainsString('class="site-footer', $response->getContent());
|
||
}
|
||
$this->site->save(['homePath' => 'home', 'llmsTxt' => true]);
|
||
self::assertSame($id, $this->site->load()['notFoundPageId']);
|
||
self::assertStringNotContainsString('/fehlerseite', $this->request('/sitemap.xml')->getContent());
|
||
self::assertStringNotContainsString('/fehlerseite', $this->request('/llms.txt')->getContent());
|
||
$this->pages->movePage($id, 'fehler-umbenannt', null, 3, $this->redirects);
|
||
self::assertStringContainsString('Unser individueller Fehlerhinweis', $this->request('/weiterhin-falsch')->getContent());
|
||
$document['blocks'][0]['children'][1]['props']['text'] = 'Noch geheimer Entwurf';
|
||
$page = $this->pages->findForEditor($id);
|
||
$revision = $this->pages->saveDraft($id, $document, $page['version']);
|
||
self::assertStringNotContainsString('Noch geheimer Entwurf', $this->request('/weiterhin-falsch')->getContent());
|
||
$page = $this->pages->findForEditor($id);
|
||
$this->pages->publish($id, $revision, $page['version']);
|
||
self::assertStringContainsString('Noch geheimer Entwurf', $this->request('/weiterhin-falsch')->getContent());
|
||
$page = $this->pages->findForEditor($id);
|
||
$this->pages->trashPage($id, $page['version']);
|
||
self::assertStringContainsString('Seite nicht gefunden', $this->request('/weiterhin-falsch')->getContent());
|
||
self::assertStringNotContainsString('Noch geheimer Entwurf', $this->request('/weiterhin-falsch')->getContent());
|
||
}
|
||
|
||
public function test404CreationPreservesExistingPagesAndRejectsStaleAssignments(): void
|
||
{
|
||
$existing = $this->pages->createPage('404', null, 'page', DemoContent::starter('Vorhandene Seite'));
|
||
$service = new NotFoundPage($this->site, $this->pages, $this->db);
|
||
$id = $service->create('');
|
||
$created = $this->pages->findForEditor($id);
|
||
self::assertSame('404-2', $created['path']);
|
||
self::assertNull($created['published_revision_id']);
|
||
self::assertSame('noindex', $created['robots']);
|
||
self::assertSame('Vorhandene Seite', $this->pages->findForEditor($existing)['document']['title']);
|
||
$count = $this->db->fetchOne('SELECT COUNT(*) FROM page');
|
||
try {
|
||
$service->create('');
|
||
self::fail('Repeated creation must not create another page.');
|
||
} catch (InvalidPage) {
|
||
self::assertSame($count, $this->db->fetchOne('SELECT COUNT(*) FROM page'));
|
||
}
|
||
try {
|
||
$this->site->assignNotFoundPage('', '');
|
||
self::fail('A stale settings form must not reset a newer assignment.');
|
||
} catch (StaleWrite) {
|
||
self::assertSame($id, $this->site->load()['notFoundPageId']);
|
||
}
|
||
foreach (['page-1', 'missing', $this->pages->createPage('beitrag', null, 'post', DemoContent::starter('Beitrag', 'post'))] as $invalid) {
|
||
try {
|
||
$this->site->assignNotFoundPage($invalid, $id);
|
||
self::fail('The home page, posts and missing pages cannot serve as the 404 page.');
|
||
} catch (InvalidPage) {
|
||
self::assertSame($id, $this->site->load()['notFoundPageId']);
|
||
}
|
||
}
|
||
$this->site->assignNotFoundPage('', $id);
|
||
self::assertSame('', $this->site->load()['notFoundPageId']);
|
||
}
|
||
|
||
public function testCustom404DoesNotReplaceRedirectsGoneResourcesOrAdminEndpoints(): void
|
||
{
|
||
$document = NotFoundPage::document();
|
||
$document['title'] = 'CUSTOM404MARKER';
|
||
$id = $this->pages->createPage('fehler', null, 'page', $document);
|
||
$page = $this->pages->findForEditor($id);
|
||
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
|
||
$this->site->assignNotFoundPage($id, '');
|
||
$this->redirects->save('umgezogen', 'home', 301);
|
||
$this->redirects->save('entfernt', '', 410);
|
||
self::assertSame(301, $this->request('/umgezogen')->getStatusCode());
|
||
self::assertSame(410, $this->request('/entfernt')->getStatusCode());
|
||
self::assertStringNotContainsString('CUSTOM404MARKER', $this->request('/entfernt')->getContent());
|
||
foreach (['/assets/missing.js', '/media/missing', '/admin/api/missing', '/missing.json', '/llms.txt'] as $url) {
|
||
self::assertStringNotContainsString('CUSTOM404MARKER', $this->request($url)->getContent(), $url);
|
||
}
|
||
}
|
||
|
||
public function testHomeAliasRedirectsToCanonical(): void
|
||
{
|
||
$response = $this->request('/home');
|
||
self::assertSame(301, $response->getStatusCode());
|
||
self::assertSame('/', $response->headers->get('Location'));
|
||
}
|
||
|
||
public function 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</h1>', $response->getContent());
|
||
self::assertStringContainsString('href="https://cms.test/ueber-uns"', $response->getContent());
|
||
}
|
||
|
||
public function testChildPagesBuildNestedPathsAndSiblingSlugsStayIndependent(): void
|
||
{
|
||
$parent = $this->pages->createPage('leistungen', null, 'page', DemoContent::starter('Leistungen'));
|
||
$child = $this->pages->createPage('webdesign', $parent, 'page', DemoContent::starter('Webdesign'));
|
||
$other = $this->pages->createPage('beratung', null, 'page', DemoContent::starter('Beratung'));
|
||
// The same slug under a different parent is a different page, not a collision.
|
||
$twin = $this->pages->createPage('webdesign', $other, 'page', DemoContent::starter('Webdesign kompakt'));
|
||
foreach ([$parent => 'leistungen', $child => 'leistungen/webdesign', $twin => 'beratung/webdesign'] as $id => $path) {
|
||
$draft = $this->pages->findForEditor($id);
|
||
$this->pages->publish($id, $draft['draft_revision_id'], $draft['version']);
|
||
self::assertSame(200, $this->request('/'.$path)->getStatusCode(), $path);
|
||
}
|
||
self::assertSame('Webdesign', $this->pages->findPublished('leistungen/webdesign')['document']['title']);
|
||
self::assertSame('Webdesign kompakt', $this->pages->findPublished('beratung/webdesign')['document']['title']);
|
||
}
|
||
|
||
/**
|
||
* @return iterable<string, array{string, string|null, string}>
|
||
*/
|
||
public static function rejectedPages(): iterable
|
||
{
|
||
yield 'uppercase slug' => ['Über Uns', null, 'page'];
|
||
yield 'path separator in slug' => ['a/b', null, 'page'];
|
||
yield 'empty slug' => ['', null, 'page'];
|
||
yield 'reserved slug' => ['admin', null, 'page'];
|
||
yield 'unknown type' => ['gueltig', null, 'template'];
|
||
yield 'missing parent' => ['gueltig', 'does-not-exist', 'page'];
|
||
}
|
||
|
||
#[DataProvider('rejectedPages')]
|
||
public function testRejectsInvalidAddressesWithoutWriting(string $slug, ?string $parent, string $type): void
|
||
{
|
||
$before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM page');
|
||
try {
|
||
$this->pages->createPage($slug, $parent, $type, DemoContent::starter('Titel'));
|
||
self::fail('Expected the page to be rejected.');
|
||
} catch (InvalidPage) {
|
||
}
|
||
self::assertSame($before, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
|
||
}
|
||
|
||
public function testDuplicateAddressAndExcessiveDepthAreRejected(): void
|
||
{
|
||
$this->pages->createPage('kontakt', null, 'page', DemoContent::starter('Kontakt'));
|
||
$this->expectException(InvalidPage::class);
|
||
$this->pages->createPage('kontakt', null, 'page', DemoContent::starter('Kontakt erneut'));
|
||
}
|
||
|
||
public function testHierarchyStopsAtTheDocumentedDepthAndPostsTakeNoChildren(): void
|
||
{
|
||
$id = null;
|
||
for ($level = 1; $level <= PageRepository::MAX_DEPTH; ++$level) {
|
||
$id = $this->pages->createPage('ebene-'.$level, $id, 'page', DemoContent::starter('Ebene '.$level));
|
||
}
|
||
try {
|
||
$this->pages->createPage('zu-tief', $id, 'page', DemoContent::starter('Zu tief'));
|
||
self::fail('Expected the depth limit to apply.');
|
||
} catch (InvalidPage) {
|
||
}
|
||
$post = $this->pages->createPage('erster-beitrag', null, 'post', DemoContent::starter('Erster Beitrag', 'post'));
|
||
$this->expectException(InvalidPage::class);
|
||
$this->pages->createPage('unter-beitrag', $post, 'page', DemoContent::starter('Unter Beitrag'));
|
||
}
|
||
|
||
public function testDeleteRemovesEveryRevisionButRefusesStaleVersionsAndParents(): void
|
||
{
|
||
$parent = $this->pages->createPage('eltern', null, 'page', DemoContent::starter('Eltern'));
|
||
$child = $this->pages->createPage('kind', $parent, 'page', DemoContent::starter('Kind'));
|
||
$version = $this->pages->findForEditor($parent)['version'];
|
||
try {
|
||
$this->pages->deletePage($parent, $version);
|
||
self::fail('Expected the parent to be protected.');
|
||
} catch (InvalidPage) {
|
||
}
|
||
self::assertNotNull($this->pages->findForEditor($parent));
|
||
|
||
$childVersion = $this->pages->findForEditor($child)['version'];
|
||
try {
|
||
$this->pages->deletePage($child, $childVersion - 1);
|
||
self::fail('Expected a stale delete to be refused.');
|
||
} catch (StaleWrite) {
|
||
}
|
||
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', [$child]));
|
||
|
||
$this->pages->deletePage($child, $childVersion);
|
||
self::assertNull($this->pages->findForEditor($child));
|
||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', [$child]));
|
||
// Deleting the child frees the parent and leaves every other page untouched.
|
||
$this->pages->deletePage($parent, $this->pages->findForEditor($parent)['version']);
|
||
self::assertSame(['home', 'private'], $this->db->fetchFirstColumn('SELECT slug FROM page ORDER BY slug'));
|
||
}
|
||
|
||
public function testCmsRoutesAreNotSwallowedByThePublicPathRoute(): void
|
||
{
|
||
// The catch-all would otherwise answer these as a missing public page with 404.
|
||
foreach (['/admin', '/admin/media'] as $url) {
|
||
self::assertSame(302, $this->request($url)->getStatusCode(), $url);
|
||
}
|
||
self::assertStringContainsString('name="_username"', $this->request('/admin/login')->getContent());
|
||
}
|
||
|
||
public function testPluginBlocksSurviveSavePublishAndReloadWithoutRenderingTheirProps(): void
|
||
{
|
||
$document = DemoContent::starter('Mit Erweiterung');
|
||
$document['schemaVersion'] = 4;
|
||
$document['blocks'][] = [
|
||
'id' => 'plugin-block', 'type' => 'acme.slider',
|
||
'props' => ['slides' => [['src' => 'javascript:alert(1)', 'headline' => 'MARKER_FROM_PLUGIN_PROPS']], 'autoplay' => true],
|
||
'children' => [['id' => 'plugin-child', 'type' => 'text', 'props' => ['text' => 'MARKER_INSIDE_PLUGIN']]],
|
||
];
|
||
$revision = $this->pages->saveDraft('page-1', $document, 0);
|
||
// Stored byte for byte: a page must never lose content because a plugin is absent.
|
||
self::assertSame($document, json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]), true));
|
||
$this->pages->publish('page-1', $revision, 1);
|
||
self::assertSame($document, $this->pages->findPublished('home')['document']);
|
||
self::assertSame($document, $this->pages->findForEditor('page-1')['document']);
|
||
|
||
$html = $this->request('/')->getContent();
|
||
self::assertSame(200, $this->request('/')->getStatusCode());
|
||
// The core knows no schema for these props, so none of them may reach the output.
|
||
self::assertStringNotContainsString('MARKER_FROM_PLUGIN_PROPS', $html);
|
||
self::assertStringNotContainsString('MARKER_INSIDE_PLUGIN', $html);
|
||
self::assertStringNotContainsString('javascript:', $html);
|
||
self::assertStringNotContainsString('block-unavailable', $html);
|
||
self::assertStringContainsString('<!-- Block plugin-block (acme.slider)', $html);
|
||
// The rest of the page still renders.
|
||
self::assertStringContainsString('>Mit Erweiterung</h1>', $html);
|
||
}
|
||
|
||
public function testMenuRendersPageAnchorAndExternalTargetsWithCurrentMarker(): void
|
||
{
|
||
$about = $this->publishedPage('ueber-uns', 'Ueber uns');
|
||
$this->menus->save('header', 'Hauptnavigation', [
|
||
['label' => 'Start', 'type' => 'page', 'pageId' => 'page-1', 'anchor' => '', 'children' => [
|
||
['label' => 'Kontakt', 'type' => 'page', 'pageId' => 'page-1', 'anchor' => 'kontakt', 'children' => []],
|
||
]],
|
||
['label' => 'Ueber uns', 'type' => 'page', 'pageId' => $about, 'anchor' => '', 'children' => []],
|
||
['label' => 'HausKlar', 'type' => 'url', 'url' => 'https://hausklar.app', 'children' => []],
|
||
['label' => 'Anrufen', 'type' => 'url', 'url' => 'tel:+4972219957150', 'children' => []],
|
||
], 0);
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||
|
||
$html = $this->request('/')->getContent();
|
||
self::assertStringContainsString('href="/#kontakt"', $html);
|
||
self::assertStringContainsString('href="/ueber-uns"', $html);
|
||
self::assertStringContainsString('href="https://hausklar.app"', $html);
|
||
self::assertStringContainsString('href="tel:+4972219957150"', $html);
|
||
// The home link is the current page; an anchor inside it is not.
|
||
// Desktop and collapsed menu carry the same marker; CSS exposes one variant at a time.
|
||
self::assertSame(2, substr_count($html, 'aria-current="page"'));
|
||
self::assertMatchesRegularExpression('~<a href="/" aria-current="page">\s*<span class="nav-label"><span>Start</span></span>\s*</a>~', $html);
|
||
|
||
// On another page the marker moves and nothing else changes.
|
||
$other = $this->request('/ueber-uns')->getContent();
|
||
self::assertMatchesRegularExpression('~<a href="/ueber-uns" aria-current="page">\s*<span class="nav-label"><span>Ueber uns</span></span>\s*</a>~', $other);
|
||
self::assertStringNotContainsString('<a href="/" aria-current="page">', $other);
|
||
}
|
||
|
||
public function testMenuSurvivesDeletedAndUnpublishedPagesWithoutLosingOtherEntries(): void
|
||
{
|
||
$doomed = $this->publishedPage('temporaer', 'Temporaer');
|
||
$draft = $this->pages->createPage('entwurf', null, 'page', DemoContent::starter('Entwurf'));
|
||
$this->menus->save('header', 'Hauptnavigation', [
|
||
['label' => 'Verschwindet', 'type' => 'page', 'pageId' => $doomed, 'anchor' => '', 'children' => [
|
||
['label' => 'Bleibt als Kind', 'type' => 'url', 'url' => 'https://example.com', 'children' => []],
|
||
]],
|
||
['label' => 'Noch Entwurf', 'type' => 'page', 'pageId' => $draft, 'anchor' => '', 'children' => []],
|
||
['label' => 'Extern', 'type' => 'url', 'url' => 'https://hausklar.app', 'children' => []],
|
||
], 0);
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||
$this->pages->deletePage($doomed, $this->pages->findForEditor($doomed)['version']);
|
||
|
||
$html = $this->request('/')->getContent();
|
||
self::assertStringNotContainsString('Verschwindet', $html);
|
||
// An unpublished page must not appear in public navigation.
|
||
self::assertStringNotContainsString('Noch Entwurf', $html);
|
||
self::assertStringNotContainsString('/entwurf', $html);
|
||
// The rest of the menu is intact, including the child of the removed branch.
|
||
self::assertStringContainsString('Bleibt als Kind', $html);
|
||
self::assertStringContainsString('href="https://hausklar.app"', $html);
|
||
// The stored items are untouched: publishing the draft brings its entry back.
|
||
self::assertCount(3, $this->menus->all()['header']['items']);
|
||
$page = $this->pages->findForEditor($draft);
|
||
$this->pages->publish($draft, $page['draft_revision_id'], $page['version']);
|
||
self::assertStringContainsString('Noch Entwurf', $this->request('/')->getContent());
|
||
}
|
||
|
||
/**
|
||
* @return iterable<string, array{array}>
|
||
*/
|
||
public static function rejectedMenuItems(): iterable
|
||
{
|
||
$page = static fn (array $extra = []): array => [...['label' => 'Eintrag', 'type' => 'page', 'pageId' => 'page-1', 'anchor' => '', 'children' => []], ...$extra];
|
||
yield 'empty label' => [[$page(['label' => ' '])]];
|
||
yield 'oversized label' => [[$page(['label' => str_repeat('a', 81)])]];
|
||
yield 'unknown type' => [[$page(['type' => 'script'])]];
|
||
yield 'missing page' => [[$page(['pageId' => ''])]];
|
||
yield 'anchor with hash' => [[$page(['anchor' => 'a#b'])]];
|
||
yield 'anchor starting with digit' => [[$page(['anchor' => '1kontakt'])]];
|
||
yield 'javascript url' => [[['label' => 'X', 'type' => 'url', 'url' => 'javascript:alert(1)', 'children' => []]]];
|
||
yield 'plain http url' => [[['label' => 'X', 'type' => 'url', 'url' => 'http://example.com', 'children' => []]]];
|
||
yield 'protocol relative url' => [[['label' => 'X', 'type' => 'url', 'url' => '//evil.example', 'children' => []]]];
|
||
yield 'third level' => [[$page(['children' => [$page(['children' => [$page()]])]])]];
|
||
}
|
||
|
||
#[DataProvider('rejectedMenuItems')]
|
||
public function testRejectsInvalidMenuItemsWithoutWriting(array $items): void
|
||
{
|
||
try {
|
||
$this->menus->save('header', 'Hauptnavigation', $items, 0);
|
||
self::fail('Expected the menu to be rejected.');
|
||
} catch (InvalidMenu) {
|
||
}
|
||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM menu'));
|
||
}
|
||
|
||
public function testMenuRejectsUnknownSlotOversizeAndStaleWrites(): void
|
||
{
|
||
$item = ['label' => 'Start', 'type' => 'page', 'pageId' => 'page-1', 'anchor' => '', 'children' => []];
|
||
try {
|
||
$this->menus->save('sidebar', 'Seitenleiste', [$item], 0);
|
||
self::fail('Expected an unknown slot to be rejected.');
|
||
} catch (InvalidMenu) {
|
||
}
|
||
try {
|
||
$this->menus->save('header', 'Hauptnavigation', array_fill(0, MenuRepository::MAX_ITEMS + 1, $item), 0);
|
||
self::fail('Expected the item limit to apply.');
|
||
} catch (InvalidMenu) {
|
||
}
|
||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM menu'));
|
||
|
||
$this->menus->save('header', 'Hauptnavigation', [$item], 0);
|
||
self::assertSame(1, $this->menus->all()['header']['version']);
|
||
$this->expectException(StaleWrite::class);
|
||
$this->menus->save('header', 'Geaendert', [$item], 0);
|
||
}
|
||
|
||
private function publishedPage(string $slug, string $title): string
|
||
{
|
||
$id = $this->pages->createPage($slug, null, 'page', DemoContent::starter($title));
|
||
$page = $this->pages->findForEditor($id);
|
||
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
|
||
|
||
return $id;
|
||
}
|
||
|
||
public function testArchiveOrdersPostsByDatePaginatesAndHidesPagesAndDrafts(): void
|
||
{
|
||
// Three published posts with distinct dates, one draft post and one ordinary page.
|
||
foreach ([['alt', '2024-01-05'], ['mittel', '2025-06-01'], ['neu', '2026-03-09']] as [$slug, $date]) {
|
||
$id = $this->publishedPost($slug, ucfirst($slug), $date);
|
||
self::assertNotNull($this->pages->findForEditor($id));
|
||
}
|
||
$this->pages->createPage('unveroeffentlicht', null, 'post', DemoContent::starter('Unveroeffentlicht', 'post'));
|
||
$this->publishedPage('eine-seite', 'Eine Seite');
|
||
|
||
$first = $this->posts->published(1, null, 2);
|
||
self::assertSame(3, $first['total']);
|
||
self::assertSame(2, $first['pages']);
|
||
self::assertSame(['Neu', 'Mittel'], array_column($first['posts'], 'title'));
|
||
self::assertSame(['Alt'], array_column($this->posts->published(2, null, 2)['posts'], 'title'));
|
||
// A page is not a post, and an unpublished post stays out of the archive.
|
||
foreach ($this->posts->published(1, null, 20)['posts'] as $post) {
|
||
self::assertNotSame('Eine Seite', $post['title']);
|
||
self::assertNotSame('Unveroeffentlicht', $post['title']);
|
||
}
|
||
// Out-of-range page numbers clamp instead of showing an empty archive.
|
||
self::assertSame(2, $this->posts->published(99, null, 2)['page']);
|
||
self::assertSame(1, $this->posts->published(-5, null, 2)['page']);
|
||
|
||
$html = $this->request('/beitraege')->getContent();
|
||
self::assertSame(200, $this->request('/beitraege')->getStatusCode());
|
||
self::assertStringContainsString('<time datetime="2026-03-09">09.03.2026</time>', $html);
|
||
self::assertStringNotContainsString('Unveroeffentlicht', $html);
|
||
self::assertStringContainsString('href="https://cms.test/beitraege"', $html);
|
||
}
|
||
|
||
public function testCategoriesAndTagsGroupPostsAndGetTheirOwnArchives(): void
|
||
{
|
||
$projekt = $this->publishedPost('kanttresor', 'KantTresor', '2026-02-01', ['Projekte'], ['WordPress', 'DSGVO']);
|
||
$this->publishedPost('andere', 'Andere', '2026-01-01', ['Notizen'], []);
|
||
|
||
$terms = $this->posts->termsFor($projekt);
|
||
self::assertSame(['Projekte'], array_column($terms['category'], 'name'));
|
||
self::assertSame(['DSGVO', 'WordPress'], array_column($terms['tag'], 'name'));
|
||
self::assertSame('/kategorie/projekte', $terms['category'][0]['href']);
|
||
self::assertSame('/schlagwort/dsgvo', $terms['tag'][0]['href']);
|
||
|
||
$category = $this->request('/kategorie/projekte');
|
||
self::assertSame(200, $category->getStatusCode());
|
||
self::assertStringContainsString('KantTresor', $category->getContent());
|
||
self::assertStringNotContainsString('Andere', $category->getContent());
|
||
|
||
self::assertStringContainsString('KantTresor', $this->request('/schlagwort/wordpress')->getContent());
|
||
self::assertSame(404, $this->request('/kategorie/gibt-es-nicht')->getStatusCode());
|
||
// A tag slug is not a category slug.
|
||
self::assertSame(404, $this->request('/kategorie/dsgvo')->getStatusCode());
|
||
|
||
// Saving metadata again replaces the assignment instead of adding to it.
|
||
$version = $this->pages->findForEditor($projekt)['version'];
|
||
$this->posts->saveMetadata($projekt, ['publishedAt' => '2026-02-01'], ['Projekte'], [], $version);
|
||
self::assertSame([], $this->posts->termsFor($projekt)['tag']);
|
||
// The term itself survives as a reusable label, but an archive with nothing to list is a
|
||
// dead end - it answers 404 until a post carries the tag again.
|
||
self::assertSame(404, $this->request('/schlagwort/wordpress')->getStatusCode());
|
||
}
|
||
|
||
public function testPostListBlockIsFilledByTheRendererAndRespectsItsFilter(): void
|
||
{
|
||
$this->publishedPost('projekt-eins', 'Projekt eins', '2026-02-01', ['Projekte'], []);
|
||
$this->publishedPost('notiz', 'Eine Notiz', '2026-02-02', ['Notizen'], []);
|
||
|
||
$document = DemoContent::starter('Startseite');
|
||
$document['schemaVersion'] = 5;
|
||
$document['blocks'][] = ['id' => 'liste', 'type' => 'postlist', 'props' => ['count' => 5, 'category' => 'projekte', 'tag' => ''], 'children' => []];
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||
|
||
$html = $this->request('/')->getContent();
|
||
self::assertStringContainsString('Projekt eins', $html);
|
||
self::assertStringNotContainsString('Eine Notiz', $html);
|
||
// The stored block carries no post data; only the filter and the count are content.
|
||
$stored = $this->pages->findPublished('home')['document'];
|
||
self::assertSame(['count' => 5, 'category' => 'projekte', 'tag' => ''], $stored['blocks'][2]['props']);
|
||
}
|
||
|
||
public function testAHeadingCanHighlightOneWordAndAStoredHeadingStaysFiltered(): void
|
||
{
|
||
// Im Original von kant-it.de ist die zweite Zeile der Hauptüberschrift rot. Dafür darf
|
||
// eine Überschrift ab v17 Auszeichnungen tragen - durch denselben Filter wie jeder Text,
|
||
// damit über diesen Weg kein Skript in eine Seite gelangt.
|
||
$document = DemoContent::starter('Hervorhebung');
|
||
$document['schemaVersion'] = 17;
|
||
$document['blocks'][0]['children'][0]['props']['text'] = 'Ihr Partner für <mark>digitale Exzellenz</mark><script>alert(1)</script>';
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||
|
||
$html = $this->request('/')->getContent();
|
||
self::assertStringContainsString('<mark>digitale Exzellenz</mark>', $html);
|
||
self::assertStringNotContainsString('alert(1)', $html);
|
||
self::assertStringNotContainsString('<script>alert', $html);
|
||
|
||
// Das hervorgehobene Wort nimmt die Akzentfarbe statt eines gelben Textmarkers.
|
||
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
|
||
self::assertStringContainsString('.kant h1 mark,.kant h2 mark,.kant h3 mark{background:transparent;color:var(--accent)', $css);
|
||
|
||
// Vor v17 bleibt eine Überschrift wörtlich, damit nichts nachträglich zu Markup wird.
|
||
$document['schemaVersion'] = 16;
|
||
$document['blocks'][0]['children'][0]['props']['text'] = 'Ganz <mark>ohne</mark> Auszeichnung';
|
||
$revision = $this->pages->saveDraft('page-1', $document, $this->pages->findForEditor('page-1')['version']);
|
||
$this->pages->publish('page-1', $revision, $this->pages->findForEditor('page-1')['version']);
|
||
self::assertStringContainsString('Ganz <mark>ohne</mark> Auszeichnung', $this->request('/')->getContent());
|
||
}
|
||
|
||
public function testALogoCanLinkToTheStartPageAndTheHeaderCanStick(): void
|
||
{
|
||
// Zwei Selbstverständlichkeiten, die fehlten: ein Bild ließ sich nicht verlinken - also
|
||
// auch kein Logo auf die Startseite - und der Kopfbereich konnte beim Scrollen nicht oben
|
||
// bleiben. Beides ist beim Nachbau von kant-it.de aufgefallen.
|
||
$document = DemoContent::starter('Mit Logo');
|
||
$document['schemaVersion'] = 17;
|
||
$document['blocks'][] = ['id' => 'logo', 'type' => 'image', 'props' => [
|
||
'src' => '/media/'.str_repeat('a', 32).'/480', 'alt' => 'Firmenlogo', 'decorative' => false,
|
||
'width' => 480, 'height' => 120, 'href' => '/',
|
||
]];
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||
|
||
$html = $this->request('/')->getContent();
|
||
self::assertStringContainsString('<a class="image-link" href="/">', $html);
|
||
self::assertStringContainsString('alt="Firmenlogo"', $html);
|
||
|
||
// Datum, Autor und Kategorie über einem Beitrag lassen sich abschalten - ein Beitrag mit
|
||
// eigenem Kopfbild trägt den Titel schon, und die Zeile darüber steht dann im Weg.
|
||
$this->publishedPost('projekt', 'Ein Projekt', '2026-02-01', ['Projekte'], []);
|
||
self::assertStringContainsString('post-header', $this->request('/projekt')->getContent());
|
||
$vorher = $this->site->load();
|
||
$this->site->save([...$vorher, 'postMetaHeader' => false, 'sameAs' => '', 'consentGroups' => $vorher['consentGroups']]);
|
||
self::assertStringNotContainsString('post-header', $this->request('/projekt')->getContent());
|
||
|
||
// Der Kopfbereich klebt erst, wenn die Einstellung es sagt.
|
||
self::assertStringContainsString('class="site-header"', $html);
|
||
$settings = $this->site->load();
|
||
$this->site->save([...$settings, 'stickyHeader' => true, 'sameAs' => '', 'consentGroups' => $settings['consentGroups']]);
|
||
self::assertStringContainsString('class="site-header is-sticky"', $this->request('/')->getContent());
|
||
}
|
||
|
||
/**
|
||
* Ein Kopfbereich mit eigener Fläche bekommt keine zweite dahinter.
|
||
*
|
||
* Beim Nachbau der Tagespflege war die Sektion beim Scrollen auf #ffffff00 gestellt und blieb
|
||
* trotzdem weiß: der klebende Kopf malte seine eigene Fläche und Schattenlinie darunter.
|
||
*/
|
||
public function testAHeaderThatPaintsItsOwnSurfaceGetsNoSecondOne(): void
|
||
{
|
||
$settings = $this->site->load();
|
||
$this->site->save([...$settings, 'stickyHeader' => true, 'sameAs' => '', 'consentGroups' => $settings['consentGroups']]);
|
||
self::assertStringContainsString('class="site-header is-sticky"', $this->request('/')->getContent(), 'Ohne eigene Fläche bleibt die weiße des Kopfes.');
|
||
|
||
self::assertTrue(\App\Twig\HeaderExtension::ownSurface(['blocks' => [['id' => 's', 'type' => 'section', 'style' => ['desktop' => ['scrolled' => ['backgroundColor' => '#ffffff00']]]]]]));
|
||
self::assertTrue(\App\Twig\HeaderExtension::ownSurface(['blocks' => [['id' => 's', 'type' => 'section', 'style' => ['mobile' => ['backgroundColor' => '#294655']]]]]));
|
||
// Eine Textfarbe ist keine Fläche, und ein Kasten in einer Spalte macht noch keinen Kopf.
|
||
self::assertFalse(\App\Twig\HeaderExtension::ownSurface(['blocks' => [['id' => 's', 'type' => 'section', 'style' => ['desktop' => ['color' => '#294655']]]]]));
|
||
self::assertFalse(\App\Twig\HeaderExtension::ownSurface(['blocks' => [['id' => 's', 'type' => 'section', 'children' => [['id' => 'c', 'type' => 'column', 'style' => ['desktop' => ['backgroundColor' => '#ffffff']]]]]]]));
|
||
self::assertFalse(\App\Twig\HeaderExtension::ownSurface(null));
|
||
|
||
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
|
||
self::assertStringContainsString('.site-header.is-sticky.has-own-surface{background:transparent;box-shadow:none}', $css);
|
||
}
|
||
|
||
/** @return iterable<string, array{string, bool}> */
|
||
public static function imageLinks(): iterable
|
||
{
|
||
yield 'lokal' => ['/kontakt', true];
|
||
yield 'Sprungmarke' => ['#kontakt', true];
|
||
yield 'HTTPS' => ['https://beispiel.de/', true];
|
||
yield 'Skript' => ['javascript:alert(1)', false];
|
||
yield 'Datenadresse' => ['data:text/html,hi', false];
|
||
yield 'schemalos' => ['//fremd.test', false];
|
||
}
|
||
|
||
#[DataProvider('imageLinks')]
|
||
public function testAnImageLinkFollowsTheSameRulesAsEveryOtherLink(string $href, bool $allowed): void
|
||
{
|
||
$document = DemoContent::starter('Bildlink');
|
||
$document['schemaVersion'] = 17;
|
||
$document['blocks'][] = ['id' => 'logo', 'type' => 'image', 'props' => [
|
||
'src' => '/bild.webp', 'alt' => 'Logo', 'decorative' => false, 'width' => 400, 'height' => 100, 'href' => $href,
|
||
]];
|
||
if (!$allowed) {
|
||
$this->expectException(InvalidDocument::class);
|
||
}
|
||
(new DocumentValidator())->validate($document);
|
||
if ($allowed) {
|
||
self::assertTrue(true, 'Erlaubte Adresse wird angenommen.');
|
||
}
|
||
}
|
||
|
||
public function testSliderControlsLoadOnlyForPagesWithSliders(): void
|
||
{
|
||
$document = DemoContent::starter('Mit Slider');
|
||
$document['schemaVersion'] = 17;
|
||
$document['blocks'][] = ['id' => 'slider', 'type' => 'slider', 'props' => [
|
||
'desktopVisible' => 1, 'tabletVisible' => 1, 'mobileVisible' => 1, 'snap' => true, 'autoplay' => 0,
|
||
], 'children' => []];
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||
|
||
$ohne = $this->request('/');
|
||
self::assertStringContainsString('slider.js?v=', $ohne->getContent());
|
||
self::assertStringNotContainsString('data-autoplay=', $ohne->getContent());
|
||
self::assertStringContainsString("script-src 'self'", (string) $ohne->headers->get('Content-Security-Policy'));
|
||
|
||
$document['blocks'][array_key_last($document['blocks'])]['props']['autoplay'] = 7;
|
||
$revision = $this->pages->saveDraft('page-1', $document, $this->pages->findForEditor('page-1')['version']);
|
||
$this->pages->publish('page-1', $revision, $this->pages->findForEditor('page-1')['version']);
|
||
|
||
$mit = $this->request('/');
|
||
self::assertStringContainsString('<script type="module" src="/assets/slider.js?v=', $mit->getContent());
|
||
self::assertStringContainsString('data-autoplay="7"', $mit->getContent());
|
||
self::assertStringContainsString("script-src 'self'", (string) $mit->headers->get('Content-Security-Policy'));
|
||
|
||
// Removing the block removes the public dependency and closes the script policy again.
|
||
array_pop($document['blocks']);
|
||
$revision = $this->pages->saveDraft('page-1', $document, $this->pages->findForEditor('page-1')['version']);
|
||
$this->pages->publish('page-1', $revision, $this->pages->findForEditor('page-1')['version']);
|
||
$plain = $this->request('/');
|
||
self::assertStringNotContainsString('slider.js', $plain->getContent());
|
||
self::assertStringContainsString("script-src 'none'", (string) $plain->headers->get('Content-Security-Policy'));
|
||
}
|
||
|
||
public function testTheSamePostsCanBeShownInThreeWays(): void
|
||
{
|
||
// Umut wollte auch für die Auflistung mehrere Darstellungen. Es sind dieselben Daten und
|
||
// dasselbe Markup - nur die Klasse wechselt, damit eine Darstellung nichts kostet außer
|
||
// ein paar Regeln CSS.
|
||
$this->publishedPost('projekt-eins', 'Projekt eins', '2026-02-01', ['Projekte'], []);
|
||
|
||
// Jeder Schritt hebt die Fassung um eins: erst das Speichern, dann das Veröffentlichen.
|
||
$version = 0;
|
||
foreach (['karten', 'liste', 'kompakt', 'wechsel'] as $art) {
|
||
$document = DemoContent::starter('Übersicht');
|
||
$document['schemaVersion'] = 18;
|
||
$document['blocks'][] = ['id' => 'liste', 'type' => 'postlist', 'props' => [
|
||
'count' => 4, 'category' => 'projekte', 'tag' => '', 'layout' => $art,
|
||
]];
|
||
$revision = $this->pages->saveDraft('page-1', $document, $version);
|
||
$this->pages->publish('page-1', $revision, ++$version);
|
||
++$version;
|
||
|
||
$html = $this->request('/')->getContent();
|
||
self::assertStringContainsString('post-list post-list-'.$art, $html, $art);
|
||
self::assertStringContainsString('Projekt eins', $html, $art);
|
||
}
|
||
|
||
// Wechselseitig kehrt jede zweite Zeile um - die Karte selbst ist das Listenelement,
|
||
// also zählt nth-child auf ihr und nicht eine Ebene tiefer.
|
||
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
|
||
self::assertStringContainsString('.post-list-wechsel .post-card:nth-child(even) .post-image{order:2}', $css);
|
||
// Und die Nummer kommt aus einem Zähler, nicht aus dem Inhalt: eine Liste, die auf Seite
|
||
// zwei wieder bei 01 beginnt, wäre falsch beschriftet, aber sie wäre ehrlich - der Inhalt
|
||
// weiß nicht, der wievielte Beitrag er ist.
|
||
self::assertStringContainsString('counter-increment:beitrag', $css);
|
||
|
||
// Die kompakte Darstellung lässt die Bilder ganz weg - sonst wäre sie nicht kompakt.
|
||
$document = DemoContent::starter('Übersicht');
|
||
$document['schemaVersion'] = 18;
|
||
$document['blocks'][] = ['id' => 'liste', 'type' => 'postlist', 'props' => ['count' => 4, 'category' => 'projekte', 'tag' => '', 'layout' => 'kompakt']];
|
||
$revision = $this->pages->saveDraft('page-1', $document, $version);
|
||
$this->pages->publish('page-1', $revision, ++$version);
|
||
self::assertStringNotContainsString('post-image', $this->request('/')->getContent());
|
||
|
||
// Und eine erfundene Darstellung wird abgewiesen, statt still auf etwas zurückzufallen.
|
||
$document = DemoContent::starter('Übersicht');
|
||
$document['schemaVersion'] = 18;
|
||
$document['blocks'][] = ['id' => 'liste', 'type' => 'postlist', 'props' => ['count' => 4, 'layout' => 'kacheln']];
|
||
$this->expectException(InvalidDocument::class);
|
||
(new DocumentValidator())->validate($document);
|
||
}
|
||
|
||
public function testEntranceEffectsLoadOnlyWhenUsedAndKeepTheirSelectionInHtml(): void
|
||
{
|
||
$document = DemoContent::starter('Mit Bewegung');
|
||
$document['schemaVersion'] = 19;
|
||
$document['blocks'][0]['layout']['effects']['entrance'] = 'left';
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||
$response = $this->request('/');
|
||
self::assertStringContainsString('fx-entrance-left', $response->getContent());
|
||
self::assertStringContainsString('motion.js?v=', $response->getContent());
|
||
self::assertStringContainsString("script-src 'self'", (string) $response->headers->get('Content-Security-Policy'));
|
||
unset($document['blocks'][0]['layout']['effects']['entrance']);
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 2), 3);
|
||
self::assertStringNotContainsString('motion.js?v=', $this->request('/')->getContent());
|
||
|
||
// Und die drei Beitragsvorlagen bringen die Einblendung von sich aus mit.
|
||
foreach (array_keys(PostLayouts::all()) as $key) {
|
||
$document = PostLayouts::document($key, 'Ein Beitrag');
|
||
$mitEffekt = array_filter($document['blocks'], static fn (array $block): bool => isset($block['layout']['effects']['entrance']));
|
||
self::assertCount(count($document['blocks']), $mitEffekt, $key.': jeder Abschnitt blendet ein.');
|
||
(new DocumentValidator())->validate($document);
|
||
}
|
||
}
|
||
|
||
public function testANewPostStartsFromOneOfThreeLayouts(): void
|
||
{
|
||
// Umut wollte mehrere Aufbauten zur Auswahl. Ein leeres Dokument ist für eine Seite
|
||
// richtig - dort weiß niemand vorher, was daraus wird -, für einen Beitrag fast nie.
|
||
self::assertSame(['bildkopf', 'klassisch', 'magazin'], array_keys(PostLayouts::all()));
|
||
|
||
foreach (PostLayouts::all() as $key => $layout) {
|
||
$document = PostLayouts::document($key, 'Ein Beitrag');
|
||
// Jeder Aufbau ist ein gültiges Dokument mit genau einer Hauptüberschrift.
|
||
(new DocumentValidator())->validate($document);
|
||
self::assertSame(18, $document['schemaVersion'], $key);
|
||
self::assertNotSame('', $layout['description'], $key);
|
||
|
||
$id = $this->pages->createPage('beitrag-'.$key, null, 'post', $document);
|
||
$page = $this->pages->findForEditor($id);
|
||
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
|
||
$html = $this->request('/beitrag-'.$key)->getContent();
|
||
self::assertStringContainsString('Ein Beitrag', $html, $key);
|
||
// Jeder Aufbau endet mit einem Weg weiter, keiner im Nichts.
|
||
self::assertStringContainsString('block-postnav', $html, $key);
|
||
}
|
||
|
||
// Ein erfundener Aufbau fällt auf die Voreinstellung zurück statt zu scheitern.
|
||
self::assertFalse(PostLayouts::exists('gibtsnicht'));
|
||
self::assertSame(
|
||
PostLayouts::document(PostLayouts::DEFAULT, 'X'),
|
||
PostLayouts::document('gibtsnicht', 'X'),
|
||
);
|
||
}
|
||
|
||
public function testTheFirstTextOfASectionIsNoLongerSilentlyTurnedIntoALabel(): void
|
||
{
|
||
// Diese Regel gestaltete nach Position statt nach Absicht: im Fußbereich wurde aus dem
|
||
// Firmennamen roter Versalientext, in einer Beitragsvorlage aus dem Einstiegsabsatz eine
|
||
// Kleinbeschriftung. Wer eine Beschriftung will, stellt sie ein.
|
||
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
|
||
self::assertStringNotContainsString('.section-inner>.block-text:first-child', $css);
|
||
self::assertStringNotContainsString('.block-column>.block-text:first-child', $css);
|
||
}
|
||
|
||
public function testAPostCanPointToItsNeighboursWithoutStoringThem(): void
|
||
{
|
||
// Die Beitragsseite endete bisher im Nichts: gelesen, und dann? Der neue Baustein führt
|
||
// zum nächsten Beitrag - innerhalb derselben Kategorie, denn von einem Projekt führt man
|
||
// zum nächsten Projekt und nicht zur nächsten Pressemitteilung.
|
||
foreach ([['eins', '2026-02-01'], ['zwei', '2026-02-02'], ['drei', '2026-02-03']] as [$slug, $datum]) {
|
||
$this->publishedPost('projekt-'.$slug, 'Projekt '.$slug, $datum, ['Projekte'], []);
|
||
}
|
||
$this->publishedPost('notiz', 'Eine Notiz', '2026-02-04', ['Notizen'], []);
|
||
|
||
$mitte = $this->db->fetchOne('SELECT id FROM page WHERE path = ?', ['projekt-zwei']);
|
||
$document = DemoContent::starter('Projekt zwei', 'post');
|
||
$document['schemaVersion'] = 18;
|
||
$document['blocks'][] = ['id' => 'weiter', 'type' => 'postnav', 'props' => ['previousLabel' => 'Vorheriges Projekt', 'nextLabel' => 'Nächstes Projekt']];
|
||
$this->pages->publish($mitte, $this->pages->saveDraft($mitte, $document, $this->pages->findForEditor($mitte)['version']), $this->pages->findForEditor($mitte)['version']);
|
||
|
||
$html = $this->request('/projekt-zwei')->getContent();
|
||
// Neuer steht im Archiv oben, also ist „vorheriges" der neuere Beitrag.
|
||
self::assertStringContainsString('Projekt drei', $html);
|
||
self::assertStringContainsString('Projekt eins', $html);
|
||
self::assertStringContainsString('Vorheriges Projekt', $html);
|
||
// Die Notiz gehört in eine andere Kategorie und taucht nicht auf.
|
||
self::assertStringNotContainsString('Eine Notiz', $html);
|
||
|
||
// Und im gespeicherten Dokument steht kein Beitrag - nur die Beschriftungen.
|
||
$stored = $this->pages->findPublished('projekt-zwei')['document'];
|
||
self::assertSame(['previousLabel' => 'Vorheriges Projekt', 'nextLabel' => 'Nächstes Projekt'], end($stored['blocks'])['props']);
|
||
|
||
// Am Rand wird nicht umgebrochen: der neueste Beitrag hat keinen Vorgänger.
|
||
$neuestes = $this->db->fetchOne('SELECT id FROM page WHERE path = ?', ['projekt-drei']);
|
||
$document['title'] = 'Projekt drei';
|
||
$this->pages->publish($neuestes, $this->pages->saveDraft($neuestes, $document, $this->pages->findForEditor($neuestes)['version']), $this->pages->findForEditor($neuestes)['version']);
|
||
$rand = $this->request('/projekt-drei')->getContent();
|
||
self::assertStringNotContainsString('Vorheriges Projekt', $rand);
|
||
self::assertStringContainsString('Nächstes Projekt', $rand);
|
||
}
|
||
|
||
public function testTheCloudflareCheckIsOffUntilItIsSetUp(): void
|
||
{
|
||
// Umut: „bei Formularen hätte ich gerne optional Cloudflare." Optional heißt: ohne
|
||
// Schlüssel steht nichts im Formular, und die Sicherheitsrichtlinie bleibt so eng wie
|
||
// vorher - eine Website ohne Spamwelle bindet keinen Dritten ein.
|
||
$turnstile = new Turnstile($this->site);
|
||
self::assertFalse($turnstile->isActive());
|
||
// Ohne eingerichtete Prüfung gilt jede Absendung als bestanden.
|
||
self::assertTrue($turnstile->passed(null, '127.0.0.1'));
|
||
|
||
// Eingeschaltet ohne Schlüssel wäre ein Formular, das jede Anfrage ablehnt.
|
||
try {
|
||
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'turnstile' => '1']);
|
||
self::fail('Ohne Schlüssel darf die Prüfung nicht angehen.');
|
||
} catch (InvalidPage) {
|
||
}
|
||
|
||
$this->site->save([
|
||
'country' => 'DE', 'homePath' => 'home', 'turnstile' => '1',
|
||
'turnstileSiteKey' => '0xAAAAAAAAAAAAAAAAAAAAAA', 'turnstileSecret' => '0xBBBBBBBBBBBBBBBBBBBBBB',
|
||
]);
|
||
self::assertTrue((new Turnstile($this->site))->isActive());
|
||
// Ein fehlender Nachweis wird abgelehnt, ohne dass jemand gefragt werden muss.
|
||
self::assertFalse((new Turnstile($this->site))->passed('', '127.0.0.1'));
|
||
|
||
(new FormRepository($this->db))->save('kontakt', 'Kontakt', [
|
||
['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true],
|
||
], ['recipient' => 'info@kant-it.de', 'subject' => 'Anfrage', 'replyToField' => 'email', 'success' => 'Danke.'], 0);
|
||
$document = DemoContent::starter('Kontakt');
|
||
$document['schemaVersion'] = 8;
|
||
$document['blocks'][] = ['id' => 'formblock', 'type' => 'form', 'props' => ['form' => 'kontakt']];
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||
$antwort = $this->request('/');
|
||
$html = $antwort->getContent();
|
||
self::assertStringContainsString('data-sitekey="0xAAAAAAAAAAAAAAAAAAAAAA"', $html);
|
||
// Ohne Skript kann man es nicht absenden - das steht dann auch da.
|
||
self::assertStringContainsString('<noscript>', $html);
|
||
// Skript und Rahmen von Cloudflare sind erlaubt, aber nichts sonst.
|
||
$csp = (string) $antwort->headers->get('Content-Security-Policy');
|
||
self::assertStringContainsString("script-src 'self' https://challenges.cloudflare.com", $csp);
|
||
self::assertStringContainsString('frame-src https://challenges.cloudflare.com', $csp);
|
||
}
|
||
|
||
public function testAPictureCanBeEnlargedAndWorksWithoutScripting(): void
|
||
{
|
||
// Umut: „Bilder vergrößern Funktion, ein Popup-Viewer - gerne auch auswählbar wie bei
|
||
// WordPress." Auswählbar heißt: je Bild entscheidet sich, was ein Klick tut. Und ohne
|
||
// Skript bleibt es benutzbar: dann öffnet der Verweis schlicht die große Fassung.
|
||
$media = new MediaLibrary($this->db, $this->path.'-media');
|
||
$file = tempnam(sys_get_temp_dir(), 'kant-zoom-');
|
||
$image = imagecreatetruecolor(1600, 900);
|
||
imagepng($image, $file);
|
||
imagedestroy($image);
|
||
$item = $media->upload(new UploadedFile($file, 'motiv.png', test: true));
|
||
@unlink($file);
|
||
$quellen = array_map(static fn (array $v): array => ['src' => $v['src'], 'width' => $v['width']], $item['variants']);
|
||
$gross = end($quellen)['src'];
|
||
|
||
$bild = static fn (string $id, array $props): array => ['id' => $id, 'type' => 'image', 'props' => [
|
||
'src' => $quellen[0]['src'], 'alt' => 'Ein Motiv', 'decorative' => false,
|
||
'width' => 1600, 'height' => 900, 'sources' => $quellen, ...$props,
|
||
]];
|
||
|
||
$document = DemoContent::starter('Startseite');
|
||
$document['schemaVersion'] = 19;
|
||
$document['blocks'][] = ['id' => 'galerie', 'type' => 'gallery', 'props' => ['lightbox' => true], 'children' => [
|
||
$bild('g1', ['click' => 'vergroessern', 'caption' => 'Erstes Bild']),
|
||
$bild('g2', ['click' => 'vergroessern']),
|
||
]];
|
||
$document['blocks'][] = $bild('einzeln', ['click' => 'vergroessern']);
|
||
$document['blocks'][] = $bild('ruhig', []);
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||
|
||
$antwort = $this->request('/');
|
||
$html = $antwort->getContent();
|
||
// Der Verweis zeigt auf die größte Fassung - ohne Skript ist genau das die Vergrößerung.
|
||
self::assertStringContainsString('<a class="image-link image-zoom" href="'.$gross.'" data-zoom', $html);
|
||
self::assertStringContainsString('data-caption="Erstes Bild"', $html);
|
||
// Die Galerie ist als Gruppe gekennzeichnet, damit sich darin blättern lässt.
|
||
self::assertStringContainsString('data-zoom-group', $html);
|
||
// Ein Bild ohne Klickwirkung bleibt, was es war.
|
||
self::assertMatchesRegularExpression('#<figure id="ruhig".{0,200}<img#s', $html);
|
||
// Drei Bilder mit Klickwirkung, das vierte bleibt unberührt.
|
||
self::assertSame(3, substr_count($html, 'image-zoom'));
|
||
|
||
// Das Skript kommt nur auf Seiten mit Betrachter, und die Richtlinie erlaubt es dort.
|
||
self::assertStringContainsString('<script src="/assets/lightbox.js?v=', $html);
|
||
self::assertStringContainsString("script-src 'self'", (string) $antwort->headers->get('Content-Security-Policy'));
|
||
|
||
// Eine Seite ohne vergrößerbares Bild lädt es nicht und bleibt ohne Skript.
|
||
$ohne = DemoContent::starter('Startseite');
|
||
$ohne['schemaVersion'] = 19;
|
||
$ohne['blocks'][] = $bild('ruhig', []);
|
||
$version = $this->pages->findForEditor('page-1')['version'];
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $ohne, $version), $version + 1);
|
||
$zweite = $this->request('/');
|
||
self::assertStringNotContainsString('lightbox.js', $zweite->getContent());
|
||
self::assertStringContainsString("script-src 'none'", (string) $zweite->headers->get('Content-Security-Policy'));
|
||
}
|
||
|
||
public function testAZoomableGalleryMakesEveryImageWithoutItsOwnChoiceZoomable(): void
|
||
{
|
||
// Umut: „Bei Galerie muss man da alles einzeln machen? kann man das mit einem Klick für
|
||
// alle Elemente machen?" Der Haken an der Galerie gilt jetzt für jedes Bild darin, das
|
||
// selbst nichts gewählt hat. Eine eigene Wahl und ein Link gehen vor.
|
||
$bild = static fn (string $id, array $props): array => ['id' => $id, 'type' => 'image', 'props' => [
|
||
'src' => '/media/'.str_repeat('a', 32).'/480', 'alt' => 'Motiv '.$id, 'decorative' => false, 'width' => 480, 'height' => 270, ...$props,
|
||
]];
|
||
$document = DemoContent::starter('Startseite');
|
||
$document['schemaVersion'] = 19;
|
||
$document['blocks'][] = ['id' => 'galerie', 'type' => 'gallery', 'props' => ['lightbox' => true], 'children' => [
|
||
$bild('frei1', []),
|
||
$bild('frei2', []),
|
||
$bild('still', ['click' => 'keine']),
|
||
$bild('verlinkt', ['click' => 'link', 'href' => '/kontakt']),
|
||
]];
|
||
$document['blocks'][] = ['id' => 'ohne', 'type' => 'gallery', 'props' => [], 'children' => [$bild('ruhig', [])]];
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||
|
||
$html = $this->request('/')->getContent();
|
||
self::assertMatchesRegularExpression('#<figure id="frei1"[^>]*>\s*<a class="image-link image-zoom"#', $html);
|
||
self::assertMatchesRegularExpression('#<figure id="frei2"[^>]*>\s*<a class="image-link image-zoom"#', $html);
|
||
self::assertMatchesRegularExpression('#<figure id="still"[^>]*><img#', $html);
|
||
self::assertMatchesRegularExpression('#<figure id="verlinkt"[^>]*>\s*<a class="image-link" href="/kontakt"#', $html);
|
||
// Eine Galerie ohne Haken bleibt, wie sie war.
|
||
self::assertMatchesRegularExpression('#<figure id="ruhig"[^>]*><img#', $html);
|
||
self::assertSame(2, substr_count($html, 'image-zoom'));
|
||
self::assertStringContainsString('<script src="/assets/lightbox.js?v=', $html);
|
||
}
|
||
|
||
public function testAPdfFromTheLibraryIsALinkTargetAndOpensInTheViewer(): void
|
||
{
|
||
// Umut: „PDFs hochladen und als Button verlinken - und ein Inline-PDF-Viewer wie das
|
||
// Bild-Popup." Das PDF bleibt, wie es kam; geprüft wird, dass es eines ist.
|
||
$documents = new DocumentLibrary($this->db, $this->path.'-media');
|
||
$file = tempnam(sys_get_temp_dir(), 'kant-pdf-');
|
||
try {
|
||
file_put_contents($file, '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>');
|
||
try {
|
||
$documents->upload(new UploadedFile($file, 'preisliste.pdf', test: true));
|
||
self::fail('Eine Datei, die kein PDF ist, darf nicht als PDF durchgehen.');
|
||
} catch (\InvalidArgumentException) {
|
||
}
|
||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM document'));
|
||
|
||
file_put_contents($file, "%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
|
||
$item = $documents->upload(new UploadedFile($file, 'Preisliste 2026.pdf', test: true));
|
||
} finally {
|
||
@unlink($file);
|
||
}
|
||
self::assertSame('Preisliste 2026', $item['title']);
|
||
self::assertSame('/media/'.$item['id'].'.pdf', $item['src']);
|
||
|
||
// Ausgeliefert als PDF, zum Anzeigen, mit dem Titel als Dateinamen, und einbetten darf es
|
||
// nur die eigene Website.
|
||
$antwort = $this->request($item['src']);
|
||
self::assertSame(200, $antwort->getStatusCode());
|
||
self::assertSame('application/pdf', $antwort->headers->get('Content-Type'));
|
||
self::assertSame('nosniff', $antwort->headers->get('X-Content-Type-Options'));
|
||
self::assertSame("frame-ancestors 'self'", $antwort->headers->get('Content-Security-Policy'));
|
||
self::assertStringStartsWith('inline;', (string) $antwort->headers->get('Content-Disposition'));
|
||
self::assertStringContainsString('Preisliste 2026.pdf', (string) $antwort->headers->get('Content-Disposition'));
|
||
self::assertSame(404, $this->request('/media/'.str_repeat('a', 32).'.pdf')->getStatusCode());
|
||
|
||
// Ein Button zeigt auf das PDF - ein gewöhnlicher lokaler Pfad, ohne neues Schema.
|
||
$document = DemoContent::starter('Startseite');
|
||
$document['blocks'][] = ['id' => 'preise', 'type' => 'button', 'props' => ['text' => 'Preisliste', 'href' => $item['src']]];
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||
$seite = $this->request('/');
|
||
$html = $seite->getContent();
|
||
self::assertStringContainsString('href="'.$item['src'].'"', $html);
|
||
self::assertStringContainsString('<script src="/assets/lightbox.js?v=', $html);
|
||
$csp = (string) $seite->headers->get('Content-Security-Policy');
|
||
self::assertStringContainsString("frame-src 'self'", $csp);
|
||
self::assertStringContainsString("script-src 'self'", $csp);
|
||
|
||
// Ohne Link auf ein PDF bleibt es beim alten: kein Skript, kein Rahmen.
|
||
$ohne = DemoContent::starter('Startseite');
|
||
$version = $this->pages->findForEditor('page-1')['version'];
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $ohne, $version), $version + 1);
|
||
$zweite = $this->request('/');
|
||
self::assertStringNotContainsString('lightbox.js', $zweite->getContent());
|
||
self::assertStringContainsString("frame-src 'none'", (string) $zweite->headers->get('Content-Security-Policy'));
|
||
}
|
||
|
||
public function testLegalLinksAppearOnlyWhereAnAuthorPlacesThem(): void
|
||
{
|
||
// Die hinterlegten Adressen erzeugen keine zusätzliche Leiste unter dem Footer.
|
||
$datenschutz = $this->pages->createPage('datenschutz', null, 'page', DemoContent::starter('Datenschutz'));
|
||
$seite = $this->pages->findForEditor($datenschutz);
|
||
$this->pages->publish($datenschutz, $seite['draft_revision_id'], $seite['version']);
|
||
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'privacyPath' => 'datenschutz', 'imprintPath' => 'home']);
|
||
$document = DemoContent::starter('Startseite');
|
||
$document['schemaVersion'] = 19;
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||
|
||
// Ohne selbst platzierte Links bleibt die Seite ohne Rechtszeile.
|
||
$html = $this->request('/')->getContent();
|
||
self::assertStringNotContainsString('<nav class="legal-bar"', $html);
|
||
self::assertStringNotContainsString('<a href="/datenschutz">', $html);
|
||
|
||
// Mit Baustein: nur noch dieser, und zwar an seiner Stelle im Dokument.
|
||
$document['blocks'][] = ['id' => 'rechtszeile', 'type' => 'legal', 'props' => []];
|
||
$version = $this->pages->findForEditor('page-1')['version'];
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, $version), $version + 1);
|
||
$mit = $this->request('/')->getContent();
|
||
self::assertStringContainsString('<nav id="rechtszeile" class="block-legal"', $mit);
|
||
self::assertStringNotContainsString('<nav class="legal-bar"', $mit);
|
||
self::assertStringContainsString('Datenschutz', $mit);
|
||
}
|
||
|
||
public function testTheAdministrationCanLiveSomewhereElseAndIsNotAdvertised(): void
|
||
{
|
||
// Umut: „Erstelle mal Möglichkeit, Backend anzupassen. Also URL, Admin auf irgendwas."
|
||
// /admin ist die erste Adresse, die ein Scanner probiert. Wer sie umbenennt, nimmt der
|
||
// Maschinerie das Ziel - aber nur, wenn der neue Name nirgends öffentlich steht.
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::starter('Startseite'), 0), 1);
|
||
|
||
// Beim Standardnamen bleibt die Zeile in der robots.txt: diese Adresse kennt ohnehin jeder.
|
||
self::assertStringContainsString('Disallow: /admin', $this->request('/robots.txt')->getContent());
|
||
|
||
// Bei geändertem Namen darf er dort nicht auftauchen.
|
||
$vorher = $_SERVER['ADMIN_PATH'] ?? null;
|
||
$_SERVER['ADMIN_PATH'] = $_ENV['ADMIN_PATH'] = 'stiller-eingang';
|
||
try {
|
||
$robots = $this->request('/robots.txt')->getContent();
|
||
self::assertStringNotContainsString('stiller-eingang', $robots, 'robots.txt ist öffentlich - der Name gehört dort nicht hinein.');
|
||
self::assertStringNotContainsString('Disallow: /admin', $robots);
|
||
self::assertStringContainsString('Disallow: /formular/', $robots, 'Der Rest der Datei bleibt, wie er war.');
|
||
} finally {
|
||
if (null === $vorher) {
|
||
unset($_SERVER['ADMIN_PATH'], $_ENV['ADMIN_PATH']);
|
||
} else {
|
||
$_SERVER['ADMIN_PATH'] = $_ENV['ADMIN_PATH'] = $vorher;
|
||
}
|
||
}
|
||
|
||
// Und keine Route trägt die Adresse fest im Text: sonst zöge eine Umbenennung die Hälfte
|
||
// der Verwaltung nicht mit.
|
||
foreach (glob(__DIR__.'/../src/Controller/*.php') ?: [] as $datei) {
|
||
$inhalt = (string) file_get_contents($datei);
|
||
self::assertDoesNotMatchRegularExpression(
|
||
"#\#\[Route\('/admin#",
|
||
$inhalt,
|
||
basename($datei).' schreibt /admin fest, statt den eingestellten Pfad zu nehmen.',
|
||
);
|
||
}
|
||
}
|
||
|
||
public function testTheReleaseArchiveCarriesEveryFileTheApplicationNeeds(): void
|
||
{
|
||
// Auf dem Hosting gefunden, nicht hier: „The configuration file does not exist" beim
|
||
// Anlegen der Tabellen. Im Releasearchiv fehlte migrations.php - die Datei, die Doctrine
|
||
// sagt, wo die Migrationen liegen. Sie stand weder in der Mitnahmeliste noch in der
|
||
// Ausschlussliste, also fiel niemandem auf, dass sie fehlt.
|
||
$skript = (string) file_get_contents(__DIR__.'/../scripts/build-release.php');
|
||
preg_match('/\$include = \[(.+?)\];/s', $skript, $mit);
|
||
preg_match('/\$skip = \[(.+?)\];/s', $skript, $ohne);
|
||
self::assertNotEmpty($mit, 'Die Mitnahmeliste des Releaseskripts ist nicht auffindbar.');
|
||
$liste = static fn (string $roh): array => array_map(
|
||
static fn (string $eintrag): string => trim($eintrag, " '
|
||
"),
|
||
explode(',', preg_replace('#//[^
|
||
]*#', '', $roh) ?? ''),
|
||
);
|
||
$eingeordnet = [...$liste($mit[1]), ...$liste($ohne[1] ?? ''), '.env', '.env.local', 'LICENSE'];
|
||
|
||
foreach (scandir(__DIR__.'/..') ?: [] as $eintrag) {
|
||
if (in_array($eintrag, ['.', '..'], true) || str_starts_with($eintrag, '.git')) {
|
||
continue;
|
||
}
|
||
self::assertContains(
|
||
$eintrag,
|
||
$eingeordnet,
|
||
sprintf('„%s" steht im Projektstamm, aber weder in der Mitnahme- noch in der Ausschlussliste von build-release.php. Ein Release ohne diese Datei fällt erst auf dem Hosting auf.', $eintrag),
|
||
);
|
||
}
|
||
}
|
||
|
||
public function testTheAdministrationDrawsItsOwnIconsInsteadOfBorrowingCharacters(): void
|
||
{
|
||
// Umut: „Machst du auch gleich eigene Icons statt die Web-Dinger zu nutzen – auf dem
|
||
// Smartphone kommen sonst Emojis.“ Genau so ist es: ein Telefon ersetzt ein Zeichen wie
|
||
// das Zahnrad durch ein buntes Emoji in fremdem Stil, und die Navigation sieht aus wie
|
||
// zusammengewürfelt. Eigene Pfade sind eine Linienstärke, eine Farbe und kein Request.
|
||
$shell = (string) file_get_contents(__DIR__.'/../templates/admin/shell.html.twig');
|
||
self::assertDoesNotMatchRegularExpression(
|
||
'/[\\x{2190}-\\x{2BFF}\\x{1F300}-\\x{1FAFF}]/u',
|
||
$shell,
|
||
'In der Navigation steht ein Zeichen, das ein Telefon zum Emoji macht.',
|
||
);
|
||
self::assertStringContainsString('admin/icon.html.twig', $shell);
|
||
|
||
$icons = (string) file_get_contents(__DIR__.'/../templates/admin/icon.html.twig');
|
||
// Jedes verwendete Symbol muss es geben - sonst zeichnet die Vorlage stillschweigend das
|
||
// Ersatzsymbol, und zwei Einträge sehen gleich aus.
|
||
foreach (['pages', 'partials', 'menus', 'media', 'forms', 'statistics', 'theme', 'settings', 'system', 'users', 'external'] as $name) {
|
||
self::assertStringContainsString($name.": 'M", $icons, $name.' fehlt in der Symbolsammlung.');
|
||
}
|
||
preg_match_all("/'(M[^']+)'/", $icons, $pfade);
|
||
self::assertSame(count($pfade[1]), count(array_unique($pfade[1])), 'Zwei Symbole tragen denselben Pfad.');
|
||
}
|
||
|
||
public function testNoStylesheetHasADanglingSelector(): void
|
||
{
|
||
// Gefunden, als der Einwilligungsdialog plötzlich ungestaltet unter dem Fuß stand: beim
|
||
// Entfernen alter Regeln war der Selektorrest stehen geblieben - „.site-header" allein in
|
||
// einer Zeile. CSS liest dann weiter, hängt den nächsten Selektor an und verschluckt
|
||
// dessen Regel. Vier Veröffentlichungen lang hat das niemand bemerkt; ein Stylesheet
|
||
// beschwert sich nicht, es wirkt nur nicht mehr.
|
||
foreach (glob(__DIR__.'/../public/assets/*.css') ?: [] as $datei) {
|
||
$ohneKommentare = preg_replace('#/\*.*?\*/#s', '', (string) file_get_contents($datei)) ?? '';
|
||
foreach (preg_split('/\R/', $ohneKommentare) ?: [] as $nummer => $zeile) {
|
||
$zeile = rtrim($zeile);
|
||
if ('' === trim($zeile)) {
|
||
continue;
|
||
}
|
||
// Eine Zeile, die wie ein Selektor beginnt, muss auch wie einer enden: mit „{",
|
||
// mit einem Komma (weitere Selektoren folgen) oder mit einer Deklaration.
|
||
$beginntWieSelektor = 1 === preg_match('/^[.#a-zA-Z\[:*]/', trim($zeile));
|
||
$endetSauber = 1 === preg_match('/[{},;]$/', $zeile) || str_contains($zeile, '{');
|
||
self::assertTrue(
|
||
!$beginntWieSelektor || $endetSauber,
|
||
sprintf('%s Zeile %d endet mitten im Selektor: „%s“', basename($datei), $nummer + 1, trim($zeile)),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
public function testEveryPublicAnswerCarriesItsProtectiveHeaders(): void
|
||
{
|
||
// Diese Kopfzeilen fehlen lautlos, wenn jemand eine Antwort an der Stelle vorbeibaut, die
|
||
// sie setzt. Ein Test ist billiger als die Entdeckung im Betrieb.
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::starter('Startseite'), 0), 1);
|
||
foreach (['/', '/gibt-es-nicht'] as $adresse) {
|
||
$antwort = $this->request($adresse);
|
||
$csp = (string) $antwort->headers->get('Content-Security-Policy');
|
||
self::assertStringContainsString("default-src 'self'", $csp, $adresse);
|
||
self::assertStringContainsString("object-src 'none'", $csp, $adresse);
|
||
// Ein eingeschleustes <base> würde jeden Verweis der Seite umlenken.
|
||
self::assertStringContainsString("base-uri 'none'", $csp, $adresse);
|
||
self::assertStringContainsString("frame-ancestors 'none'", $csp, $adresse);
|
||
self::assertSame('nosniff', $antwort->headers->get('X-Content-Type-Options'), $adresse);
|
||
self::assertStringContainsString('camera=()', (string) $antwort->headers->get('Permissions-Policy'), $adresse);
|
||
}
|
||
|
||
// Und der Vordereingang verrät nicht, welche PHP-Fassung hier läuft.
|
||
$index = (string) file_get_contents(__DIR__.'/../public/index.php');
|
||
self::assertStringContainsString("header_remove('X-Powered-By')", $index);
|
||
self::assertStringContainsString('Header always unset X-Powered-By', (string) file_get_contents(__DIR__.'/../public/.htaccess'));
|
||
}
|
||
|
||
public function testABackgroundPictureShrinksForSmallScreensEvenWhenSetOnce(): void
|
||
{
|
||
// Gemessen auf der nachgebauten Startseite: das Telefon lud das Hintergrundbild mit 1920
|
||
// Pixeln und 189 KB, weil die Regel nur unter „Desktop" stand und ein kleines Gerät sie
|
||
// erbt. Sichtbar war davon nichts - bezahlt wurde es trotzdem.
|
||
$media = new MediaLibrary($this->db, $this->path.'-media');
|
||
$file = tempnam(sys_get_temp_dir(), 'kant-hintergrund-');
|
||
$image = imagecreatetruecolor(2000, 1200);
|
||
imagepng($image, $file);
|
||
imagedestroy($image);
|
||
$item = $media->upload(new UploadedFile($file, 'hintergrund.png', test: true));
|
||
@unlink($file);
|
||
|
||
$document = DemoContent::starter('Startseite');
|
||
$document['schemaVersion'] = 18;
|
||
$document['blocks'][0]['style'] = ['desktop' => [
|
||
'backgroundImage' => $item['id'],
|
||
'backgroundSize' => 'cover',
|
||
'overlayColor' => '#0a192f',
|
||
'overlayOpacity' => 0.6,
|
||
]];
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||
$html = $this->request('/')->getContent();
|
||
|
||
$id = $document['blocks'][0]['id'];
|
||
preg_match('/@media \(max-width:767px\)\{#'.$id.'\{([^}]*)}/', $html, $mobil);
|
||
preg_match('/#'.$id.'\{([^}]*)}/', $html, $desktop);
|
||
self::assertNotEmpty($mobil, 'Für kleine Geräte muss eine eigene Regel entstehen.');
|
||
self::assertStringContainsString('/960', $mobil[1], 'Das Telefon bekommt die kleine Variante.');
|
||
self::assertStringContainsString('/2000', $desktop[1], 'Der Schreibtisch die große.');
|
||
// Die Abdunkelung gehört zur selben Regel - sonst stünde heller Text plötzlich auf
|
||
// einem ungedämpften Bild.
|
||
self::assertStringContainsString('#0a192f 60%', $mobil[1]);
|
||
self::assertStringContainsString('background-size:cover', $mobil[1]);
|
||
|
||
// Eine eigene Regel für kleine Geräte wird nie überschrieben.
|
||
$document['blocks'][0]['style']['mobile'] = ['backgroundColor' => '#ffffff'];
|
||
$version = $this->pages->findForEditor('page-1')['version'];
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, $version), $version + 1);
|
||
preg_match('/@media \(max-width:767px\)\{#'.$id.'\{([^}]*)}/', $this->request('/')->getContent(), $eigen);
|
||
self::assertStringContainsString('background-color:#ffffff', $eigen[1]);
|
||
self::assertStringContainsString('/960', $eigen[1], 'Das Bild fehlt dort nicht, nur weil eine andere Eigenschaft gesetzt ist.');
|
||
}
|
||
|
||
public function testTheBigPictureLoadsFirstAndSmallOnesAskForSmallVariants(): void
|
||
{
|
||
// Zwei Fehler auf einmal: die Eile lag auf dem Logo im Kopfbereich statt auf dem großen
|
||
// Bild darunter, und jedes Bild fragte nach einer Variante für 800 px Breite - auch ein
|
||
// Logo, das 190 px breit ist. Das waren 32 KB für nichts.
|
||
$media = new MediaLibrary($this->db, $this->path.'-media');
|
||
$file = tempnam(sys_get_temp_dir(), 'kant-bild-');
|
||
$image = imagecreatetruecolor(1200, 800);
|
||
imagepng($image, $file);
|
||
imagedestroy($image);
|
||
$item = $media->upload(new UploadedFile($file, 'bild.png', test: true));
|
||
@unlink($file);
|
||
$variants = array_column($item['variants'], 'src', 'width');
|
||
|
||
$bild = static fn (string $id, array $style): array => [
|
||
'id' => $id,
|
||
'type' => 'image',
|
||
'props' => ['src' => $variants[960], 'alt' => 'Ein Bild', 'decorative' => false, 'width' => 960, 'height' => 640, 'sources' => [['src' => $variants[480], 'width' => 480], ['src' => $variants[960], 'width' => 960]]],
|
||
'style' => $style,
|
||
];
|
||
|
||
$document = DemoContent::starter('Startseite');
|
||
$document['schemaVersion'] = 18;
|
||
$document['blocks'] = [
|
||
['id' => 'oben', 'type' => 'section', 'props' => [], 'children' => [
|
||
['id' => 'titel', 'type' => 'heading', 'props' => ['text' => 'Startseite', 'level' => 1]],
|
||
$bild('gross', []),
|
||
$bild('logo', ['desktop' => ['maxWidth' => '190px']]),
|
||
]],
|
||
['id' => 'unten', 'type' => 'section', 'props' => [], 'children' => [$bild('weiter-unten', [])]],
|
||
];
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||
$html = $this->request('/')->getContent();
|
||
|
||
// Genau ein Bild wird vorgezogen, und zwar das erste im ersten Abschnitt. Es wird
|
||
// zweimal genannt: einmal als Vorabladung im Kopf, einmal als das Bild selbst.
|
||
self::assertSame(1, substr_count($html, '<link rel="preload" as="image"'));
|
||
self::assertMatchesRegularExpression('#<link rel="preload" as="image" href="[^"]+" imagesrcset="[^"]*480w[^"]*" imagesizes="\(max-width: 650px\) 100vw[^"]*" fetchpriority="high">#', $html);
|
||
self::assertSame(2, substr_count($html, 'fetchpriority="high"'));
|
||
self::assertMatchesRegularExpression('#<figure id="gross".{0,400}fetchpriority="high"#s', $html);
|
||
self::assertMatchesRegularExpression('#<figure id="weiter-unten".{0,400}loading="lazy"#s', $html);
|
||
// Ein Block mit fester Breite fragt nach genau dieser Breite.
|
||
self::assertMatchesRegularExpression('#<figure id="logo".{0,500}sizes="190px"#s', $html);
|
||
self::assertMatchesRegularExpression('#<figure id="gross".{0,500}sizes="\(max-width: 650px\) 100vw#s', $html);
|
||
}
|
||
|
||
public function testFontsAreReadFromGoogleStylesheetAndServedFromHere(): void
|
||
{
|
||
// Eine Schrift von Google kostet zwei fremde Verbindungen hintereinander und schickt die
|
||
// IP jedes Besuchers nach Kalifornien. Der Parser holt aus dem Stylesheet genau das, was
|
||
// eine deutschsprachige Website braucht - Kyrillisch bleibt liegen.
|
||
$fonts = new FontLibrary($this->path.'-fonts');
|
||
$css = <<<'CSS'
|
||
/* cyrillic */
|
||
@font-face{font-family:'Lato';font-style:normal;font-weight:400;src:url(https://fonts.gstatic.com/s/lato/kyrillisch.woff2) format('woff2');unicode-range:U+0301, U+0400-045F}
|
||
/* latin */
|
||
@font-face{font-family:'Lato';font-style:normal;font-weight:400;src:url(https://fonts.gstatic.com/s/lato/latein.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131}
|
||
/* latin */
|
||
@font-face{font-family:'Lato';font-style:normal;font-weight:700;src:url(https://fonts.gstatic.com/s/lato/latein-fett.woff2) format('woff2');unicode-range:U+0000-00FF}
|
||
CSS;
|
||
$faces = $fonts->parse($css, 'Lato');
|
||
self::assertCount(2, $faces, 'Kyrillisch gehört nicht dazu.');
|
||
self::assertSame([400, 700], array_column($faces, 'weight'));
|
||
self::assertSame('https://fonts.gstatic.com/s/lato/latein.woff2', $faces[0]['url']);
|
||
self::assertStringContainsString('U+0131', $faces[0]['range']);
|
||
|
||
// Ohne heruntergeladene Dateien bleibt es bei der Ersatzschrift - verlinkt wird Google
|
||
// nie, denn das gäbe die IP jedes Besuchers an einen Dritten weiter.
|
||
$theme = new Theme($this->db);
|
||
$gespeichert = $theme->load();
|
||
$theme->save($gespeichert['colors'], [
|
||
['key' => 'body', 'name' => 'Fließtext', 'family' => 'Lato', 'source' => 'google', 'weights' => [400, 700], 'fallback' => 'system-ui'],
|
||
['key' => 'heading', 'name' => 'Überschriften', 'family' => 'Lato', 'source' => 'google', 'weights' => [700, 900], 'fallback' => 'system-ui'],
|
||
], $gespeichert['base']);
|
||
self::assertFalse($fonts->hasLocal($theme->load()));
|
||
self::assertSame(['Lato 400', 'Lato 700', 'Lato 900'], $fonts->missing($theme->load()));
|
||
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::starter('Startseite'), 0), 1);
|
||
$ohne = $this->request('/');
|
||
self::assertStringNotContainsString('fonts.googleapis.com', $ohne->getContent());
|
||
self::assertStringNotContainsString('fonts.gstatic.com', $ohne->getContent());
|
||
self::assertStringNotContainsString('rel="preload" href="/assets/fonts', $ohne->getContent());
|
||
|
||
// Mit Dateien: die eigenen Dateien, und zwar **jeder** Schnitt vorab. Fehlte einer, läge
|
||
// er erst nach dem Stylesheet vor, und die Überschrift spränge sichtbar von der
|
||
// Ersatzschrift auf die richtige.
|
||
mkdir($this->path.'-fonts', 0o775, true);
|
||
foreach (['lato-400-latin.woff2', 'lato-700-latin.woff2', 'lato-900-latin.woff2'] as $datei) {
|
||
file_put_contents($this->path.'-fonts/'.$datei, 'wOF2test');
|
||
}
|
||
file_put_contents($this->path.'-fonts/fonts.css', "@font-face{font-family:'Lato'}");
|
||
self::assertTrue($fonts->hasLocal($theme->load()));
|
||
self::assertSame([
|
||
'/assets/fonts/lato-400-latin.woff2',
|
||
'/assets/fonts/lato-700-latin.woff2',
|
||
'/assets/fonts/lato-900-latin.woff2',
|
||
], $fonts->preload($theme->load()));
|
||
}
|
||
|
||
public function testTheStylesheetsGoOutAsOneFileWithoutComments(): void
|
||
{
|
||
// Vier Stylesheets sind vier Anfragen, und jede einzelne hält das Zeichnen der Seite auf.
|
||
$assets = $this->path.'-assets';
|
||
mkdir($assets, 0o775, true);
|
||
file_put_contents($assets.'/site.css', '/* Das Grundgerüst. */
|
||
.kant {
|
||
color: red;
|
||
}
|
||
');
|
||
file_put_contents($assets.'/design.css', '/* Gestaltung. */
|
||
.block-row { gap: 10px }
|
||
');
|
||
|
||
$bundle = new StyleBundle($assets);
|
||
$url = $bundle->url(false);
|
||
self::assertNotNull($url);
|
||
self::assertStringStartsWith('/assets/build/kant-', $url);
|
||
|
||
$inhalt = (string) file_get_contents($assets.'/build/'.basename((string) $url));
|
||
self::assertStringNotContainsString('Das Grundgerüst', $inhalt, 'Kommentare erklären den Quelltext, nicht den Browser.');
|
||
self::assertStringContainsString('color: red;', $inhalt, 'Leerzeichen innerhalb einer Zeile bleiben - in calc() tragen sie Bedeutung.');
|
||
self::assertStringContainsString('.block-row { gap: 10px }', $inhalt);
|
||
|
||
// Derselbe Inhalt ergibt denselben Namen; eine Änderung einen neuen.
|
||
self::assertSame($url, (new StyleBundle($assets))->url(false));
|
||
touch($assets.'/site.css', time() + 10);
|
||
clearstatcache();
|
||
self::assertNotSame($url, (new StyleBundle($assets))->url(false));
|
||
}
|
||
|
||
public function testTheTitleInSearchResultsCanDifferFromTheTitleOnThePage(): void
|
||
{
|
||
// Auf der Seite steht "Leistungen", im Suchergebnis hilft der lange Titel mit Ort und
|
||
// Firma. Bisher war beides dasselbe Feld, also musste man sich entscheiden.
|
||
$document = DemoContent::starter('Leistungen');
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||
$html = $this->request('/')->getContent();
|
||
self::assertStringContainsString('<title>Leistungen</title>', $html);
|
||
|
||
$this->posts->saveMetadata('page-1', ['seoTitle' => 'IT-Leistungen für Mittelbaden – Kant-IT Solutions'], [], [], (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['page-1']));
|
||
$html = $this->request('/')->getContent();
|
||
self::assertStringContainsString('<title>IT-Leistungen für Mittelbaden – Kant-IT Solutions</title>', $html);
|
||
self::assertStringContainsString('<meta property="og:title" content="IT-Leistungen für Mittelbaden – Kant-IT Solutions">', $html);
|
||
// Die Überschrift auf der Seite bleibt, was sie war.
|
||
self::assertStringContainsString('>Leistungen</h1>', $html);
|
||
|
||
// Wieder geleert heißt: der Titel der Seite gilt.
|
||
$this->posts->saveMetadata('page-1', ['seoTitle' => ''], [], [], (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['page-1']));
|
||
self::assertStringContainsString('<title>Leistungen</title>', $this->request('/')->getContent());
|
||
|
||
try {
|
||
$this->posts->saveMetadata('page-1', ['seoTitle' => str_repeat('a', 161)], [], [], (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['page-1']));
|
||
self::fail('Ein Titel über 160 Zeichen muss abgelehnt werden.');
|
||
} catch (InvalidPage) {
|
||
}
|
||
}
|
||
|
||
public function testThePreviewPictureForSharingActuallyReachesTheHead(): void
|
||
{
|
||
// Das Feld „Vorschaubild beim Teilen" gab es seit dem SEO-Paket - ausgegeben wurde es
|
||
// nie. Eine Einstellung, die speichert und nichts tut, ist schlimmer als keine.
|
||
$media = new MediaLibrary($this->db, $this->path.'-media');
|
||
$file = tempnam(sys_get_temp_dir(), 'kant-teilen-');
|
||
$image = imagecreatetruecolor(1800, 1000);
|
||
imagepng($image, $file);
|
||
imagedestroy($image);
|
||
$item = $media->upload(new UploadedFile($file, 'vorschau.png', test: true));
|
||
@unlink($file);
|
||
$media->update($item['id'], ['title' => 'Vorschau', 'alt' => 'Ein Blick auf die Website', 'archived' => false, 'version' => 1]);
|
||
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::starter('Startseite'), 0), 1);
|
||
|
||
// Ohne Bild: die kleine Karte, kein Verweis auf ein Bild, das es nicht gibt.
|
||
$ohne = $this->request('/')->getContent();
|
||
self::assertStringNotContainsString('og:image', $ohne);
|
||
self::assertStringContainsString('<meta name="twitter:card" content="summary">', $ohne);
|
||
self::assertStringContainsString('<meta property="og:locale" content="de_DE">', $ohne);
|
||
|
||
$this->db->update('page', ['og_media_id' => $item['id']], ['id' => 'page-1']);
|
||
$html = $this->request('/')->getContent();
|
||
// Absolut, weil die Adresse in fremden Diensten landet, und höchstens 1600 px breit.
|
||
self::assertStringContainsString('<meta property="og:image" content="https://cms.test/media/'.$item['id'].'/1600">', $html);
|
||
self::assertStringContainsString('<meta property="og:image:alt" content="Ein Blick auf die Website">', $html);
|
||
self::assertStringContainsString('<meta name="twitter:card" content="summary_large_image">', $html);
|
||
|
||
// Ein Beitrag ohne eigenes Vorschaubild nimmt sein Beitragsbild.
|
||
$id = $this->publishedPost('mit-bild', 'Mit Bild', '2026-02-01', ['Projekte'], []);
|
||
$this->posts->saveMetadata($id, ['featuredMediaId' => $item['id'], 'publishedAt' => '2026-02-01'], ['Projekte'], [], $this->pages->findForEditor($id)['version']);
|
||
$page = $this->pages->findForEditor($id);
|
||
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
|
||
$beitrag = $this->request('/mit-bild')->getContent();
|
||
self::assertStringContainsString('<meta property="og:image" content="https://cms.test/media/'.$item['id'].'/1600">', $beitrag);
|
||
// Und dasselbe Bild steht im strukturierten Datensatz des Beitrags.
|
||
self::assertStringContainsString('"image":"https://cms.test/media/'.$item['id'].'/1600"', $beitrag);
|
||
}
|
||
|
||
public function testTheSiteIconIsMadeFromAPictureOfTheLibrary(): void
|
||
{
|
||
// Umut: „dann brauchen wir Favicon-Möglichkeit zum Festlegen“. Aus einem Bild der
|
||
// Mediathek entstehen beim Speichern zwei quadratische PNG-Dateien; ohne Einstellung
|
||
// bleibt es beim mitgelieferten Zeichen.
|
||
self::assertStringContainsString('<link rel="icon" href="/favicon.svg"', $this->request('/')->getContent());
|
||
self::assertSame(404, $this->request('/favicon.png')->getStatusCode());
|
||
|
||
$media = new MediaLibrary($this->db, $this->path.'-media');
|
||
$file = tempnam(sys_get_temp_dir(), 'kant-symbol-');
|
||
// Breit und flach: der Ausschnitt muss quadratisch werden, nicht gestaucht.
|
||
$image = imagecreatetruecolor(600, 200);
|
||
imagepng($image, $file);
|
||
imagedestroy($image);
|
||
$item = $media->upload(new UploadedFile($file, 'logo.png', test: true));
|
||
@unlink($file);
|
||
|
||
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'faviconMediaId' => $item['id']]);
|
||
self::assertSame($item['id'], $this->site->load()['faviconMediaId']);
|
||
|
||
$html = $this->request('/')->getContent();
|
||
self::assertStringContainsString('<link rel="icon" href="/favicon.png?v='.substr($item['id'], 0, 8).'" type="image/png" sizes="32x32">', $html);
|
||
self::assertStringContainsString('apple-touch-icon.png', $html);
|
||
self::assertStringNotContainsString('favicon.svg', $html);
|
||
|
||
$symbol = $this->request('/favicon.png');
|
||
self::assertSame(200, $symbol->getStatusCode());
|
||
self::assertSame('image/png', $symbol->headers->get('Content-Type'));
|
||
$size = getimagesizefromstring((string) file_get_contents($this->path.'-media/favicon-32.png'));
|
||
self::assertSame([32, 32], [$size[0], $size[1]], 'Quadratisch, nicht gestaucht.');
|
||
self::assertSame(200, $this->request('/apple-touch-icon.png')->getStatusCode());
|
||
|
||
// Der Zuschnitt aus dem Werkzeug: links oben, ein Viertel der kurzen Seite. Der Verweis
|
||
// bekommt eine neue Version, sonst hielte der Browser am alten Ausschnitt fest.
|
||
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'faviconMediaId' => $item['id'], 'faviconCrop' => '0,0,0.25']);
|
||
self::assertSame('0,0,0.25', $this->site->load()['faviconCrop']);
|
||
self::assertStringContainsString('/favicon.png?v='.substr(md5($item['id'].'|0,0,0.25'), 0, 8), $this->request('/')->getContent());
|
||
foreach (['2,0,0.5', '0,0,0.01', 'mitte'] as $falsch) {
|
||
try {
|
||
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'faviconMediaId' => $item['id'], 'faviconCrop' => $falsch]);
|
||
self::fail('Ein ungültiger Ausschnitt muss abgelehnt werden: '.$falsch);
|
||
} catch (InvalidPage) {
|
||
}
|
||
}
|
||
|
||
// Ein winziges quadratisches Zeichen hat keine einzige der vier Regelbreiten - genau so
|
||
// sieht ein echtes Favicon aus, und genau daran ist die erste Fassung gescheitert.
|
||
$klein = tempnam(sys_get_temp_dir(), 'kant-zeichen-');
|
||
$zeichen = imagecreatetruecolor(192, 192);
|
||
imagepng($zeichen, $klein);
|
||
imagedestroy($zeichen);
|
||
$kleines = $media->upload(new UploadedFile($klein, 'zeichen.png', test: true));
|
||
@unlink($klein);
|
||
self::assertSame([192], array_column($kleines['variants'], 'width'));
|
||
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'faviconMediaId' => $kleines['id']]);
|
||
self::assertSame(200, $this->request('/favicon.png')->getStatusCode());
|
||
|
||
// Ein Bild, das es nicht gibt, wird nicht gespeichert - sonst zeigte der Kopf ins Leere.
|
||
try {
|
||
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'faviconMediaId' => str_repeat('a', 32)]);
|
||
self::fail('Eine unbekannte Bildkennung muss abgelehnt werden.');
|
||
} catch (InvalidPage) {
|
||
}
|
||
self::assertSame($kleines['id'], $this->site->load()['faviconMediaId']);
|
||
}
|
||
|
||
public function testGoogleGetsTheOwnSiteIconUnderEveryAddress(): void
|
||
{
|
||
// Umut: Google zeigte bei Elektro Bernhard nach dem Wechsel weiter das KantSites-K. Das
|
||
// eigene Symbol gab es nur in 32 px (Google will Vielfache von 48), und /favicon.svg lag
|
||
// als Datei in public/ und lieferte immer das mitgelieferte Zeichen.
|
||
self::assertFileDoesNotExist(__DIR__.'/../public/favicon.svg');
|
||
$ohne = $this->request('/favicon.svg');
|
||
self::assertSame(200, $ohne->getStatusCode());
|
||
self::assertSame('image/svg+xml', $ohne->headers->get('Content-Type'));
|
||
self::assertSame('/favicon.svg', $this->request('/favicon.ico')->headers->get('Location'));
|
||
self::assertSame(404, $this->request('/favicon-192.png')->getStatusCode());
|
||
|
||
$media = new MediaLibrary($this->db, $this->path.'-media');
|
||
$file = tempnam(sys_get_temp_dir(), 'kant-symbol-');
|
||
$image = imagecreatetruecolor(300, 300);
|
||
imagepng($image, $file);
|
||
imagedestroy($image);
|
||
$item = $media->upload(new UploadedFile($file, 'zeichen.png', test: true));
|
||
@unlink($file);
|
||
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'faviconMediaId' => $item['id']]);
|
||
$version = substr($item['id'], 0, 8);
|
||
|
||
self::assertStringContainsString('<link rel="icon" href="/favicon-192.png?v='.$version.'" type="image/png" sizes="192x192">', $this->request('/')->getContent());
|
||
foreach (['/favicon-192.png' => 192, '/favicon.ico' => 48] as $adresse => $kante) {
|
||
$antwort = $this->request($adresse);
|
||
self::assertSame(200, $antwort->getStatusCode(), $adresse);
|
||
self::assertSame('image/png', $antwort->headers->get('Content-Type'));
|
||
$size = getimagesizefromstring((string) file_get_contents($this->path.'-media/favicon-'.$kante.'.png'));
|
||
self::assertSame([$kante, $kante], [$size[0], $size[1]]);
|
||
}
|
||
$svg = $this->request('/favicon.svg');
|
||
self::assertSame(302, $svg->getStatusCode());
|
||
self::assertSame('/favicon-192.png?v='.$version, $svg->headers->get('Location'));
|
||
|
||
// Wer sein Symbol vor den neuen Größen gesetzt hat, bekommt sie beim ersten Abruf.
|
||
unlink($this->path.'-media/favicon-48.png');
|
||
unlink($this->path.'-media/favicon-192.png');
|
||
self::assertSame(200, $this->request('/favicon-192.png')->getStatusCode());
|
||
self::assertFileExists($this->path.'-media/favicon-48.png');
|
||
|
||
// Symbol entfernt: die Dateien liegen noch da, gelten aber nicht mehr.
|
||
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'faviconMediaId' => '']);
|
||
self::assertSame(200, $this->request('/favicon.svg')->getStatusCode());
|
||
self::assertSame(404, $this->request('/favicon.png')->getStatusCode());
|
||
}
|
||
|
||
public function testUnderlinedLinksAreAChoiceAndOnlyApplyToRunningText(): void
|
||
{
|
||
// Umut: „links haben alle underlined“. Im Menü, auf Karten und über Bildern hat ein
|
||
// Unterstrich nichts zu suchen; im Fließtext ist er die einzige Kennzeichnung neben der
|
||
// Farbe - deshalb dort ein Schalter statt einer stillen Entscheidung.
|
||
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
|
||
self::assertStringContainsString('.post-image{display:block;position:relative;overflow:hidden;text-decoration:none}', $css);
|
||
self::assertStringNotContainsString('[aria-current=page]{text-decoration:underline}', $css);
|
||
|
||
$theme = new Theme($this->db);
|
||
self::assertTrue($theme->load()['base']['linkUnderline'], 'Standard: unterstrichen.');
|
||
self::assertStringContainsString('.kant .block-text a,.kant .block-list a,.kant .post-excerpt a{color:var(--k-link);text-decoration:underline}', $theme->css($theme->load()));
|
||
|
||
$gespeichert = $theme->load();
|
||
$theme->save($gespeichert['colors'], $gespeichert['fonts'], [...$gespeichert['base'], 'linkUnderline' => null]);
|
||
self::assertFalse($theme->load()['base']['linkUnderline']);
|
||
$css = $theme->css($theme->load());
|
||
self::assertStringContainsString('text-decoration:none}', $css);
|
||
// Beim Überfahren bleibt der Strich in jedem Fall - sonst ist ein Link im Text nicht zu finden.
|
||
self::assertStringContainsString('.kant .block-text a:hover,.kant .block-list a:hover,.kant .post-excerpt a:hover{text-decoration:underline}', $css);
|
||
}
|
||
|
||
public function testAWebsiteWithoutPostsNeverMentionsPosts(): void
|
||
{
|
||
// Umut: „Können wir Beiträge optional machen? Wenn es keine gibt, überall ausblenden.“
|
||
// Teilweise war es das schon - die Sitemap ließ das Archiv weg. Das Archiv selbst, der
|
||
// Feed, der Verweis im Kopf und der Kasten „hier erscheinen Beiträge“ blieben.
|
||
$document = DemoContent::starter('Ohne Blog');
|
||
$document['schemaVersion'] = 18;
|
||
$document['blocks'][] = ['id' => 'liste', 'type' => 'postlist', 'props' => ['count' => 4, 'category' => '', 'tag' => '']];
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||
|
||
// Ohne einen einzigen Beitrag: kein Archiv, kein Feed.
|
||
self::assertSame(404, $this->request('/beitraege')->getStatusCode());
|
||
self::assertSame(404, $this->request('/feed.xml')->getStatusCode());
|
||
|
||
$html = $this->request('/')->getContent();
|
||
self::assertStringNotContainsString('application/rss+xml', $html, 'Kein Verweis auf einen Feed, den es nicht gibt.');
|
||
self::assertStringNotContainsString('post-empty', $html, 'Kein Kasten, der Beiträge ankündigt.');
|
||
self::assertStringNotContainsString('Hier erscheinen die Beiträge', $html);
|
||
// Die Sitemap führt das Archiv ebenfalls nicht.
|
||
self::assertStringNotContainsString('/beitraege', $this->request('/sitemap.xml')->getContent());
|
||
|
||
// Sobald ein Beitrag veröffentlicht ist, ist alles davon wieder da.
|
||
$this->publishedPost('erster', 'Erster Beitrag', '2026-02-01', ['Projekte'], []);
|
||
self::assertSame(200, $this->request('/beitraege')->getStatusCode());
|
||
self::assertSame(200, $this->request('/feed.xml')->getStatusCode());
|
||
$mit = $this->request('/')->getContent();
|
||
self::assertStringContainsString('application/rss+xml', $mit);
|
||
self::assertStringContainsString('Erster Beitrag', $mit);
|
||
self::assertStringContainsString('/beitraege', $this->request('/sitemap.xml')->getContent());
|
||
|
||
// Im Editor bleibt der Hinweis stehen, sonst sucht jemand den Fehler an der leeren Stelle.
|
||
$template = (string) file_get_contents(__DIR__.'/../templates/blocks/post-list.html.twig');
|
||
self::assertStringContainsString('{% if editing|default(false) %}<p class="post-empty">', $template);
|
||
}
|
||
|
||
public function testAPostListCanPageThroughEverythingInsteadOfCuttingOff(): void
|
||
{
|
||
// Umut: „fehlt die Pagination bei Projekte? Es werden nur 12 angezeigt.“ Richtig: eine
|
||
// Auswahl zeigt die ersten n, eine Übersicht muss blättern - sonst verschweigt sie, dass
|
||
// es mehr gibt. Das ist die eigene Regel aus Arbeitspaket M, die für die Beitragsliste
|
||
// eines Dokuments bis hierher nicht galt.
|
||
for ($i = 1; $i <= 7; ++$i) {
|
||
$this->publishedPost('projekt-'.$i, 'Projekt '.$i, sprintf('2026-02-%02d', $i), ['Projekte'], []);
|
||
}
|
||
$document = DemoContent::starter('Übersicht');
|
||
$document['schemaVersion'] = 17;
|
||
$document['blocks'][] = ['id' => 'liste', 'type' => 'postlist', 'props' => [
|
||
'count' => 3, 'category' => 'projekte', 'tag' => '', 'paginate' => true,
|
||
]];
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||
|
||
$erste = $this->request('/')->getContent();
|
||
self::assertSame(3, substr_count($erste, 'post-card'));
|
||
self::assertStringContainsString('Seite 1 von 3', $erste);
|
||
self::assertStringContainsString('7 Beiträge', $erste);
|
||
self::assertStringNotContainsString('Neuere', $erste, 'Auf der ersten Seite gibt es nichts Neueres.');
|
||
|
||
$dritte = $this->request('/?seite=3')->getContent();
|
||
self::assertStringContainsString('Seite 3 von 3', $dritte);
|
||
self::assertSame(1, substr_count($dritte, 'post-card'));
|
||
self::assertStringNotContainsString('Ältere', $dritte);
|
||
|
||
// Eine Seitenzahl jenseits des Endes landet auf der letzten Seite statt im Leeren.
|
||
self::assertStringContainsString('Seite 3 von 3', $this->request('/?seite=99')->getContent());
|
||
|
||
// Ohne den Schalter bleibt es eine Auswahl: drei Karten, keine Leiste.
|
||
$document['blocks'][array_key_last($document['blocks'])]['props']['paginate'] = false;
|
||
$revision = $this->pages->saveDraft('page-1', $document, $this->pages->findForEditor('page-1')['version']);
|
||
$this->pages->publish('page-1', $revision, $this->pages->findForEditor('page-1')['version']);
|
||
$auswahl = $this->request('/?seite=2')->getContent();
|
||
self::assertSame(3, substr_count($auswahl, 'post-card'));
|
||
self::assertStringNotContainsString('Seite 1 von', $auswahl);
|
||
}
|
||
|
||
public function testAPostCardReactsToThePointerAndToTheKeyboard(): void
|
||
{
|
||
// Umut: „es gibt keinen Mausover-Effekt, das fehlt“. Eine Karte, die sich beim Zeigen
|
||
// nicht rührt, wirkt tot. Der Hinweis liegt über dem Bild, ist für Vorlesesoftware aber
|
||
// nichts Neues - der Titel darunter führt zum selben Ziel.
|
||
$this->publishedPost('projekt-eins', 'Projekt eins', '2026-02-01', ['Projekte'], []);
|
||
// Der Hinweis liegt über dem Beitragsbild, also braucht der Beitrag eines.
|
||
$media = new MediaLibrary($this->db, $this->path.'-media');
|
||
$file = tempnam(sys_get_temp_dir(), 'kant-karte-');
|
||
$image = imagecreatetruecolor(600, 400);
|
||
imagepng($image, $file);
|
||
imagedestroy($image);
|
||
$item = $media->upload(new UploadedFile($file, 'karte.png', test: true));
|
||
@unlink($file);
|
||
$id = $this->db->fetchOne('SELECT id FROM page WHERE path = ?', ['projekt-eins']);
|
||
$this->posts->saveMetadata($id, ['featuredMediaId' => $item['id'], 'publishedAt' => '2026-02-01'], ['Projekte'], [], (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', [$id]));
|
||
|
||
$document = DemoContent::starter('Startseite');
|
||
$document['schemaVersion'] = 17;
|
||
$document['blocks'][] = ['id' => 'raster', 'type' => 'postlist', 'props' => ['count' => 5, 'category' => 'projekte', 'tag' => '']];
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||
|
||
$html = $this->request('/')->getContent();
|
||
self::assertStringContainsString('<span class="post-view" aria-hidden="true">Ansehen</span>', $html);
|
||
|
||
// Die Bewegung spielt für alle; beachtet die Website die Browservorgabe, nimmt ein
|
||
// Überschreibblock hinter der Bewegungsschranke nur die Fahrt weg, der Hinweis bleibt.
|
||
$effects = (string) file_get_contents(__DIR__.'/../public/assets/enhancements.css');
|
||
self::assertStringContainsString('.post-card:hover, .post-card:focus-within {', $effects);
|
||
self::assertStringContainsString('transform: scale(1.06)', $effects);
|
||
$schranke = explode('@media (prefers-reduced-motion: reduce) {', $effects);
|
||
self::assertCount(2, $schranke, 'Genau ein Überschreibblock für „Bewegung reduzieren“.');
|
||
self::assertStringContainsString('html.motion-respect', $schranke[1]);
|
||
self::assertStringContainsString('transform: none', $schranke[1]);
|
||
self::assertStringNotContainsString('html.motion-respect', $schranke[0], 'Ohne Schranke gilt die Klasse nicht.');
|
||
|
||
// Und über die Tastatur erreichbar: der Zustand hängt auch an :focus-within.
|
||
$site = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
|
||
self::assertStringContainsString('.post-card:hover .post-view,.post-card:focus-within .post-view{opacity:1}', $site);
|
||
}
|
||
|
||
public function testAPostListCanLookLikeAProjectGridInsteadOfABlogArchive(): void
|
||
{
|
||
// Beim Nachbau von kant-it.de fehlte genau das: das Projektraster dort zeigt Bild und
|
||
// Titel, die Beitragsliste zeigte zusätzlich Datum, Autor, Auszug und Kategorie und sah
|
||
// damit aus wie ein Blog. Was eine Karte zeigt, ist jetzt einstellbar.
|
||
$this->publishedPost('projekt-eins', 'Projekt eins', '2026-02-01', ['Projekte'], []);
|
||
$id = $this->db->fetchOne('SELECT id FROM page WHERE path = ?', ['projekt-eins']);
|
||
$this->posts->saveMetadata($id, ['excerpt' => 'Ein Auszug, der im Raster stört.', 'author' => 'Umut', 'publishedAt' => '2026-02-01'], ['Projekte'], [], (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', [$id]));
|
||
|
||
$document = DemoContent::starter('Startseite');
|
||
$document['schemaVersion'] = 17;
|
||
$document['blocks'][] = ['id' => 'raster', 'type' => 'postlist', 'props' => [
|
||
'count' => 5, 'category' => 'projekte', 'tag' => '',
|
||
'showMeta' => false, 'showExcerpt' => false, 'showTerms' => false,
|
||
], 'children' => []];
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||
|
||
$html = $this->request('/')->getContent();
|
||
self::assertStringContainsString('Projekt eins', $html, 'Der Titel bleibt.');
|
||
self::assertStringNotContainsString('Ein Auszug, der im Raster stört.', $html);
|
||
self::assertStringNotContainsString('von Umut', $html);
|
||
self::assertStringNotContainsString('post-terms', $html);
|
||
|
||
// Und die Gegenprobe: ohne die Schalter bleibt alles wie vorher. Das ist der Fall, der
|
||
// fast danebengegangen wäre - „false|default(true)" ist in Twig true, weil der Filter auch
|
||
// auf leere Werte greift. Die Einstellung hätte sich speichern lassen und nichts getan.
|
||
$document['blocks'][array_key_last($document['blocks'])]['props'] = ['count' => 5, 'category' => 'projekte', 'tag' => ''];
|
||
$revision = $this->pages->saveDraft('page-1', $document, $this->pages->findForEditor('page-1')['version']);
|
||
$this->pages->publish('page-1', $revision, $this->pages->findForEditor('page-1')['version']);
|
||
$html = $this->request('/')->getContent();
|
||
self::assertStringContainsString('Ein Auszug, der im Raster stört.', $html);
|
||
self::assertStringContainsString('von Umut', $html);
|
||
self::assertStringContainsString('post-terms', $html);
|
||
}
|
||
|
||
public function testFeedListsPublishedPostsWithAbsoluteAddresses(): void
|
||
{
|
||
$this->publishedPost('erster', 'Erster Beitrag', '2026-02-01', ['Projekte'], []);
|
||
$this->pages->createPage('geheim', null, 'post', DemoContent::starter('Geheim', 'post'));
|
||
|
||
$response = $this->request('/feed.xml');
|
||
self::assertSame(200, $response->getStatusCode());
|
||
self::assertStringContainsString('application/xml', (string) $response->headers->get('Content-Type'));
|
||
$xml = $response->getContent();
|
||
self::assertStringContainsString('<link>https://cms.test/erster</link>', $xml);
|
||
self::assertStringContainsString('<category>Projekte</category>', $xml);
|
||
self::assertStringNotContainsString('Geheim', $xml);
|
||
self::assertNotFalse(simplexml_load_string($xml), 'The feed must be well-formed XML.');
|
||
}
|
||
|
||
/**
|
||
* @return iterable<string, array{array, array, array}>
|
||
*/
|
||
public static function rejectedPostMetadata(): iterable
|
||
{
|
||
yield 'oversized excerpt' => [['excerpt' => str_repeat('a', 501)], [], []];
|
||
yield 'oversized author' => [['author' => str_repeat('a', 121)], [], []];
|
||
yield 'malformed date' => [['publishedAt' => '09.03.2026'], [], []];
|
||
yield 'unknown media' => [['featuredMediaId' => str_repeat('a', 32)], [], []];
|
||
yield 'malformed media id' => [['featuredMediaId' => 'not-a-media-id'], [], []];
|
||
yield 'unusable term name' => [[], ['---'], []];
|
||
}
|
||
|
||
#[DataProvider('rejectedPostMetadata')]
|
||
public function testRejectsInvalidPostMetadataWithoutWriting(array $values, array $categories, array $tags): void
|
||
{
|
||
$id = $this->pages->createPage('beitrag', null, 'post', DemoContent::starter('Beitrag', 'post'));
|
||
$version = $this->pages->findForEditor($id)['version'];
|
||
try {
|
||
$this->posts->saveMetadata($id, $values, $categories, $tags, $version);
|
||
self::fail('Expected the metadata to be rejected.');
|
||
} catch (InvalidPage) {
|
||
}
|
||
self::assertSame($version, $this->pages->findForEditor($id)['version']);
|
||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page_term'));
|
||
}
|
||
|
||
public function testDeletingAPostReleasesItsTermAssignments(): void
|
||
{
|
||
$id = $this->publishedPost('weg', 'Weg', '2026-01-01', ['Projekte'], ['Tag']);
|
||
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page_term'));
|
||
$this->pages->deletePage($id, $this->pages->findForEditor($id)['version']);
|
||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page_term'));
|
||
// The terms themselves stay: they are reusable labels, not content of one post.
|
||
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM term'));
|
||
// Der Begriff existiert noch, sein Archiv hat aber nichts mehr zu zeigen: eine leere Liste
|
||
// unter eigener Adresse ist eine Sackgasse, also 404 statt einer Seite mit Überschrift.
|
||
self::assertSame(404, $this->request('/kategorie/projekte')->getStatusCode());
|
||
self::assertSame(0, $this->posts->published(1, $this->posts->findTerm('category', 'projekte')['id'])['total']);
|
||
|
||
// Ein Beitrag in einer anderen Kategorie bringt deren Archiv zurück, dieses hier nicht.
|
||
$this->publishedPost('bleibt', 'Bleibt', '2026-01-02', ['Technik'], []);
|
||
self::assertSame(200, $this->request('/kategorie/technik')->getStatusCode());
|
||
self::assertSame(404, $this->request('/kategorie/projekte')->getStatusCode());
|
||
}
|
||
|
||
public function testPublishingStampsAMissingDateButKeepsAGivenOne(): void
|
||
{
|
||
$given = $this->publishedPost('mit-datum', 'Mit Datum', '2024-07-04');
|
||
self::assertStringStartsWith('2024-07-04', (string) $this->db->fetchOne('SELECT published_at FROM page WHERE id = ?', [$given]));
|
||
|
||
$without = $this->pages->createPage('ohne-datum', null, 'post', DemoContent::starter('Ohne Datum', 'post'));
|
||
$page = $this->pages->findForEditor($without);
|
||
$this->pages->publish($without, $page['draft_revision_id'], $page['version']);
|
||
self::assertNotNull($this->db->fetchOne('SELECT published_at FROM page WHERE id = ?', [$without]));
|
||
|
||
// An ordinary page is never stamped.
|
||
$plain = $this->publishedPage('normale-seite', 'Normale Seite');
|
||
self::assertNull($this->db->fetchOne('SELECT published_at FROM page WHERE id = ?', [$plain]));
|
||
}
|
||
|
||
private function publishedPost(string $slug, string $title, string $date, array $categories = [], array $tags = []): string
|
||
{
|
||
$id = $this->pages->createPage($slug, null, 'post', DemoContent::starter($title, 'post'));
|
||
$this->posts->saveMetadata($id, ['publishedAt' => $date, 'author' => 'Umut'], $categories, $tags, $this->pages->findForEditor($id)['version']);
|
||
$page = $this->pages->findForEditor($id);
|
||
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
|
||
|
||
return $id;
|
||
}
|
||
|
||
/** A page built from the section kinds the pilot start page actually uses. */
|
||
private static function pilotDocument(): array
|
||
{
|
||
return [
|
||
'schemaVersion' => 6,
|
||
'title' => 'Kant-IT',
|
||
'description' => 'Ihr IT-Partner.',
|
||
'blocks' => [
|
||
['id' => 'hero', 'type' => 'section', 'children' => [
|
||
['id' => 'h1', 'type' => 'heading', 'props' => ['text' => 'Digitale Exzellenz', 'level' => 1]],
|
||
['id' => 'stat1', 'type' => 'stat', 'props' => ['value' => '15+', 'label' => 'Jahre Erfahrung']],
|
||
['id' => 'sp', 'type' => 'spacer', 'props' => ['size' => 'large']],
|
||
['id' => 'hr', 'type' => 'divider'],
|
||
]],
|
||
['id' => 'leistungen', 'type' => 'section', 'children' => [
|
||
['id' => 'card1', 'type' => 'iconcard', 'props' => ['icon' => 'app', 'title' => 'App-Entwicklung', 'text' => 'Für iOS und Android.', 'href' => '/leistungen']],
|
||
['id' => 'card2', 'type' => 'iconcard', 'props' => ['icon' => 'shop', 'title' => 'E-Commerce', 'text' => '', 'href' => '']],
|
||
]],
|
||
['id' => 'stimmen', 'type' => 'section', 'children' => [
|
||
['id' => 'q1', 'type' => 'quote', 'props' => ['text' => 'Kant-IT denkt mit.', 'author' => 'Malerservice Drews', 'role' => 'Zufriedener Kunde', 'rating' => 5]],
|
||
['id' => 'logos', 'type' => 'logos', 'children' => [
|
||
['id' => 'logo1', 'type' => 'image', 'props' => ['src' => '/assets/image-placeholder.svg', 'alt' => 'Partner', 'decorative' => false, 'width' => 200, 'height' => 80]],
|
||
]],
|
||
]],
|
||
['id' => 'fragen', 'type' => 'accordion', 'children' => [
|
||
['id' => 'f1', 'type' => 'accordionitem', 'props' => ['title' => 'Wie schnell antwortet ihr?', 'open' => true], 'children' => [
|
||
['id' => 'f1t', 'type' => 'text', 'props' => ['text' => 'In der Regel innerhalb von 24 Stunden.']],
|
||
]],
|
||
]],
|
||
['id' => 'kontakt', 'type' => 'section', 'children' => [
|
||
['id' => 'cta1', 'type' => 'cta', 'props' => ['title' => 'Bereit?', 'text' => 'Kostenlose Erstberatung.', 'buttonText' => 'Jetzt anfragen', 'href' => '#kontakt']],
|
||
['id' => 'adr', 'type' => 'contact', 'props' => ['phone' => '+49 7221 99 57 150', 'email' => 'info@kant-it.de', 'address' => 'Ooser Hauptstr. 51, Baden-Baden']],
|
||
]],
|
||
],
|
||
];
|
||
}
|
||
|
||
public function testPilotSectionsRoundTripAndRenderWithoutJavaScript(): void
|
||
{
|
||
$document = self::pilotDocument();
|
||
$revision = $this->pages->saveDraft('page-1', $document, 0);
|
||
self::assertSame($document, json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]), true));
|
||
$this->pages->publish('page-1', $revision, 1);
|
||
|
||
$html = $this->request('/')->getContent();
|
||
self::assertSame(200, $this->request('/')->getStatusCode());
|
||
// Every new block reaches the output.
|
||
foreach (['block-iconcard', 'block-stat', 'block-quote', 'block-logos', 'block-cta', 'block-contact', 'block-divider', 'block-spacer', 'block-accordionitem'] as $marker) {
|
||
self::assertStringContainsString($marker, $html, $marker);
|
||
}
|
||
// Contact details become real links, with the phone number stripped for the href.
|
||
self::assertStringContainsString('href="tel:+4972219957150"', $html);
|
||
self::assertStringContainsString('href="mailto:info@kant-it.de"', $html);
|
||
// The accordion is a native details element, so it works without scripting.
|
||
self::assertStringContainsString('<details id="f1" class="block-accordionitem" open>', $html);
|
||
self::assertStringContainsString('<summary>Wie schnell antwortet ihr?</summary>', $html);
|
||
// Icons are inline SVG, not an icon font or an external request.
|
||
self::assertStringContainsString('<svg class="block-icon"', $html);
|
||
self::assertStringNotContainsString('<script', $html);
|
||
// The rating is announced for screen readers rather than left as decorative stars.
|
||
self::assertStringContainsString('Bewertung: 5 von 5', $html);
|
||
}
|
||
|
||
/**
|
||
* Jedes erlaubte Symbol braucht eine Zeichnung, und die Symbolfarbe muss am SVG ankommen.
|
||
*
|
||
* Ohne den ersten Teil rendert ein Name aus der Liste ein leeres `<path d="">`: unsichtbar,
|
||
* ohne Fehler, erst auf der fertigen Seite bemerkbar. Der zweite Teil sichert die Regel, die
|
||
* das eingebettete SVG die Farbe seiner Hülle erben lässt - es trägt dieselbe Klasse und
|
||
* setzte sonst die Akzentfarbe erneut.
|
||
*/
|
||
public function testEveryAllowedIconHasADrawingAndTakesItsColourFromTheBlock(): void
|
||
{
|
||
$template = (string) file_get_contents(__DIR__.'/../templates/blocks/icon.html.twig');
|
||
foreach (DocumentValidator::ICONS as $icon) {
|
||
self::assertMatchesRegularExpression(
|
||
// Ein Name mit Bindestrich braucht in Twig Anfuehrungszeichen um den Schluessel.
|
||
'/\R\s{4}\'?'.preg_quote($icon, '/')."'?: '[^']{4,}',?\R/",
|
||
$template,
|
||
sprintf('Das Symbol „%s“ steht in der Liste, hat aber keine Zeichnung.', $icon),
|
||
);
|
||
}
|
||
self::assertStringContainsString('.block-icon .block-icon{color:inherit}', (string) file_get_contents(__DIR__.'/../public/assets/site.css'));
|
||
}
|
||
|
||
/**
|
||
* @return iterable<string, array{array}>
|
||
*/
|
||
public static function rejectedV6Blocks(): iterable
|
||
{
|
||
$wrap = static function (array $block): array {
|
||
$document = DemoContent::starter('Titel');
|
||
$document['schemaVersion'] = 6;
|
||
$document['blocks'][] = $block;
|
||
|
||
return [$document];
|
||
};
|
||
yield 'unknown icon' => $wrap(['id' => 'x', 'type' => 'iconcard', 'props' => ['icon' => 'rocket', 'title' => 'T', 'text' => '']]);
|
||
yield 'iconcard without title' => $wrap(['id' => 'x', 'type' => 'iconcard', 'props' => ['icon' => 'app', 'title' => ' ', 'text' => '']]);
|
||
yield 'iconcard with unsafe link' => $wrap(['id' => 'x', 'type' => 'iconcard', 'props' => ['icon' => 'app', 'title' => 'T', 'text' => '', 'href' => 'javascript:alert(1)']]);
|
||
yield 'rating out of range' => $wrap(['id' => 'x', 'type' => 'quote', 'props' => ['text' => 'T', 'author' => 'A', 'role' => '', 'rating' => 6]]);
|
||
yield 'rating not a number' => $wrap(['id' => 'x', 'type' => 'quote', 'props' => ['text' => 'T', 'author' => 'A', 'role' => '', 'rating' => '5']]);
|
||
yield 'invalid phone' => $wrap(['id' => 'x', 'type' => 'contact', 'props' => ['phone' => 'ruf mich an', 'email' => '', 'address' => '']]);
|
||
yield 'invalid email' => $wrap(['id' => 'x', 'type' => 'contact', 'props' => ['phone' => '', 'email' => 'keine-adresse', 'address' => '']]);
|
||
yield 'unknown spacer size' => $wrap(['id' => 'x', 'type' => 'spacer', 'props' => ['size' => 'gigantic']]);
|
||
yield 'cta without target' => $wrap(['id' => 'x', 'type' => 'cta', 'props' => ['title' => 'T', 'text' => '', 'buttonText' => 'Los', 'href' => 'ftp://example.com']]);
|
||
yield 'logos with a text child' => $wrap(['id' => 'x', 'type' => 'logos', 'children' => [['id' => 'y', 'type' => 'text', 'props' => ['text' => 'kein Logo']]]]);
|
||
yield 'accordion with a text child' => $wrap(['id' => 'x', 'type' => 'accordion', 'children' => [['id' => 'y', 'type' => 'text', 'props' => ['text' => 'direkt']]]]);
|
||
yield 'accordion entry outside an accordion' => $wrap(['id' => 'x', 'type' => 'accordionitem', 'props' => ['title' => 'T', 'open' => false], 'children' => []]);
|
||
yield 'divider with children' => $wrap(['id' => 'x', 'type' => 'divider', 'children' => [['id' => 'y', 'type' => 'text', 'props' => ['text' => 'a']]]]);
|
||
}
|
||
|
||
#[DataProvider('rejectedV6Blocks')]
|
||
public function testRejectsInvalidV6BlocksBeforeWriting(array $document): void
|
||
{
|
||
$this->expectException(InvalidDocument::class);
|
||
$this->pages->saveDraft('page-1', $document, 0);
|
||
}
|
||
|
||
public function testV6BlocksNeedSchemaSixAndStayRejectedInOlderDocuments(): void
|
||
{
|
||
$document = DemoContent::starter('Titel');
|
||
$document['schemaVersion'] = 5;
|
||
$document['blocks'][] = ['id' => 'karte', 'type' => 'iconcard', 'props' => ['icon' => 'app', 'title' => 'T', 'text' => '']];
|
||
try {
|
||
$this->pages->saveDraft('page-1', $document, 0);
|
||
self::fail('Expected schema v5 to reject a v6 block.');
|
||
} catch (InvalidDocument) {
|
||
}
|
||
$document['schemaVersion'] = 6;
|
||
self::assertNotEmpty($this->pages->saveDraft('page-1', $document, 0));
|
||
}
|
||
|
||
private static function tabsDocument(): array
|
||
{
|
||
$document = DemoContent::starter('Mit Tabs');
|
||
$document['schemaVersion'] = 7;
|
||
$document['blocks'][] = ['id' => 'tabs', 'type' => 'tabs', 'children' => [
|
||
['id' => 'tab-web', 'type' => 'tabitem', 'props' => ['title' => 'Web Design'], 'children' => [
|
||
['id' => 'tab-web-text', 'type' => 'text', 'props' => ['text' => 'Inhalt des ersten Tabs.']],
|
||
]],
|
||
['id' => 'tab-app', 'type' => 'tabitem', 'props' => ['title' => 'App-Entwicklung'], 'children' => [
|
||
['id' => 'tab-app-text', 'type' => 'text', 'props' => ['text' => 'Inhalt des zweiten Tabs.']],
|
||
]],
|
||
]];
|
||
|
||
return $document;
|
||
}
|
||
|
||
public function testTabsRenderCompletelyWithoutScriptingAndLoadTheEnhancementOnlyWhereNeeded(): void
|
||
{
|
||
$document = self::tabsDocument();
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||
|
||
$response = $this->request('/');
|
||
$html = $response->getContent();
|
||
// Without the script the page still carries both panels, their headings and their content.
|
||
self::assertStringContainsString('Inhalt des ersten Tabs.', $html);
|
||
self::assertStringContainsString('Inhalt des zweiten Tabs.', $html);
|
||
self::assertStringContainsString('<h3 class="tabs-heading">Web Design</h3>', $html);
|
||
self::assertStringContainsString('<a href="#tab-app">App-Entwicklung</a>', $html);
|
||
// Neither panel is hidden server side; hiding is the script's job alone.
|
||
self::assertStringContainsString('<section id="tab-web" class="block-tabitem">', $html);
|
||
self::assertStringContainsString('<section id="tab-app" class="block-tabitem">', $html);
|
||
// The enhancement is an external file; inline script never appears.
|
||
// Die Fassung haengt als Parameter an: nach einem Update laedt niemand ein altes Skript
|
||
// aus dem Zwischenspeicher.
|
||
self::assertStringContainsString('<script src="/assets/tabs.js?v=', $html);
|
||
// Scripting is permitted for this page only, and only from this origin.
|
||
self::assertStringContainsString("script-src 'self'", (string) $response->headers->get('Content-Security-Policy'));
|
||
|
||
// A page without tabs keeps the strict policy and loads no script at all.
|
||
$plain = $this->publishedPage('ohne-tabs', 'Ohne Tabs');
|
||
self::assertNotNull($this->pages->findForEditor($plain));
|
||
$other = $this->request('/ohne-tabs');
|
||
self::assertStringNotContainsString('tabs.js', $other->getContent());
|
||
self::assertStringContainsString("script-src 'none'", (string) $other->headers->get('Content-Security-Policy'));
|
||
self::assertSame(404, $this->request('/gibt-es-nicht')->getStatusCode());
|
||
self::assertStringContainsString("script-src 'none'", (string) $this->request('/gibt-es-nicht')->headers->get('Content-Security-Policy'));
|
||
|
||
// Tabs im Fussbereich: die Seite selbst hat keine, braucht das Skript aber trotzdem - und
|
||
// eine Richtlinie, die es zulaesst. Vorher blieb der Fuss eine Ankerliste mit blockiertem
|
||
// Skript.
|
||
$footer = $this->partials->findByKind('footer');
|
||
$this->partials->save($footer['id'], [...$footer['document'], 'schemaVersion' => max((int) $footer['document']['schemaVersion'], 7), 'blocks' => [...$footer['document']['blocks'], ...array_slice($document['blocks'], -1)]], (int) $footer['version']);
|
||
$withFooterTabs = $this->request('/ohne-tabs');
|
||
self::assertStringContainsString('<script src="/assets/tabs.js?v=', $withFooterTabs->getContent());
|
||
self::assertStringContainsString("script-src 'self'", (string) $withFooterTabs->headers->get('Content-Security-Policy'));
|
||
}
|
||
|
||
public function testTabsSurviveSavingAndRejectMisplacedEntries(): void
|
||
{
|
||
$document = self::tabsDocument();
|
||
$revision = $this->pages->saveDraft('page-1', $document, 0);
|
||
self::assertSame($document, json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]), true));
|
||
|
||
// A tab outside a tab group, and a tab group holding anything else, are both refused.
|
||
$loose = DemoContent::starter('Titel');
|
||
$loose['schemaVersion'] = 7;
|
||
$loose['blocks'][] = ['id' => 'x', 'type' => 'tabitem', 'props' => ['title' => 'Allein'], 'children' => []];
|
||
try {
|
||
$this->pages->saveDraft('page-1', $loose, 1);
|
||
self::fail('Expected a tab outside a tab group to be rejected.');
|
||
} catch (InvalidDocument) {
|
||
}
|
||
$wrong = DemoContent::starter('Titel');
|
||
$wrong['schemaVersion'] = 7;
|
||
$wrong['blocks'][] = ['id' => 'x', 'type' => 'tabs', 'children' => [['id' => 'y', 'type' => 'text', 'props' => ['text' => 'direkt']]]];
|
||
try {
|
||
$this->pages->saveDraft('page-1', $wrong, 1);
|
||
self::fail('Expected a tab group with a text child to be rejected.');
|
||
} catch (InvalidDocument) {
|
||
}
|
||
// And tabs need schema v7.
|
||
$old = self::tabsDocument();
|
||
$old['schemaVersion'] = 6;
|
||
$this->expectException(InvalidDocument::class);
|
||
$this->pages->saveDraft('page-1', $old, 1);
|
||
}
|
||
|
||
public function testSitemapListsOnlyPublishedIndexablePagesAndPointsRobotsAtIt(): void
|
||
{
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||
$hidden = $this->publishedPage('intern', 'Intern');
|
||
$this->posts->saveMetadata($hidden, ['robots' => 'noindex'], [], [], $this->pages->findForEditor($hidden)['version']);
|
||
$this->publishedPage('offen', 'Offen');
|
||
$this->pages->createPage('entwurf', null, 'page', DemoContent::starter('Entwurf'));
|
||
$post = $this->publishedPost('ein-beitrag', 'Ein Beitrag', '2026-02-01', ['Projekte'], []);
|
||
// Neu veroeffentlicht nach einer Aenderung: lastmod ist das Datum der Fassung, nicht das
|
||
// der ersten Veroeffentlichung.
|
||
$edited = $this->pages->findForEditor($post);
|
||
$this->pages->publish($post, $this->pages->saveDraft($post, $edited['document'], $edited['version']), $edited['version'] + 1);
|
||
|
||
$sitemap = $this->request('/sitemap.xml');
|
||
self::assertSame(200, $sitemap->getStatusCode());
|
||
self::assertStringContainsString('application/xml', (string) $sitemap->headers->get('Content-Type'));
|
||
$xml = $sitemap->getContent();
|
||
self::assertNotFalse(simplexml_load_string($xml), 'The sitemap must be well-formed XML.');
|
||
self::assertStringContainsString('<loc>https://cms.test/</loc>', $xml);
|
||
self::assertStringContainsString('<loc>https://cms.test/offen</loc>', $xml);
|
||
self::assertStringContainsString('<loc>https://cms.test/beitraege</loc>', $xml);
|
||
self::assertStringContainsString('<loc>https://cms.test/kategorie/projekte</loc>', $xml);
|
||
self::assertStringContainsString("<loc>https://cms.test/ein-beitrag</loc>\n <lastmod>".gmdate('Y-m-d').'</lastmod>', $xml);
|
||
// A noindex page and an unpublished page have no business in a sitemap.
|
||
self::assertStringNotContainsString('/intern', $xml);
|
||
self::assertStringNotContainsString('/entwurf', $xml);
|
||
|
||
$robots = $this->request('/robots.txt');
|
||
self::assertSame(200, $robots->getStatusCode());
|
||
self::assertStringContainsString('text/plain', (string) $robots->headers->get('Content-Type'));
|
||
self::assertStringContainsString('Disallow: /admin', $robots->getContent());
|
||
self::assertStringContainsString('Sitemap: https://cms.test/sitemap.xml', $robots->getContent());
|
||
|
||
// A page excluded from indexing says so on the page itself as well.
|
||
self::assertStringContainsString('<meta name="robots" content="noindex, follow">', $this->request('/intern')->getContent());
|
||
self::assertStringNotContainsString('name="robots"', $this->request('/offen')->getContent());
|
||
}
|
||
|
||
public function testRedirectsAnswerBeforeTheNotFoundAndRefuseChainsAndLoops(): void
|
||
{
|
||
$this->publishedPage('leistungen', 'Leistungen');
|
||
$this->redirects->save('alte-seite', 'leistungen', 301);
|
||
$this->redirects->save('weg', '', 410);
|
||
|
||
$moved = $this->request('/alte-seite');
|
||
self::assertSame(301, $moved->getStatusCode());
|
||
self::assertSame('/leistungen', $moved->headers->get('Location'));
|
||
// A deliberately removed address is gone, not merely missing.
|
||
self::assertSame(410, $this->request('/weg')->getStatusCode());
|
||
self::assertSame(404, $this->request('/nie-dagewesen')->getStatusCode());
|
||
|
||
foreach ([['alte-seite', 'alte-seite', 301], ['noch-eine', 'alte-seite', 301], ['Gross', 'leistungen', 301], ['ok', 'leistungen', 302]] as [$from, $to, $status]) {
|
||
try {
|
||
$this->redirects->save($from, $to, $status);
|
||
self::fail(sprintf('Expected /%s -> /%s (%d) to be rejected.', $from, $to, $status));
|
||
} catch (InvalidPage) {
|
||
}
|
||
}
|
||
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM redirect'));
|
||
|
||
// An existing page always wins over a redirect with the same address.
|
||
$this->redirects->save('leistungen', 'offen', 301);
|
||
self::assertSame(200, $this->request('/leistungen')->getStatusCode());
|
||
}
|
||
|
||
public function testStructuredDataComesOnlyFromMaintainedValues(): void
|
||
{
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||
// Nothing maintained yet: no empty organisation block is emitted.
|
||
self::assertStringNotContainsString('application/ld+json', $this->request('/')->getContent());
|
||
|
||
$this->site->save([
|
||
'legalName' => 'Kant-IT Solutions', 'phone' => '+49 7221 9957150', 'email' => 'info@kant-it.de',
|
||
'street' => 'Ooser Hauptstr. 51', 'postalCode' => '76532', 'city' => 'Baden-Baden', 'country' => 'de',
|
||
'sameAs' => "https://hausklar.app\nhttps://hausklar.app", 'localBusiness' => '1',
|
||
]);
|
||
$html = $this->request('/')->getContent();
|
||
self::assertStringContainsString('<script type="application/ld+json">', $html);
|
||
preg_match('#<script type="application/ld\+json">(.*?)</script>#s', $html, $match);
|
||
$data = json_decode(html_entity_decode($match[1], ENT_QUOTES), true);
|
||
self::assertSame('LocalBusiness', $data['@type']);
|
||
self::assertSame('Kant-IT Solutions', $data['name']);
|
||
self::assertSame('Baden-Baden', $data['address']['addressLocality']);
|
||
self::assertSame('DE', $data['address']['addressCountry']);
|
||
// Duplicates are collapsed, and nothing is invented.
|
||
self::assertSame(['https://hausklar.app'], $data['sameAs']);
|
||
self::assertArrayNotHasKey('description', $data);
|
||
|
||
// A post carries Article data built from its own metadata.
|
||
$post = $this->publishedPost('ein-beitrag', 'Ein Beitrag', '2026-02-01');
|
||
self::assertNotNull($this->pages->findForEditor($post));
|
||
$article = $this->request('/ein-beitrag')->getContent();
|
||
self::assertStringContainsString('"@type":"Article"', $article);
|
||
self::assertStringContainsString('"headline":"Ein Beitrag"', $article);
|
||
self::assertStringContainsString('"name":"Umut"', $article);
|
||
}
|
||
|
||
public function testBreadcrumbTrailAndItsStructuredDataFollowTheStoredHierarchy(): void
|
||
{
|
||
$parent = $this->publishedPage('leistungen', 'Leistungen');
|
||
self::assertNotNull($this->pages->findForEditor($parent));
|
||
$childId = $this->pages->createPage('webdesign', $parent, 'page', (static function (): array {
|
||
$document = DemoContent::starter('Webdesign');
|
||
$document['schemaVersion'] = 9;
|
||
$document['blocks'][] = ['id' => 'krumen', 'type' => 'breadcrumb'];
|
||
|
||
return $document;
|
||
})());
|
||
$child = $this->pages->findForEditor($childId);
|
||
$this->pages->publish($childId, $child['draft_revision_id'], $child['version']);
|
||
|
||
$html = $this->request('/leistungen/webdesign')->getContent();
|
||
self::assertStringContainsString('aria-label="Brotkrumen"', $html);
|
||
self::assertStringContainsString('<a href="/leistungen">Leistungen</a>', $html);
|
||
self::assertStringContainsString('<span aria-current="page">Webdesign</span>', $html);
|
||
self::assertStringContainsString('"@type":"BreadcrumbList"', $html);
|
||
self::assertStringContainsString('"item":"https://cms.test/leistungen"', $html);
|
||
|
||
// A page directly under the root gets no trail, so no empty navigation appears.
|
||
self::assertStringNotContainsString('aria-label="Brotkrumen"', $this->request('/leistungen')->getContent());
|
||
}
|
||
|
||
public function testLlmsTxtIsOffByDefaultAndListsOnlyPublicIndexableContent(): void
|
||
{
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||
self::assertSame(404, $this->request('/llms.txt')->getStatusCode());
|
||
|
||
$hidden = $this->publishedPage('intern', 'Intern');
|
||
$this->posts->saveMetadata($hidden, ['robots' => 'noindex'], [], [], $this->pages->findForEditor($hidden)['version']);
|
||
$this->pages->createPage('entwurf', null, 'page', DemoContent::starter('Entwurf'));
|
||
$this->site->save(['llmsTxt' => '1', 'legalName' => 'Kant-IT', 'description' => 'Ihr IT-Partner.', 'country' => 'DE']);
|
||
|
||
$response = $this->request('/llms.txt');
|
||
self::assertSame(200, $response->getStatusCode());
|
||
$body = $response->getContent();
|
||
self::assertStringContainsString('# Kant-IT', $body);
|
||
self::assertStringContainsString('> Ihr IT-Partner.', $body);
|
||
self::assertStringContainsString('https://cms.test/', $body);
|
||
self::assertStringNotContainsString('Intern', $body);
|
||
self::assertStringNotContainsString('Entwurf', $body);
|
||
}
|
||
|
||
/**
|
||
* @return iterable<string, array{array}>
|
||
*/
|
||
public static function rejectedSettings(): iterable
|
||
{
|
||
yield 'invalid email' => [['email' => 'keine-adresse', 'country' => 'DE']];
|
||
yield 'invalid phone' => [['phone' => 'ruf an', 'country' => 'DE']];
|
||
yield 'invalid country' => [['country' => 'Deutschland']];
|
||
yield 'insecure profile' => [['country' => 'DE', 'sameAs' => 'http://example.com']];
|
||
yield 'local business without address' => [['country' => 'DE', 'localBusiness' => '1']];
|
||
}
|
||
|
||
#[DataProvider('rejectedSettings')]
|
||
public function testRejectsInvalidCompanyDataWithoutWriting(array $input): void
|
||
{
|
||
try {
|
||
$this->site->save($input);
|
||
self::fail('Expected the settings to be rejected.');
|
||
} catch (InvalidPage) {
|
||
}
|
||
self::assertNull($this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']));
|
||
}
|
||
|
||
public function testRobotsExtraOnlyAcceptsPlainDirectiveLines(): void
|
||
{
|
||
$this->site->save([
|
||
'country' => 'DE',
|
||
'robotsExtra' => "Disallow: /geheim\n# ein Kommentar\n<script>alert(1)</script>\nSitemap: https://fremde.example/sitemap.xml",
|
||
]);
|
||
$body = $this->request('/robots.txt')->getContent();
|
||
self::assertStringContainsString('Disallow: /geheim', $body);
|
||
self::assertStringContainsString('# ein Kommentar', $body);
|
||
// Neither markup nor a second sitemap declaration survives the filter.
|
||
self::assertStringNotContainsString('<script>', $body);
|
||
self::assertStringNotContainsString('fremde.example', $body);
|
||
self::assertSame(1, substr_count($body, 'Sitemap:'));
|
||
}
|
||
|
||
private static function styledDocument(): array
|
||
{
|
||
$document = DemoContent::starter('Gestaltet');
|
||
$document['schemaVersion'] = 10;
|
||
$document['blocks'][0]['style'] = [
|
||
'desktop' => [
|
||
'backgroundColor' => '@mint',
|
||
'color' => '#123456',
|
||
'padding' => ['top' => '80px', 'bottom' => '80px'],
|
||
'borderRadius' => ['topLeft' => '24px'],
|
||
'fontSize' => '42px',
|
||
'letterSpacing' => '-1.5px',
|
||
'shadowY' => '12px', 'shadowBlur' => '40px', 'shadowColor' => '#00000022',
|
||
'hover' => ['backgroundColor' => '#ffffff', 'scale' => 1.02],
|
||
],
|
||
'mobile' => ['fontSize' => '28px', 'padding' => ['top' => '32px']],
|
||
];
|
||
|
||
return $document;
|
||
}
|
||
|
||
public function testFreeStylesSurviveSavingAndBecomeScopedCss(): void
|
||
{
|
||
$document = self::styledDocument();
|
||
$revision = $this->pages->saveDraft('page-1', $document, 0);
|
||
self::assertSame($document, json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]), true));
|
||
$this->pages->publish('page-1', $revision, 1);
|
||
|
||
$html = $this->request('/')->getContent();
|
||
// Values become declarations scoped to the block, palette colours become variables.
|
||
self::assertStringContainsString('#start-hero{', $html);
|
||
self::assertStringContainsString('background-color:var(--k-mint)', $html);
|
||
self::assertStringContainsString('color:#123456', $html);
|
||
self::assertStringContainsString('padding-top:80px', $html);
|
||
self::assertStringContainsString('border-top-left-radius:24px', $html);
|
||
self::assertStringContainsString('letter-spacing:-1.5px', $html);
|
||
self::assertStringContainsString('box-shadow:0 12px 40px 0 #00000022', $html);
|
||
// Hover and the narrow breakpoint get their own rules.
|
||
self::assertStringContainsString('#start-hero:hover,#start-hero:focus-within{', $html);
|
||
self::assertStringContainsString('transform:scale(1.02)', $html);
|
||
self::assertStringContainsString('@media (max-width:767px){#start-hero{font-size:28px', $html);
|
||
}
|
||
|
||
public function testAHeaderCanChangeItsColoursOnceThePageHasScrolled(): void
|
||
{
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', self::styledDocument(), 0), 1);
|
||
$header = $this->partials->findByKind('header');
|
||
$document = $header['document'];
|
||
// Glass over the hero, solid once the reader has moved: the Flatsome transparent header.
|
||
$document['blocks'][0]['style'] = ['desktop' => ['backgroundColor' => '#ffffff00', 'scrolled' => ['backgroundColor' => '#ffffff', 'color' => '#123456']]];
|
||
$this->partials->save($header['id'], $document, (int) $header['version']);
|
||
|
||
$response = $this->request('/');
|
||
$html = $response->getContent();
|
||
self::assertStringContainsString('#area-header{background-color:#ffffff00}', $html);
|
||
self::assertStringContainsString('#area-header{transition:background-color .3s,color .3s,border-color .3s,box-shadow .3s,opacity .3s}html.is-scrolled #area-header{background-color:#ffffff;color:#123456}', $html);
|
||
// The small script that marks the scrolled page comes only with such a header, and the
|
||
// policy opens for it exactly then.
|
||
self::assertStringContainsString('/assets/scrolled.js?v=', $html);
|
||
self::assertStringContainsString("script-src 'self'", (string) $response->headers->get('Content-Security-Policy'));
|
||
|
||
// Nur Farben, Schatten und Deckkraft dürfen sich beim Scrollen ändern; eine wachsende
|
||
// Schrift würde die Seite unter dem Finger verschieben.
|
||
$document['blocks'][0]['style']['desktop']['scrolled'] = ['fontSize' => '40px'];
|
||
$this->expectException(InvalidDocument::class);
|
||
$this->partials->save($header['id'], $document, (int) $header['version'] + 1);
|
||
}
|
||
|
||
public function testSliderControlsTakeTheirColoursFromTheSliderStyle(): void
|
||
{
|
||
$document = self::styledDocument();
|
||
$document['schemaVersion'] = 22;
|
||
$document['blocks'][0]['children'][] = ['id' => 'hero-slider', 'type' => 'slider', 'props' => ['autoplay' => 0, 'dots' => true, 'snap' => true, 'desktopVisible' => 1, 'tabletVisible' => 1, 'mobileVisible' => 1], 'style' => ['desktop' => ['sliderDotColor' => '#ffffff', 'sliderControlColor' => '@mint', 'sliderControlBackground' => '#00000066']], 'children' => [
|
||
['id' => 'slide-1', 'type' => 'column', 'children' => [['id' => 'slide-1-text', 'type' => 'text', 'props' => ['text' => 'Eins']]]],
|
||
['id' => 'slide-2', 'type' => 'column', 'children' => [['id' => 'slide-2-text', 'type' => 'text', 'props' => ['text' => 'Zwei']]]],
|
||
]];
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||
$html = $this->request('/')->getContent();
|
||
// On the slider for the script-free markers, on the control bar the script adds after it.
|
||
self::assertStringContainsString('#hero-slider,#hero-slider + .slider-controls{--slider-dot:#ffffff;--slider-ink:var(--k-mint);--slider-bg:#00000066}', $html);
|
||
}
|
||
|
||
public function testInlineStylesheetIsAllowedByItsOwnHashAndNothingElse(): void
|
||
{
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', self::styledDocument(), 0), 1);
|
||
$response = $this->request('/');
|
||
$policy = (string) $response->headers->get('Content-Security-Policy');
|
||
|
||
preg_match('#<style>(.*?)</style>#s', $response->getContent(), $match);
|
||
self::assertNotEmpty($match[1] ?? '', 'The page carries a generated stylesheet.');
|
||
$expected = base64_encode(hash('sha256', html_entity_decode($match[1], ENT_QUOTES), true));
|
||
self::assertStringContainsString(sprintf("'sha256-%s'", $expected), $policy);
|
||
// Never a blanket permission for inline styles, and scripting stays off.
|
||
self::assertStringNotContainsString('unsafe-inline', $policy);
|
||
self::assertStringContainsString("script-src 'none'", $policy);
|
||
}
|
||
|
||
/**
|
||
* @return iterable<string, array{array}>
|
||
*/
|
||
public static function rejectedStyles(): iterable
|
||
{
|
||
$wrap = static function (array $style): array {
|
||
$document = DemoContent::starter('Titel');
|
||
$document['schemaVersion'] = 10;
|
||
$document['blocks'][0]['style'] = $style;
|
||
|
||
return [$document];
|
||
};
|
||
yield 'unknown property' => $wrap(['desktop' => ['zIndexHack' => '9999']]);
|
||
yield 'unknown device' => $wrap(['watch' => ['fontSize' => '12px']]);
|
||
yield 'colour that is a word' => $wrap(['desktop' => ['color' => 'red']]);
|
||
yield 'colour with a function' => $wrap(['desktop' => ['color' => 'rgb(1,2,3)']]);
|
||
yield 'length without unit' => $wrap(['desktop' => ['fontSize' => '18']]);
|
||
yield 'length with expression' => $wrap(['desktop' => ['fontSize' => 'calc(1px + 2px)']]);
|
||
yield 'value with a declaration' => $wrap(['desktop' => ['fontSize' => '18px;position:fixed']]);
|
||
yield 'choice outside the list' => $wrap(['desktop' => ['display' => 'contents']]);
|
||
yield 'number out of range' => $wrap(['desktop' => ['opacity' => 4]]);
|
||
yield 'side given as a single value' => $wrap(['desktop' => ['padding' => '10px']]);
|
||
yield 'unknown side' => $wrap(['desktop' => ['padding' => ['middle' => '10px']]]);
|
||
yield 'hover on a property without hover' => $wrap(['desktop' => ['hover' => ['fontSize' => '10px']]]);
|
||
yield 'font key with quotes' => $wrap(['desktop' => ['fontFamily' => '"Inter",serif']]);
|
||
yield 'background that is not a media id' => $wrap(['desktop' => ['backgroundImage' => '/etc/passwd']]);
|
||
}
|
||
|
||
#[DataProvider('rejectedStyles')]
|
||
public function testRejectsInvalidStylesBeforeWriting(array $document): void
|
||
{
|
||
$this->expectException(InvalidDocument::class);
|
||
$this->pages->saveDraft('page-1', $document, 0);
|
||
}
|
||
|
||
public function testFreeStylesNeedSchemaTen(): void
|
||
{
|
||
$document = self::styledDocument();
|
||
$document['schemaVersion'] = 9;
|
||
$this->expectException(InvalidDocument::class);
|
||
$this->pages->saveDraft('page-1', $document, 0);
|
||
}
|
||
|
||
public function testThemeDrivesEveryPageAndNoFontIsEverFetchedFromGoogle(): void
|
||
{
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||
// Without a stored theme the defaults apply and no third party is contacted.
|
||
$plain = $this->request('/');
|
||
self::assertStringContainsString('--k-ink:#203b34', $plain->getContent());
|
||
self::assertStringNotContainsString('fonts.googleapis.com', $plain->getContent());
|
||
self::assertStringNotContainsString('fonts.gstatic.com', (string) $plain->headers->get('Content-Security-Policy'));
|
||
|
||
$this->themes->save(
|
||
[['key' => 'marke', 'name' => 'Marke', 'value' => '#Ff3300'], ['key' => 'ink', 'name' => 'Tinte', 'value' => '#111111']],
|
||
[['key' => 'body', 'name' => 'Text', 'family' => 'Inter', 'source' => 'google', 'weights' => [400, 700], 'fallback' => 'sans-serif']],
|
||
['fontFamily' => 'body', 'headingFontFamily' => 'body', 'fontSize' => '20px', 'lineHeight' => 1.7, 'color' => '@ink', 'backgroundColor' => '#ffffff', 'linkColor' => '@marke', 'containerWidth' => '1100px', 'headingScale' => 1.3],
|
||
);
|
||
|
||
$response = $this->request('/');
|
||
$html = $response->getContent();
|
||
self::assertStringContainsString('--k-marke:#ff3300', $html, 'colour values are normalised');
|
||
self::assertStringContainsString('--kf-body:"Inter",sans-serif', $html);
|
||
self::assertStringContainsString('font-size:var(--k-size)', $html);
|
||
self::assertStringContainsString('--k-container:1100px', $html);
|
||
// Auch eine Google-Familie im Theme verlinkt nichts nach draußen: Solange die Dateien
|
||
// nicht hier liegen, greift die Ersatzschrift, und die Kopfzeile lässt keinen fremden
|
||
// Schrifthost zu.
|
||
self::assertStringNotContainsString('fonts.googleapis.com', $html);
|
||
$policy = (string) $response->headers->get('Content-Security-Policy');
|
||
self::assertStringNotContainsString('googleapis.com', $policy);
|
||
self::assertStringContainsString("font-src 'self';", $policy);
|
||
self::assertStringContainsString("style-src 'self' 'sha256-", $policy);
|
||
}
|
||
|
||
/**
|
||
* @return iterable<string, array{array, array, array}>
|
||
*/
|
||
public static function rejectedThemes(): iterable
|
||
{
|
||
$font = ['key' => 'body', 'name' => 'Text', 'family' => '', 'source' => 'system', 'weights' => [400], 'fallback' => 'system-ui'];
|
||
$base = ['fontFamily' => 'body', 'headingFontFamily' => 'body', 'color' => '@ink', 'backgroundColor' => '#ffffff', 'linkColor' => '#000000', 'fontSize' => '18px', 'containerWidth' => '1240px', 'lineHeight' => 1.6, 'headingScale' => 1.25];
|
||
yield 'colour that is not hex' => [[['key' => 'ink', 'name' => 'T', 'value' => 'darkgreen']], [$font], $base];
|
||
yield 'duplicate colour key' => [[['key' => 'ink', 'name' => 'A', 'value' => '#000000'], ['key' => 'ink', 'name' => 'B', 'value' => '#111111']], [$font], $base];
|
||
yield 'font family with a stack' => [[['key' => 'ink', 'name' => 'T', 'value' => '#000000']], [[...$font, 'family' => 'Inter,serif']], $base];
|
||
yield 'google font without a name' => [[['key' => 'ink', 'name' => 'T', 'value' => '#000000']], [[...$font, 'source' => 'google']], $base];
|
||
yield 'base colour without palette entry' => [[['key' => 'other', 'name' => 'T', 'value' => '#000000']], [$font], $base];
|
||
yield 'base size without unit' => [[['key' => 'ink', 'name' => 'T', 'value' => '#000000']], [$font], [...$base, 'fontSize' => '18']];
|
||
yield 'line height out of range' => [[['key' => 'ink', 'name' => 'T', 'value' => '#000000']], [$font], [...$base, 'lineHeight' => 9]];
|
||
yield 'no colour at all' => [[], [$font], $base];
|
||
}
|
||
|
||
#[DataProvider('rejectedThemes')]
|
||
public function testRejectsInvalidThemesWithoutWriting(array $colors, array $fonts, array $base): void
|
||
{
|
||
$before = $this->db->fetchOne('SELECT theme FROM website WHERE id = ?', ['default']);
|
||
try {
|
||
$this->themes->save($colors, $fonts, $base);
|
||
self::fail('Expected the theme to be rejected.');
|
||
} catch (InvalidPage) {
|
||
}
|
||
self::assertSame($before, $this->db->fetchOne('SELECT theme FROM website WHERE id = ?', ['default']));
|
||
self::assertSame(Theme::DEFAULTS['colors'], $this->themes->load()['colors']);
|
||
}
|
||
|
||
public function testMovingAPageCarriesItsSubtreeAndLeavesRedirectsBehind(): void
|
||
{
|
||
$parent = $this->publishedPage('leistungen', 'Leistungen');
|
||
$child = $this->publishedChild('webdesign', 'Webdesign', $parent);
|
||
$grandchild = $this->publishedChild('shop', 'Shop', $child);
|
||
foreach (['leistungen', 'leistungen/webdesign', 'leistungen/webdesign/shop'] as $path) {
|
||
self::assertSame(200, $this->request('/'.$path)->getStatusCode(), $path);
|
||
}
|
||
|
||
$moved = $this->pages->movePage($parent, 'services', null, $this->pages->findForEditor($parent)['version'], $this->redirects);
|
||
self::assertSame(3, $moved, 'The page and both descendants change address.');
|
||
|
||
// Every new address answers and every old one redirects permanently.
|
||
foreach ([
|
||
'services' => 'leistungen',
|
||
'services/webdesign' => 'leistungen/webdesign',
|
||
'services/webdesign/shop' => 'leistungen/webdesign/shop',
|
||
] as $now => $before) {
|
||
self::assertSame(200, $this->request('/'.$now)->getStatusCode(), $now);
|
||
$old = $this->request('/'.$before);
|
||
self::assertSame(301, $old->getStatusCode(), $before);
|
||
self::assertSame('/'.$now, $old->headers->get('Location'), $before);
|
||
}
|
||
// The grandchild kept its own slug and its parent link.
|
||
self::assertSame('shop', $this->db->fetchOne('SELECT slug FROM page WHERE id = ?', [$grandchild]));
|
||
self::assertSame($child, $this->db->fetchOne('SELECT parent_id FROM page WHERE id = ?', [$grandchild]));
|
||
}
|
||
|
||
public function testMoveRefusesCyclesCollisionsAndTheHomePage(): void
|
||
{
|
||
$parent = $this->publishedPage('leistungen', 'Leistungen');
|
||
$child = $this->publishedChild('webdesign', 'Webdesign', $parent);
|
||
$other = $this->publishedPage('beratung', 'Beratung');
|
||
|
||
$cases = [
|
||
'into its own child' => [$parent, 'leistungen', $child],
|
||
'into itself' => [$parent, 'leistungen', $parent],
|
||
'onto a taken address' => [$other, 'leistungen', null],
|
||
'with a slug that is no slug' => [$other, 'mit leerzeichen', null],
|
||
];
|
||
foreach ($cases as $name => [$page, $slug, $target]) {
|
||
try {
|
||
$this->pages->movePage($page, $slug, $target, $this->pages->findForEditor($page)['version'], $this->redirects);
|
||
self::fail(sprintf('Expected "%s" to be refused.', $name));
|
||
} catch (InvalidPage) {
|
||
}
|
||
}
|
||
// The home page keeps its address; a different start page is a settings matter.
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||
try {
|
||
$this->pages->movePage('page-1', 'start', null, $this->pages->findForEditor('page-1')['version'], $this->redirects);
|
||
self::fail('Expected the home page to be refused.');
|
||
} catch (InvalidPage) {
|
||
}
|
||
// Nothing moved and no redirect was invented.
|
||
self::assertSame(['beratung', 'home', 'leistungen', 'leistungen/webdesign', 'private'], $this->db->fetchFirstColumn('SELECT path FROM page ORDER BY path'));
|
||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM redirect'));
|
||
}
|
||
|
||
public function testTrashHidesAPageEverywhereAndRestoreBringsItBack(): void
|
||
{
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||
$parent = $this->publishedPage('leistungen', 'Leistungen');
|
||
$child = $this->publishedChild('webdesign', 'Webdesign', $parent);
|
||
$post = $this->publishedPost('ein-beitrag', 'Ein Beitrag', '2026-02-01', ['Projekte'], []);
|
||
$this->menus->save('header', 'Hauptnavigation', [
|
||
['label' => 'Leistungen', 'type' => 'page', 'pageId' => $parent, 'anchor' => '', 'children' => []],
|
||
], 0);
|
||
self::assertStringContainsString('Leistungen', $this->request('/')->getContent());
|
||
|
||
// Trashing takes the subtree along.
|
||
self::assertSame(2, $this->pages->trashPage($parent, $this->pages->findForEditor($parent)['version']));
|
||
self::assertSame(404, $this->request('/leistungen')->getStatusCode());
|
||
self::assertSame(404, $this->request('/leistungen/webdesign')->getStatusCode());
|
||
self::assertStringNotContainsString('Leistungen', $this->request('/')->getContent(), 'A trashed page leaves the navigation.');
|
||
self::assertStringNotContainsString('/leistungen', $this->request('/sitemap.xml')->getContent());
|
||
self::assertNull($this->pages->findForEditor($parent), 'A trashed page is out of the editor listing.');
|
||
self::assertSame([$parent, $child], array_column($this->pages->trash(), 'id'));
|
||
|
||
// A trashed post disappears from the archive and the feed as well.
|
||
$this->pages->trashPage($post, $this->db->fetchOne('SELECT version FROM page WHERE id = ?', [$post]));
|
||
self::assertSame(0, $this->posts->published()['total']);
|
||
self::assertStringNotContainsString('Ein Beitrag', $this->request('/feed.xml')->getContent());
|
||
|
||
// A child cannot come back before its parent, because its address needs it.
|
||
try {
|
||
$this->pages->restorePage($child);
|
||
self::fail('Expected the child to wait for its parent.');
|
||
} catch (InvalidPage) {
|
||
}
|
||
$this->pages->restorePage($parent);
|
||
$this->pages->restorePage($child);
|
||
self::assertSame(200, $this->request('/leistungen/webdesign')->getStatusCode());
|
||
// Only the post is left in the bin; restoring one page does not restore another.
|
||
self::assertSame([$post], array_column($this->pages->trash(), 'id'));
|
||
}
|
||
|
||
public function testRestoringARevisionKeepsTheWholeHistory(): void
|
||
{
|
||
$first = DemoContent::starter('Erste Fassung');
|
||
$second = DemoContent::starter('Zweite Fassung');
|
||
$this->pages->saveDraft('page-1', $first, 0);
|
||
$this->pages->saveDraft('page-1', $second, 1);
|
||
// The history is paged now, so it says how many there are instead of stopping silently.
|
||
$revisions = $this->pages->revisions('page-1');
|
||
self::assertSame(['Zweite Fassung', 'Erste Fassung'], array_column($revisions['items'], 'title'), 'Newest first.');
|
||
self::assertSame(2, $revisions['total']);
|
||
self::assertSame(1, $revisions['pages']);
|
||
self::assertTrue($revisions['items'][0]['draft']);
|
||
|
||
$oldest = end($revisions['items']);
|
||
$this->pages->restoreRevision('page-1', $oldest['id'], 2);
|
||
self::assertSame('Erste Fassung', $this->pages->findForEditor('page-1')['document']['title']);
|
||
// Restoring adds a revision instead of overwriting one, so nothing is lost.
|
||
self::assertSame(3, $this->pages->revisions('page-1')['total']);
|
||
self::assertSame('Zweite Fassung', $this->pages->revisions('page-1')['items'][1]['title']);
|
||
|
||
$foreign = $this->pages->saveDraft('page-2', DemoContent::starter('Fremd'), 0);
|
||
$this->expectException(InvalidPage::class);
|
||
$this->pages->restoreRevision('page-1', $foreign, 3);
|
||
}
|
||
|
||
public function testScheduledPublicationHappensOnlyWhenItIsDue(): void
|
||
{
|
||
$id = $this->pages->createPage('geplant', null, 'page', DemoContent::starter('Geplant'));
|
||
$this->pages->schedule($id, gmdate('Y-m-d H:i', time() + 86400), $this->pages->findForEditor($id)['version']);
|
||
self::assertSame([], $this->pages->publishDue(), 'A future date publishes nothing.');
|
||
self::assertSame(404, $this->request('/geplant')->getStatusCode());
|
||
|
||
$this->pages->schedule($id, gmdate('Y-m-d H:i', time() - 3600), $this->pages->findForEditor($id)['version']);
|
||
self::assertSame(['geplant'], $this->pages->publishDue());
|
||
self::assertSame(200, $this->request('/geplant')->getStatusCode());
|
||
// The schedule is consumed, so a later run does not publish again.
|
||
self::assertNull($this->db->fetchOne('SELECT publish_at FROM page WHERE id = ?', [$id]));
|
||
self::assertSame([], $this->pages->publishDue());
|
||
|
||
// A trashed page is never published by the schedule.
|
||
$trashed = $this->pages->createPage('versteckt', null, 'page', DemoContent::starter('Versteckt'));
|
||
$this->pages->schedule($trashed, gmdate('Y-m-d H:i', time() - 3600), $this->pages->findForEditor($trashed)['version']);
|
||
$this->pages->trashPage($trashed, $this->db->fetchOne('SELECT version FROM page WHERE id = ?', [$trashed]));
|
||
self::assertSame([], $this->pages->publishDue());
|
||
|
||
$this->expectException(InvalidPage::class);
|
||
$this->pages->schedule($id, '12.09.2026', $this->db->fetchOne('SELECT version FROM page WHERE id = ?', [$id]));
|
||
}
|
||
|
||
private function publishedChild(string $slug, string $title, string $parentId): string
|
||
{
|
||
$id = $this->pages->createPage($slug, $parentId, 'page', DemoContent::starter($title));
|
||
$page = $this->pages->findForEditor($id);
|
||
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
|
||
|
||
return $id;
|
||
}
|
||
|
||
public function testAnyPublishedPageCanBecomeTheRootAndEverythingFollows(): void
|
||
{
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||
$start = $this->publishedPage('willkommen', 'Willkommen');
|
||
self::assertNotNull($this->pages->findForEditor($start));
|
||
$this->site->save(['country' => 'DE', 'homePath' => 'willkommen', 'legalName' => 'Kant-IT', 'street' => 'Ooser Hauptstr. 51', 'city' => 'Baden-Baden']);
|
||
|
||
// The chosen page answers at the root, and its own address redirects there.
|
||
$root = $this->request('/');
|
||
self::assertSame(200, $root->getStatusCode());
|
||
self::assertStringContainsString('>Willkommen</h1>', $root->getContent());
|
||
self::assertStringContainsString('href="https://cms.test/"', $root->getContent());
|
||
$own = $this->request('/willkommen');
|
||
self::assertSame(301, $own->getStatusCode());
|
||
self::assertSame('/', $own->headers->get('Location'));
|
||
|
||
// The previous root became an ordinary page and keeps its own address.
|
||
self::assertSame(200, $this->request('/home')->getStatusCode());
|
||
|
||
// Sitemap, structured data and the menu follow the same decision.
|
||
self::assertStringContainsString('<loc>https://cms.test/</loc>', $this->request('/sitemap.xml')->getContent());
|
||
self::assertStringContainsString('<loc>https://cms.test/home</loc>', $this->request('/sitemap.xml')->getContent());
|
||
self::assertStringContainsString('"@type":"Organization"', $root->getContent(), 'Organisation data belongs on the root page.');
|
||
self::assertStringNotContainsString('schema.org', $this->request('/home')->getContent());
|
||
$this->menus->save('header', 'Hauptnavigation', [
|
||
['label' => 'Start', 'type' => 'page', 'pageId' => $start, 'anchor' => '', 'children' => []],
|
||
], 0);
|
||
// Seit den Menuesymbolen (1.13.0) steht die Beschriftung in einer eigenen Huelle. Geprueft
|
||
// wird, was die Aussage traegt: Adresse, Kennzeichnung der aktuellen Seite, Beschriftung.
|
||
self::assertMatchesRegularExpression(
|
||
'#<a href="/" aria-current="page">.*?Start.*?</a>#s',
|
||
(string) $this->request('/')->getContent(),
|
||
);
|
||
|
||
// The root is protected from being renamed or binned while it is the root.
|
||
foreach ([fn () => $this->pages->movePage($start, 'anders', null, $this->pages->findForEditor($start)['version'], $this->redirects),
|
||
fn () => $this->pages->trashPage($start, $this->pages->findForEditor($start)['version'])] as $attempt) {
|
||
try {
|
||
$attempt();
|
||
self::fail('Expected the root page to be protected.');
|
||
} catch (InvalidPage) {
|
||
}
|
||
}
|
||
// But the page that used to be the root can now be moved.
|
||
self::assertSame(1, $this->pages->movePage('page-1', 'alte-startseite', null, $this->pages->findForEditor('page-1')['version'], $this->redirects));
|
||
}
|
||
|
||
public function testRootMustBeAnExistingPublishedPage(): void
|
||
{
|
||
$before = $this->site->load()['homePath'];
|
||
foreach (['gibt-es-nicht', '', 'Gross'] as $candidate) {
|
||
try {
|
||
$this->site->save(['country' => 'DE', 'homePath' => $candidate]);
|
||
self::fail(sprintf('Expected "%s" to be refused as the root.', $candidate));
|
||
} catch (InvalidPage) {
|
||
}
|
||
}
|
||
self::assertSame($before, $this->site->load()['homePath']);
|
||
}
|
||
|
||
public function testHeaderAndFooterAreEditableDocumentsThatReachEveryPage(): void
|
||
{
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||
$second = $this->publishedPage('zweite', 'Zweite Seite');
|
||
self::assertNotNull($this->pages->findForEditor($second));
|
||
|
||
// The seeded areas render as ordinary blocks, and the navigation block shows the menu.
|
||
$this->menus->save('header', 'Hauptnavigation', [
|
||
['label' => 'Start', 'type' => 'page', 'pageId' => 'page-1', 'anchor' => '', 'children' => []],
|
||
], 0);
|
||
$html = $this->request('/')->getContent();
|
||
self::assertStringContainsString('<header class="site-header">', $html);
|
||
self::assertStringContainsString('<p id="area-header-name" class="block-text">kantsites</p>', $html);
|
||
self::assertStringContainsString('aria-label="Hauptnavigation"', $html);
|
||
self::assertStringContainsString('<span class="nav-label"><span>Start</span></span>', $html);
|
||
|
||
// Changing the header reaches every page at once, because it is one shared document.
|
||
$header = $this->partials->findByKind('header');
|
||
$document = $header['document'];
|
||
$document['blocks'][0]['children'][0]['children'][0]['children'][0]['props']['text'] = 'Neue Wortmarke';
|
||
$this->partials->save($header['id'], $document, $header['version']);
|
||
foreach (['/', '/zweite'] as $path) {
|
||
self::assertStringContainsString('Neue Wortmarke', $this->request($path)->getContent(), $path);
|
||
self::assertStringNotContainsString('kantsites', $this->request($path)->getContent(), $path);
|
||
}
|
||
|
||
// An emptied area renders nothing rather than leaving a broken shell behind.
|
||
$footer = $this->partials->findByKind('footer');
|
||
$this->partials->save($footer['id'], [...$footer['document'], 'blocks' => []], $footer['version']);
|
||
self::assertStringContainsString('<footer class="site-footer">', $this->request('/')->getContent());
|
||
self::assertStringNotContainsString('Viele Möglichkeiten', $this->request('/')->getContent());
|
||
}
|
||
|
||
public function testALinkedSectionChangesOnEveryPageThatEmbedsIt(): void
|
||
{
|
||
$sectionId = $this->partials->create('section', 'aufruf', 'Aufruf-Band', [
|
||
'schemaVersion' => 11, 'title' => 'Aufruf-Band', 'description' => 'Wiederverwendbar.',
|
||
'blocks' => [['id' => 'cta-section', 'type' => 'section', 'children' => [
|
||
['id' => 'cta-heading', 'type' => 'heading', 'props' => ['text' => 'Bereit?', 'level' => 2]],
|
||
]]],
|
||
]);
|
||
|
||
$withRef = static function (string $title): array {
|
||
$document = DemoContent::starter($title);
|
||
$document['schemaVersion'] = 11;
|
||
$document['blocks'][] = ['id' => 'ref', 'type' => 'partialref', 'props' => ['partial' => 'aufruf']];
|
||
|
||
return $document;
|
||
};
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $withRef('Erste'), 0), 1);
|
||
$other = $this->pages->createPage('zweite', null, 'page', $withRef('Zweite'));
|
||
$page = $this->pages->findForEditor($other);
|
||
$this->pages->publish($other, $page['draft_revision_id'], $page['version']);
|
||
|
||
foreach (['/', '/zweite'] as $path) {
|
||
self::assertStringContainsString('Bereit?', $this->request($path)->getContent(), $path);
|
||
}
|
||
// The page documents carry only the address, never the section's blocks.
|
||
self::assertSame(['partial' => 'aufruf'], $this->pages->findPublished('zweite')['document']['blocks'][2]['props']);
|
||
|
||
// One change in the section reaches both pages.
|
||
$section = $this->partials->find($sectionId);
|
||
$document = $section['document'];
|
||
$document['blocks'][0]['children'][0]['props']['text'] = 'Jetzt anfragen';
|
||
$this->partials->save($sectionId, $document, $section['version']);
|
||
foreach (['/', '/zweite'] as $path) {
|
||
self::assertStringContainsString('Jetzt anfragen', $this->request($path)->getContent(), $path);
|
||
self::assertStringNotContainsString('Bereit?', $this->request($path)->getContent(), $path);
|
||
}
|
||
self::assertSame(['/home', '/zweite'], $this->partials->usedBy('aufruf'));
|
||
|
||
// Removing the section leaves both pages intact; the reference stays stored.
|
||
$this->partials->delete($sectionId);
|
||
$stripped = $this->request('/');
|
||
self::assertSame(200, $stripped->getStatusCode());
|
||
self::assertStringContainsString('<!-- Verknüpfte Sektion aufruf ist nicht vorhanden. -->', $stripped->getContent());
|
||
self::assertSame('aufruf', $this->pages->findPublished('home')['document']['blocks'][2]['props']['partial']);
|
||
}
|
||
|
||
public function testSharedAreasRefuseAMainHeadingAndSectionsRefuseToContainThemselves(): void
|
||
{
|
||
// A page brings its own main heading; an area appearing on every page must not add one.
|
||
try {
|
||
$this->partials->create('section', 'mit-h1', 'Mit H1', [
|
||
'schemaVersion' => 11, 'title' => 'Mit H1', 'description' => 'x',
|
||
'blocks' => [['id' => 'h', 'type' => 'heading', 'props' => ['text' => 'Titel', 'level' => 1]]],
|
||
]);
|
||
self::fail('Expected a main heading in a shared area to be refused.');
|
||
} catch (InvalidDocument) {
|
||
}
|
||
|
||
$id = $this->partials->create('section', 'selbst', 'Selbstbezug', [
|
||
'schemaVersion' => 11, 'title' => 'Selbstbezug', 'description' => 'x',
|
||
'blocks' => [['id' => 's', 'type' => 'section', 'children' => []]],
|
||
]);
|
||
$cyclic = ['schemaVersion' => 11, 'title' => 'Selbstbezug', 'description' => 'x', 'blocks' => [
|
||
['id' => 's', 'type' => 'partialref', 'props' => ['partial' => 'selbst']],
|
||
]];
|
||
try {
|
||
$this->partials->save($id, $cyclic, $this->partials->find($id)['version']);
|
||
self::fail('Expected a section containing itself to be refused.');
|
||
} catch (InvalidPage) {
|
||
}
|
||
// Nothing was written, so the section still holds its original block.
|
||
self::assertSame('section', $this->partials->find($id)['document']['blocks'][0]['type']);
|
||
|
||
// A second header is allowed now that pages can choose one - it keeps its own address,
|
||
// while the first one stays "default" and is what a page without a choice gets.
|
||
$this->partials->create('header', 'landing', 'Kopf für Landingpages', ['schemaVersion' => 11, 'title' => 'x', 'description' => 'x', 'blocks' => []]);
|
||
self::assertSame(['default' => 'Kopfbereich', 'landing' => 'Kopf für Landingpages'], $this->partials->choices('header'));
|
||
self::assertSame('Kopf für Landingpages', $this->partials->findByKind('header', 'landing')['name']);
|
||
// The same address twice stays refused.
|
||
try {
|
||
$this->partials->create('header', 'landing', 'Noch einer', ['schemaVersion' => 11, 'title' => 'x', 'description' => 'x', 'blocks' => []]);
|
||
self::fail('Expected a duplicate address to be refused.');
|
||
} catch (InvalidPage) {
|
||
}
|
||
// The standard area cannot be deleted, only emptied.
|
||
$this->expectException(InvalidPage::class);
|
||
$this->partials->delete($this->partials->findByKind('header')['id']);
|
||
}
|
||
|
||
public function testNewTabLinksPersistForBlocksAndNestedMenus(): void
|
||
{
|
||
$document = DemoContent::starter('Links');
|
||
$document['schemaVersion'] = 20;
|
||
$types = [
|
||
'button' => ['text' => 'Öffnen'],
|
||
'image' => ['src' => '/bild.png', 'width' => 120, 'height' => 80, 'alt' => 'Bild', 'decorative' => false, 'click' => 'link'],
|
||
'iconcard' => ['title' => 'Karte', 'text' => '', 'icon' => 'star'],
|
||
'cta' => ['title' => 'Aufruf', 'text' => '', 'buttonText' => 'Öffnen'],
|
||
'icon' => ['icon' => 'star', 'size' => 24, 'decorative' => false, 'label' => 'Öffnen'],
|
||
];
|
||
foreach ($types as $type => $props) {
|
||
$document['blocks'][] = ['id' => 'new-'.$type, 'type' => $type, 'props' => [...$props, 'href' => '/kontakt', 'newTab' => true]];
|
||
}
|
||
$revision = $this->pages->saveDraft('page-1', $document, 0);
|
||
$this->pages->publish('page-1', $revision, 1);
|
||
$this->menus->save('header', 'Links', [['label' => 'Eltern', 'type' => 'url', 'url' => '/', 'newTab' => true, 'children' => [['label' => 'Kind', 'type' => 'url', 'url' => '/kontakt', 'newTab' => true]]]], 0);
|
||
$crawler = new \Symfony\Component\DomCrawler\Crawler($this->request('/')->getContent());
|
||
foreach (['#new-button', '#new-image a', '#new-iconcard a', '#new-cta a', '#new-icon'] as $selector) {
|
||
self::assertSame('_blank', $crawler->filter($selector)->attr('target'));
|
||
self::assertSame('noopener', $crawler->filter($selector)->attr('rel'));
|
||
}
|
||
self::assertCount(2, $crawler->filter('.site-header .block-menu > .menu-list a[target="_blank"][rel="noopener"]'));
|
||
self::assertCount(2, $crawler->filter('.site-header .menu-burger a[target="_blank"][rel="noopener"]'));
|
||
$document['blocks'][array_key_last($document['blocks'])]['props']['newTab'] = 'yes';
|
||
$this->expectException(InvalidDocument::class);
|
||
(new DocumentValidator())->validate($document);
|
||
}
|
||
|
||
public function testNewTabAndRemovedBackgroundRequireSchemaTwenty(): void
|
||
{
|
||
foreach ([['type' => 'button', 'props' => ['text' => 'Link', 'href' => '/', 'newTab' => true]], ['type' => 'section', 'style' => ['tablet' => ['backgroundImage' => 'none']], 'children' => []]] as $block) {
|
||
$document = DemoContent::starter('Schema');
|
||
$document['schemaVersion'] = 19;
|
||
$document['blocks'][] = ['id' => 'new-option', ...$block];
|
||
try {
|
||
(new DocumentValidator())->validate($document);
|
||
self::fail('An explicit schema upgrade is required.');
|
||
} catch (InvalidDocument) {
|
||
$document['schemaVersion'] = 20;
|
||
(new DocumentValidator())->validate($document);
|
||
self::assertSame(20, $document['schemaVersion']);
|
||
}
|
||
}
|
||
}
|
||
|
||
public function testRemovingAnInheritedBackgroundAlsoRemovesItOnNarrowerDevices(): void
|
||
{
|
||
$id = str_repeat('a', 32);
|
||
$this->db->insert('media', ['id' => $id, 'title' => 'Klein', 'alt' => '', 'variants' => json_encode([['src' => '/media/'.$id.'/192', 'width' => 192, 'height' => 64, 'bytes' => 1]]), 'archived' => 0, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')]);
|
||
$compiler = new StyleCompiler(new MediaLibrary($this->db, $this->path.'-media'));
|
||
$document = ['blocks' => [['id' => 'background', 'style' => ['desktop' => ['backgroundImage' => $id], 'tablet' => ['backgroundImage' => 'none']]]]];
|
||
$css = $compiler->compileDocument($document);
|
||
self::assertStringContainsString('#background{background-image:url("/media/'.$id.'/192")}', $css);
|
||
self::assertStringContainsString('@media (max-width:1024px){#background{background-image:none}}', $css);
|
||
self::assertStringContainsString('@media (max-width:767px){#background{background-image:none}}', $css);
|
||
$document['blocks'][0]['style']['desktop'] += ['gradientFrom' => '#000000', 'gradientTo' => '#ffffff', 'gradientAngle' => 90];
|
||
$gradientCss = $compiler->compileDocument($document);
|
||
self::assertStringContainsString('@media (max-width:1024px){#background{background-image:linear-gradient(90deg,#000000,#ffffff)}}', $gradientCss);
|
||
self::assertStringContainsString('@media (max-width:767px){#background{background-image:linear-gradient(90deg,#000000,#ffffff)}}', $gradientCss);
|
||
unset($document['blocks'][0]['style']['tablet']);
|
||
self::assertStringNotContainsString('background-image:none', $compiler->compileDocument($document));
|
||
}
|
||
|
||
/**
|
||
* @return iterable<string, array{string, string}>
|
||
*/
|
||
public static function richTextCases(): iterable
|
||
{
|
||
yield 'keeps allowed formatting' => ['Ganz <strong>fett</strong> und <em>kursiv</em>.', 'Ganz <strong>fett</strong> und <em>kursiv</em>.'];
|
||
yield 'keeps lists' => ['<ul><li>eins</li><li>zwei</li></ul>', '<ul><li>eins</li><li>zwei</li></ul>'];
|
||
yield 'drops a script and keeps the text after it' => ['<script>alert(1)</script>Text danach', 'Text danach'];
|
||
yield 'drops an event handler' => ['<p onclick="steal()">Mit Ereignis</p>', '<p>Mit Ereignis</p>'];
|
||
yield 'drops style and class' => ['<div class="x" style="position:fixed">Verpackt</div>', 'Verpackt'];
|
||
yield 'turns an unsafe link into text' => ['<a href="javascript:alert(1)">Boese</a>', 'Boese'];
|
||
yield 'removes an image with a handler' => ['<img src=x onerror=alert(1)>', ''];
|
||
yield 'turns a newline into a break next to markup' => ["<b>Eins</b>\nZwei", '<b>Eins</b><br>Zwei'];
|
||
yield 'removes an iframe' => ['<iframe src="https://evil.example"></iframe>ok', 'ok'];
|
||
yield 'closes unbalanced markup' => ['<strong>unge<em>schlossen', '<strong>unge<em>schlossen</em></strong>'];
|
||
yield 'keeps a local link' => ['<a href="/kontakt">hier</a>', '<a href="/kontakt">hier</a>'];
|
||
yield 'new local tab has no opener' => ['<a href="/kontakt" target="_blank" rel="opener">hier</a>', '<a href="/kontakt" target="_blank" rel="noopener">hier</a>'];
|
||
yield 'rejects named frames' => ['<a href="/kontakt" target="_top">hier</a>', '<a href="/kontakt">hier</a>'];
|
||
yield 'marks an external link' => ['<a href="https://example.com">dort</a>', '<a href="https://example.com" rel="noopener">dort</a>'];
|
||
yield 'escapes plain text and keeps the line break' => ["Eins <3\nZwei", 'Eins <3<br>Zwei'];
|
||
}
|
||
|
||
#[DataProvider('richTextCases')]
|
||
public function testTheAllowlistKeepsFormattingAndRemovesEverythingElse(string $input, string $expected): void
|
||
{
|
||
self::assertSame($expected, (new RichText())->sanitise($input));
|
||
}
|
||
|
||
public function testFilteringHappensWhenTheDocumentIsStored(): void
|
||
{
|
||
$document = DemoContent::starter('Formatiert');
|
||
$document['schemaVersion'] = 12;
|
||
$document['blocks'][1]['children'][1]['props']['text'] = '<b>Fett</b><script>alert(1)</script> und <span style="position:fixed">verpackt</span>';
|
||
$revision = $this->pages->saveDraft('page-1', $document, 0);
|
||
|
||
// The filtering happens on the way in, so the database already holds the safe version and
|
||
// no renderer has to remember to escape anything.
|
||
$stored = json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]), true);
|
||
self::assertSame('<b>Fett</b> und verpackt', $stored['blocks'][1]['children'][1]['props']['text']);
|
||
|
||
// A text that consists only of disallowed markup ends up empty, and an empty text block
|
||
// is refused - the same rule as for a text block somebody clears by hand.
|
||
$document['blocks'][1]['children'][1]['props']['text'] = '<img src=x onerror=alert(1)>';
|
||
try {
|
||
$this->pages->saveDraft('page-1', $document, 1);
|
||
self::fail('Expected an emptied text block to be refused.');
|
||
} catch (InvalidDocument) {
|
||
}
|
||
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', ['page-1']));
|
||
}
|
||
|
||
public function testFormattedTextIsPrintedAsMarkupFromVersionTwelveOnAndEscapedBefore(): void
|
||
{
|
||
$markup = 'Ein <strong>starker</strong> Satz mit <a href="/kontakt">Link</a>.';
|
||
|
||
// v12: the stored markup reaches the page as markup.
|
||
$twelve = DemoContent::starter('Formatiert');
|
||
$twelve['schemaVersion'] = 12;
|
||
$twelve['blocks'][1]['children'][1]['props']['text'] = $markup;
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $twelve, 0), 1);
|
||
$html = $this->request('/')->getContent();
|
||
self::assertStringContainsString('<strong>starker</strong>', $html);
|
||
self::assertStringContainsString('<a href="/kontakt">Link</a>', $html);
|
||
// A div, not a p: the allowlist includes lists and paragraphs, which may not nest in one.
|
||
self::assertStringContainsString('<div id="start-text" class="block-text">', $html);
|
||
|
||
// An older document keeps plain text semantics, so nothing there is ever printed as markup.
|
||
$old = DemoContent::starter('Einfach');
|
||
$old['schemaVersion'] = 3;
|
||
$old['blocks'][1]['children'][1]['props']['text'] = $markup;
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $old, 2), 3);
|
||
$plain = $this->request('/')->getContent();
|
||
self::assertStringContainsString('<strong>starker</strong>', $plain);
|
||
self::assertStringNotContainsString('<strong>starker</strong>', $plain);
|
||
self::assertStringContainsString('<p id="start-text" class="block-text">', $plain);
|
||
}
|
||
|
||
public function testFormattedTextInASharedAreaIsFilteredTheSameWay(): void
|
||
{
|
||
$header = $this->partials->findByKind('header');
|
||
$document = $header['document'];
|
||
$document['schemaVersion'] = 12;
|
||
$document['blocks'][0]['children'][0]['children'][0]['children'][0]['props']['text'] =
|
||
'<strong>kant</strong> cms<script>alert(1)</script>';
|
||
$this->partials->save($header['id'], $document, $header['version']);
|
||
|
||
$stored = $this->partials->findByKind('header')['document'];
|
||
self::assertSame('<strong>kant</strong> cms', $stored['blocks'][0]['children'][0]['children'][0]['children'][0]['props']['text']);
|
||
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||
$html = $this->request('/')->getContent();
|
||
self::assertStringContainsString('<strong>kant</strong> cms', $html);
|
||
self::assertStringNotContainsString('alert(1)', $html);
|
||
}
|
||
|
||
public function testOversizedFormattedTextIsRefused(): void
|
||
{
|
||
$document = DemoContent::starter('Zu lang');
|
||
$document['schemaVersion'] = 12;
|
||
$document['blocks'][1]['children'][1]['props']['text'] = str_repeat('a', RichText::MAX_LENGTH + 1);
|
||
$this->expectException(InvalidDocument::class);
|
||
$this->pages->saveDraft('page-1', $document, 0);
|
||
}
|
||
|
||
/**
|
||
* @return iterable<string, array{string, string}>
|
||
*/
|
||
public static function ownCssCases(): iterable
|
||
{
|
||
yield 'declarations' => ['color: red; padding: 12px', '#b1{color:red;padding:12px}'];
|
||
yield 'nested state' => ['&:hover { color: #000 }', '#b1:hover{color:#000}'];
|
||
yield 'descendant keeps its space' => ['& .block-text { font-size: 20px }', '#b1 .block-text{font-size:20px}'];
|
||
yield 'child combinator' => ['& > p { margin: 0 }', '#b1 > p{margin:0}'];
|
||
yield 'local image' => ['background: url(/bild.png)', '#b1{background:url(/bild.png)}'];
|
||
// 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}'];
|
||
}
|
||
|
||
#[DataProvider('ownCssCases')]
|
||
public function testOwnCssIsScopedToItsElement(string $css, string $expected): void
|
||
{
|
||
self::assertSame($expected, (new CustomCode())->compileCss('b1', $css));
|
||
}
|
||
|
||
/**
|
||
* @return iterable<string, array{string}>
|
||
*/
|
||
public static function refusedOwnCss(): iterable
|
||
{
|
||
yield 'escaping the block' => ['color:red} body{display:none'];
|
||
yield 'foreign selector' => ['body { color: red }'];
|
||
yield 'at rule' => ['@import url(https://evil.example/x.css)'];
|
||
yield 'media query' => ['@media (max-width: 100px) { color: red }'];
|
||
yield 'javascript url' => ['background: url(javascript:alert(1))'];
|
||
yield 'protocol relative url' => ['background: url(//evil.example/x.png)'];
|
||
yield 'backslash escape' => ['content: "\\65"'];
|
||
yield 'old expression syntax' => ['color: expression(alert(1))'];
|
||
yield 'no value' => ['color'];
|
||
yield 'nonsense property' => ['co lor: red'];
|
||
}
|
||
|
||
#[DataProvider('refusedOwnCss')]
|
||
public function testRefusedOwnCssNeverReachesADocument(string $css): void
|
||
{
|
||
$document = DemoContent::starter('Mit CSS');
|
||
$document['schemaVersion'] = 13;
|
||
$document['blocks'][0]['css'] = $css;
|
||
try {
|
||
$this->pages->saveDraft('page-1', $document, 0);
|
||
self::fail(sprintf('Expected "%s" to be refused.', $css));
|
||
} catch (InvalidDocument) {
|
||
}
|
||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', ['page-1']));
|
||
}
|
||
|
||
public function testOwnCssReachesThePageStylesheetAndOwnScriptItsOwnFile(): void
|
||
{
|
||
$document = DemoContent::starter('Mit eigenem Code');
|
||
$document['schemaVersion'] = 13;
|
||
$document['blocks'][0]['css'] = 'letter-spacing: 2px; &:hover { opacity: 0.8 }';
|
||
$document['blocks'][0]['script'] = "element.dataset.ready = 'yes';";
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||
|
||
$response = $this->request('/');
|
||
$html = $response->getContent();
|
||
self::assertStringContainsString('#start-hero{letter-spacing:2px}', $html);
|
||
self::assertStringContainsString('#start-hero:hover{opacity:0.8}', $html);
|
||
|
||
// The script is a file, never inline, and the policy opens only for this origin.
|
||
self::assertStringContainsString('<script src="/assets/seite/page-1.js" defer></script>', $html);
|
||
self::assertStringNotContainsString('element.dataset.ready', $html);
|
||
self::assertStringContainsString("script-src 'self'", (string) $response->headers->get('Content-Security-Policy'));
|
||
|
||
$script = $this->request('/assets/seite/page-1.js');
|
||
self::assertSame(200, $script->getStatusCode());
|
||
self::assertStringContainsString('text/javascript', (string) $script->headers->get('Content-Type'));
|
||
// Each script runs in its own closure with its element already looked up.
|
||
self::assertStringContainsString('document.getElementById("start-hero")', $script->getContent());
|
||
self::assertStringContainsString("element.dataset.ready = 'yes';", $script->getContent());
|
||
|
||
// A page without own script keeps the strict policy and loads no file.
|
||
$plain = $this->publishedPage('ohne-code', 'Ohne Code');
|
||
self::assertNotNull($this->pages->findForEditor($plain));
|
||
$other = $this->request('/ohne-code');
|
||
self::assertStringNotContainsString('/assets/seite/', $other->getContent());
|
||
self::assertStringContainsString("script-src 'none'", (string) $other->headers->get('Content-Security-Policy'));
|
||
self::assertSame(404, $this->request('/assets/seite/'.$plain.'.js')->getStatusCode());
|
||
}
|
||
|
||
public function testADraftScriptIsNotServedToVisitors(): void
|
||
{
|
||
$document = DemoContent::starter('Nur Entwurf');
|
||
$document['schemaVersion'] = 13;
|
||
$document['blocks'][0]['script'] = "element.dataset.draft = 'yes';";
|
||
$this->pages->saveDraft('page-1', $document, 0);
|
||
|
||
// Saved but not published, so the script file must not exist yet.
|
||
self::assertSame(404, $this->request('/assets/seite/page-1.js')->getStatusCode());
|
||
}
|
||
|
||
public function testOwnCodeNeedsSchemaThirteen(): void
|
||
{
|
||
$document = DemoContent::starter('Zu alt');
|
||
$document['schemaVersion'] = 12;
|
||
$document['blocks'][0]['css'] = 'color: red';
|
||
$this->expectException(InvalidDocument::class);
|
||
$this->pages->saveDraft('page-1', $document, 0);
|
||
}
|
||
|
||
public function testScriptChangesAreComparedAgainstWhatIsStored(): void
|
||
{
|
||
$code = new CustomCode();
|
||
$stored = DemoContent::starter('Mit Skript');
|
||
$stored['schemaVersion'] = 13;
|
||
$stored['blocks'][0]['script'] = "element.dataset.a = '1';";
|
||
|
||
// Same scripts: an editor may keep saving the page.
|
||
$unchanged = $stored;
|
||
$unchanged['title'] = 'Anderer Titel';
|
||
$code->assertScriptsUnchanged($unchanged, $stored);
|
||
|
||
foreach (['changed' => "element.dataset.a = '2';", 'removed' => null] as $case => $script) {
|
||
$attempt = $stored;
|
||
if (null === $script) {
|
||
unset($attempt['blocks'][0]['script']);
|
||
} else {
|
||
$attempt['blocks'][0]['script'] = $script;
|
||
}
|
||
try {
|
||
$code->assertScriptsUnchanged($attempt, $stored);
|
||
self::fail(sprintf('Expected a %s script to be refused.', $case));
|
||
} catch (InvalidDocument) {
|
||
}
|
||
}
|
||
|
||
// And adding one to a page that had none is refused as well.
|
||
$added = DemoContent::starter('Neu');
|
||
$added['schemaVersion'] = 13;
|
||
$before = $added;
|
||
$added['blocks'][0]['script'] = 'alert(1)';
|
||
$this->expectException(InvalidDocument::class);
|
||
$code->assertScriptsUnchanged($added, $before);
|
||
}
|
||
|
||
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 <br> 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<br>Zeile zwei',
|
||
'<p>A & B</p>' => '<p>A & B</p>',
|
||
'<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' => '<table class="preise"><tr><td>Miete</td><td>ab 8 €/m²</td></tr></table>']];
|
||
$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('<div id="roh" class="block-html">', $html);
|
||
// Unverändert heißt unverändert: keine Entschärfung, keine Umschrift.
|
||
self::assertStringContainsString('<table class="preise"><tr><td>Miete</td><td>ab 8 €/m²</td></tr></table>', $html);
|
||
|
||
foreach ([
|
||
'<script>alert(1)</script>' => 'Skript',
|
||
'<iframe src="https://example.com"></iframe>' => 'Rahmen',
|
||
'< script >alert(1)</script>' => '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' => '<p>Original</p>']];
|
||
|
||
$unchanged = $stored;
|
||
$unchanged['title'] = 'Anderer Titel';
|
||
$code->assertHtmlUnchanged($unchanged, $stored);
|
||
|
||
foreach (['geändert' => '<p>Fremd</p>', '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('/<img[^>]*id="erstes"|id="erstes"[^>]*>/', $html);
|
||
$first = $this->imageTag($html, 'erstes');
|
||
$second = $this->imageTag($html, 'zweites');
|
||
self::assertStringContainsString('loading="eager"', $first);
|
||
self::assertStringContainsString('fetchpriority="high"', $first);
|
||
self::assertStringContainsString('loading="lazy"', $second);
|
||
self::assertStringNotContainsString('fetchpriority', $second);
|
||
}
|
||
|
||
public function testTheBackgroundOfTheFirstSectionIsFetchedBeforeTheStylesheetFindsIt(): void
|
||
{
|
||
// Auf der Startseite des Piloten ist das größte Element über der Falz kein `<img>`, sondern
|
||
// der Hintergrund des ersten Abschnitts. Ein Hintergrundbild steht nur im Stylesheet, und
|
||
// das kommt nach dem HTML - der Browser erfährt also erst vom Bild, wenn er das CSS
|
||
// geparst hat. Gemessen: 1,3 s statt 0,9 s bis zum größten Element.
|
||
$media = new MediaLibrary($this->db, $this->path.'-media');
|
||
$datei = tempnam(sys_get_temp_dir(), 'kant-hero-');
|
||
$limit = ini_get('memory_limit');
|
||
ini_set('memory_limit', '512M');
|
||
|
||
try {
|
||
$bild = imagecreatetruecolor(2600, 1600);
|
||
imagejpeg($bild, $datei, 80);
|
||
imagedestroy($bild);
|
||
$item = $media->upload(new UploadedFile($datei, 'hero.jpg', test: true));
|
||
|
||
$version = static fn (Connection $db): int => (int) $db->fetchOne('SELECT version FROM page WHERE id = ?', ['page-1']);
|
||
$ohne = DemoContent::starter('Ohne Hintergrund');
|
||
$entwurf = $this->pages->saveDraft('page-1', $ohne, $version($this->db));
|
||
$this->pages->publish('page-1', $entwurf, $version($this->db));
|
||
$sparsam = (int) $this->request('/')->headers->get('X-Kant-Queries');
|
||
|
||
$mit = DemoContent::starter('Mit Hintergrund');
|
||
$mit['schemaVersion'] = max(10, $mit['schemaVersion'] ?? 10);
|
||
$mit['blocks'][0]['style']['desktop']['backgroundImage'] = $item['id'];
|
||
$entwurf = $this->pages->saveDraft('page-1', $mit, $version($this->db));
|
||
$this->pages->publish('page-1', $entwurf, $version($this->db));
|
||
$antwort = $this->request('/');
|
||
$html = (string) $antwort->getContent();
|
||
|
||
// Drei Verweise, einer je Gerät - und genau einer trifft zu.
|
||
preg_match_all('/<link rel="preload" as="image" href="([^"]+)" media="([^"]+)"[^>]*>/', $html, $treffer, PREG_SET_ORDER);
|
||
self::assertCount(3, $treffer, 'Der Hintergrund des ersten Abschnitts wird nicht vorab geholt.');
|
||
self::assertSame(
|
||
['(min-width:1025px)', '(min-width:768px) and (max-width:1024px)', '(max-width:767px)'],
|
||
array_column($treffer, 2),
|
||
);
|
||
self::assertSame(
|
||
['/media/'.$item['id'].'/2400', '/media/'.$item['id'].'/1600', '/media/'.$item['id'].'/960'],
|
||
array_column($treffer, 1),
|
||
'Jedes Gerät bekommt die Variante, die es auch ohne Vorabholen bekäme.',
|
||
);
|
||
foreach ($treffer as $verweis) {
|
||
// Der Punkt der Übung: Vorabverweis und Regel müssen dieselbe Datei meinen. Sonst
|
||
// lädt der Browser zwei Bilder statt einem, und das Vorabholen kostet mehr als es
|
||
// bringt.
|
||
self::assertStringContainsString('url("'.$verweis[1].'")', $html, 'Der Vorabverweis meint eine andere Datei als die Regel.');
|
||
self::assertStringContainsString('fetchpriority="high"', $verweis[0]);
|
||
}
|
||
|
||
// Dasselbe Bild wird während einer Ausgabe mehrfach gesucht: je Gerät eine Regel, dazu
|
||
// der Kopf. Gefragt wird trotzdem nur einmal.
|
||
$mitBild = (int) $antwort->headers->get('X-Kant-Queries');
|
||
self::assertLessThanOrEqual($sparsam + 1, $mitBild, sprintf(
|
||
'Ein Hintergrundbild kostet eine Abfrage, nicht vier (%d statt %d).',
|
||
$mitBild,
|
||
$sparsam,
|
||
));
|
||
} finally {
|
||
@unlink($datei);
|
||
ini_set('memory_limit', (string) $limit);
|
||
}
|
||
}
|
||
|
||
public function testAPageDoesNotAskTheDatabaseOverAndOver(): void
|
||
{
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||
$response = $this->request('/');
|
||
$queries = (int) $response->headers->get('X-Kant-Queries');
|
||
|
||
// A ceiling, not an exact number: the point is that settings and theme are read once per
|
||
// request instead of once per caller. Before that they were read eighteen times.
|
||
self::assertGreaterThan(0, $queries, 'The counter has to be wired up at all.');
|
||
self::assertLessThanOrEqual(12, $queries, sprintf('Die Startseite braucht %d Abfragen.', $queries));
|
||
}
|
||
|
||
/** The img tag belonging to one block id, so the attributes can be read for that one image. */
|
||
private function imageTag(string $html, string $id): string
|
||
{
|
||
$figure = strpos($html, 'id="'.$id.'"');
|
||
self::assertNotFalse($figure, 'Bild '.$id.' fehlt.');
|
||
$start = strpos($html, '<img', $figure);
|
||
self::assertNotFalse($start);
|
||
|
||
return substr($html, $start, (int) strpos($html, '>', $start) - $start);
|
||
}
|
||
|
||
public function testAPageCanUseAnotherHeaderOrNoneAtAll(): void
|
||
{
|
||
// The seeded areas plus a second header for landing pages.
|
||
$this->partials->create('header', 'landing', 'Landing-Kopf', [
|
||
'schemaVersion' => 11, 'title' => 'Landing', 'description' => 'x',
|
||
'blocks' => [['id' => 'landing-text', 'type' => 'text', 'props' => ['text' => 'Nur ein Logo']]],
|
||
]);
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||
|
||
// By default the standard header is used.
|
||
$html = $this->request('/')->getContent();
|
||
self::assertStringContainsString('area-header-name', $html);
|
||
self::assertStringNotContainsString('landing-text', $html);
|
||
|
||
// The page can ask for the other one.
|
||
$version = (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['page-1']);
|
||
$this->pages->saveAreas('page-1', 'landing', null, $version);
|
||
$html = $this->request('/')->getContent();
|
||
self::assertStringContainsString('landing-text', $html);
|
||
self::assertStringNotContainsString('area-header-name', $html);
|
||
|
||
// Or for none at all - a landing page without navigation.
|
||
$this->pages->saveAreas('page-1', 'none', 'none', $version + 1);
|
||
$html = $this->request('/')->getContent();
|
||
self::assertStringNotContainsString('site-header', $html);
|
||
self::assertStringNotContainsString('site-footer', $html);
|
||
self::assertStringContainsString('Deine Inhalte', $html, 'The page itself is still there.');
|
||
|
||
// An address that no longer exists falls back to the standard area instead of silently
|
||
// losing the header.
|
||
$this->pages->saveAreas('page-1', 'weg', null, $version + 2);
|
||
self::assertStringContainsString('area-header-name', $this->request('/')->getContent());
|
||
|
||
// A stale version is refused, and an invalid address too.
|
||
$this->expectException(StaleWrite::class);
|
||
$this->pages->saveAreas('page-1', null, null, $version);
|
||
}
|
||
|
||
public function testAnInvalidAreaAddressIsRefused(): void
|
||
{
|
||
$this->expectException(InvalidPage::class);
|
||
$this->pages->saveAreas('page-1', 'Kein Slug!', null, 0);
|
||
}
|
||
|
||
/** @return iterable<string, array{string, bool}> */
|
||
public static function menuLayouts(): iterable
|
||
{
|
||
yield 'horizontal' => ['horizontal', true];
|
||
yield 'vertical' => ['vertical', true];
|
||
yield 'burger without a script' => ['burger', true];
|
||
yield 'invented' => ['mega', false];
|
||
}
|
||
|
||
#[DataProvider('menuLayouts')]
|
||
public function testAMenuCanBeLaidOutInThreeWays(string $layout, bool $accepted): void
|
||
{
|
||
$document = DemoContent::starter('Mit Menü');
|
||
$document['schemaVersion'] = 16;
|
||
$document['blocks'][0]['children'][] = ['id' => 'nav', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => $layout]];
|
||
if (!$accepted) {
|
||
$this->expectException(InvalidDocument::class);
|
||
$this->pages->saveDraft('page-1', $document, 0);
|
||
|
||
return;
|
||
}
|
||
$this->menus->save('header', 'Hauptnavigation', [['label' => 'Start', 'type' => 'page', 'level' => 1, 'pageId' => 'page-1', 'position' => 10]], 0);
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||
$html = $this->request('/')->getContent();
|
||
self::assertStringContainsString('class="block-menu menu-'.$layout, $html);
|
||
// The burger opens with a details element, so it works on a page that runs no script.
|
||
$crawler = new \Symfony\Component\DomCrawler\Crawler($html);
|
||
self::assertCount('vertical' === $layout ? 0 : 1, $crawler->filter('#nav > details.menu-burger'));
|
||
self::assertStringContainsString("script-src 'none'", (string) $this->request('/')->headers->get('Content-Security-Policy'));
|
||
}
|
||
|
||
/** @return iterable<string, array{string, list<string>, list<string>}> */
|
||
public static function burgerLabels(): iterable
|
||
{
|
||
yield 'symbol and text' => ['both', ['<span class="burger-lines"', '<span class="burger-text">Menü'], []];
|
||
yield 'only text' => ['text', ['<span class="burger-text">Menü'], ['burger-lines']];
|
||
yield 'only the symbol, the name stays for screen readers' => ['icon', ['<span class="burger-lines"', '<span class="burger-text visually-hidden">Menü'], []];
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $enthalten
|
||
* @param list<string> $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('<span class="visually-hidden">Bewertung: 5 von 5</span>', $this->request('/')->getContent());
|
||
}
|
||
|
||
public function testAColumnIsALayoutDeviceAndNotABox(): void
|
||
{
|
||
// Eine Spalte brachte Rahmen, Ecken und 30 px Innenabstand mit. Wer zwei Texte
|
||
// nebeneinander stellte, bekam zwei Kästchen. Die Karte ist eine Einstellung, kein
|
||
// Standard - und der Knopf im Aufruf-Band nimmt die Akzentfarbe, weil er auf einem
|
||
// dunklen Band vorher Tinte auf Tinte war.
|
||
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
|
||
self::assertStringContainsString('.block-column{min-width:0}', $css);
|
||
self::assertStringNotContainsString('.block-column{border:1px solid', $css);
|
||
self::assertStringContainsString('.kant .block-cta .button{background:var(--accent)', $css);
|
||
self::assertStringContainsString('.block-iconcard + .block-iconcard', $css, 'Gestapelte Karten brauchen Abstand.');
|
||
}
|
||
|
||
public function testHeadingsAreNotHyphenated(): void
|
||
{
|
||
// Beim Nachbau von kant-it.de stand in der Überschrift „di-gitale Exzellenz“: `hyphens:auto`
|
||
// galt für h1 bis h3. Im Fließtext ist Trennung nützlich, in einer Überschrift sieht sie
|
||
// nach einem Fehler aus - und genau dort fällt sie auf, weil die Schrift groß ist.
|
||
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
|
||
self::assertStringContainsString('h1,h2,h3,h4,h5,h6{hyphens:manual}', $css);
|
||
self::assertStringContainsString('p{hyphens:auto}', $css);
|
||
self::assertStringNotContainsString('hyphens:auto}', explode('p{hyphens:auto}', $css)[0], 'Vor der Regel für Absätze trennt nichts.');
|
||
}
|
||
|
||
public function testTheGlobalContentWidthCanMakeThePageWiderNotOnlyNarrower(): void
|
||
{
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||
// The stylesheet takes the number from the theme instead of a hard 1180px, which capped
|
||
// every larger value and made the setting look broken.
|
||
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
|
||
self::assertStringContainsString('.shell{width:min(var(--k-container,1180px),calc(100% - 64px))', $css);
|
||
|
||
$theme = $this->themes->load();
|
||
$this->themes->save($theme['colors'], $theme['fonts'], [...$theme['base'], 'containerWidth' => '1440px']);
|
||
$html = $this->request('/')->getContent();
|
||
self::assertStringContainsString('--k-container:1440px', $html);
|
||
}
|
||
|
||
/** @return iterable<string, array{string, bool}> */
|
||
public static function sectionWidths(): iterable
|
||
{
|
||
yield 'normal' => ['normal', true];
|
||
yield 'narrow' => ['narrow', true];
|
||
yield 'wide' => ['wide', true];
|
||
yield 'edge to edge' => ['full', true];
|
||
yield 'invented' => ['riesig', false];
|
||
}
|
||
|
||
#[DataProvider('sectionWidths')]
|
||
public function testASectionCanRunEdgeToEdge(string $width, bool $accepted): void
|
||
{
|
||
$document = DemoContent::starter('Breite');
|
||
$document['blocks'][0]['layout'] = ['tone' => 'paper', 'width' => $width];
|
||
if (!$accepted) {
|
||
$this->expectException(InvalidDocument::class);
|
||
$this->pages->saveDraft('page-1', $document, 0);
|
||
|
||
return;
|
||
}
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||
self::assertStringContainsString('width-'.$width, $this->request('/')->getContent());
|
||
}
|
||
|
||
/** @return iterable<string, array{string, bool}> */
|
||
public static function heights(): iterable
|
||
{
|
||
// 100vh is wrong on a phone - it means the viewport without the address bar. The unit
|
||
// people actually want is dvh, so it has to be allowed.
|
||
yield 'dynamic viewport height' => ['100dvh', true];
|
||
yield 'small viewport height' => ['100svh', true];
|
||
yield 'large viewport height' => ['100lvh', true];
|
||
yield 'classic viewport height' => ['100vh', true];
|
||
yield 'pixels' => ['640px', true];
|
||
yield 'invented unit' => ['100qh', false];
|
||
yield 'no unit' => ['100', false];
|
||
}
|
||
|
||
#[DataProvider('heights')]
|
||
public function testAFullHeightSectionIsPossibleWithTheUnitThatWorksOnPhones(string $height, bool $accepted): void
|
||
{
|
||
$document = DemoContent::starter('Hoch');
|
||
$document['schemaVersion'] = 10;
|
||
$document['blocks'][0]['style'] = ['desktop' => ['minHeight' => $height, 'display' => 'flex', 'alignItems' => 'center']];
|
||
if (!$accepted) {
|
||
$this->expectException(InvalidDocument::class);
|
||
$this->pages->saveDraft('page-1', $document, 0);
|
||
|
||
return;
|
||
}
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||
$html = $this->request('/')->getContent();
|
||
self::assertStringContainsString('min-height:'.$height, $html);
|
||
// Full height alone would put the content at the top; the flex properties centre it.
|
||
self::assertStringContainsString('display:flex;align-items:center', $html);
|
||
}
|
||
|
||
public function testAFullBleedImageFillsTheSection(): void
|
||
{
|
||
$document = DemoContent::starter('Bildfläche');
|
||
$document['schemaVersion'] = 15;
|
||
$document['blocks'][0]['layout'] = ['tone' => 'paper', 'width' => 'full'];
|
||
$document['blocks'][0]['style'] = ['desktop' => ['padding' => ['top' => '0', 'bottom' => '0']]];
|
||
$document['blocks'][0]['children'][] = [
|
||
'id' => 'flaeche', 'type' => 'image',
|
||
'props' => ['src' => '/assets/image-placeholder.svg', 'alt' => 'Weite Landschaft', 'decorative' => false, 'width' => 2400, 'height' => 1200],
|
||
'style' => ['desktop' => ['width' => '100%', 'objectFit' => 'cover', 'imageHeight' => '100dvh']],
|
||
];
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||
$html = $this->request('/')->getContent();
|
||
self::assertStringContainsString('width-full', $html);
|
||
self::assertStringContainsString('#flaeche{width:100%}', $html);
|
||
// The height and the crop belong on the image itself, not on its figure.
|
||
self::assertStringContainsString('#flaeche img{object-fit:cover;height:100dvh}', $html);
|
||
}
|
||
|
||
/** @return iterable<string, array{string, array<string, mixed>, list<string>}> */
|
||
public static function newBlocks(): iterable
|
||
{
|
||
yield 'a decorative icon is hidden from screen readers' => [
|
||
'icon', ['icon' => 'star', 'size' => 48, 'decorative' => true, 'label' => '', 'href' => ''],
|
||
['class="block-icon icon-size-48', 'aria-hidden="true"'],
|
||
];
|
||
yield 'a meaningful icon carries its label as text' => [
|
||
'icon', ['icon' => 'phone', 'size' => 32, 'decorative' => false, 'label' => 'Anrufen', 'href' => '/kontakt'],
|
||
['<a id="i1"', 'href="/kontakt"', '<span class="visually-hidden">Anrufen</span>'],
|
||
];
|
||
yield 'a progress bar states its value in the markup' => [
|
||
'progress', ['label' => 'Fortschritt', 'value' => 70, 'showValue' => true],
|
||
['role="meter"', 'aria-valuenow="70"', 'progress-fill-70', '70 %'],
|
||
];
|
||
yield 'a slider is a scroll container with a class per device' => [
|
||
'slider', ['desktopVisible' => 3, 'tabletVisible' => 2, 'mobileVisible' => 1, 'snap' => true],
|
||
['class="block-slider slide-desktop-3 slide-tablet-2 slide-mobile-1 slider-snap"', 'tabindex="0"', 'role="group"'],
|
||
];
|
||
}
|
||
|
||
#[DataProvider('newBlocks')]
|
||
public function testTheNewBlocksRenderWhatTheyPromise(string $type, array $props, array $expected): void
|
||
{
|
||
$document = DemoContent::starter('Mit '.$type);
|
||
$document['schemaVersion'] = 15;
|
||
$document['blocks'][] = ['id' => 'i1', 'type' => $type, 'props' => $props];
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||
$html = $this->request('/')->getContent();
|
||
foreach ($expected as $needle) {
|
||
self::assertStringContainsString($needle, $html, $type);
|
||
}
|
||
// Sliders receive controls; the other blocks keep a script-free public page.
|
||
self::assertStringContainsString('slider' === $type ? "script-src 'self'" : "script-src 'none'", (string) $this->request('/')->headers->get('Content-Security-Policy'));
|
||
}
|
||
|
||
/** @return iterable<string, array{string, array<string, mixed>}> */
|
||
public static function refusedNewBlocks(): iterable
|
||
{
|
||
// A size without a class would leave the icon without dimensions, so only the listed ones.
|
||
yield 'icon size off the list' => ['icon', ['icon' => 'star', 'size' => 37, 'decorative' => true, 'label' => '']];
|
||
yield 'unknown icon' => ['icon', ['icon' => 'erfunden', 'size' => 48, 'decorative' => true, 'label' => '']];
|
||
yield 'meaningful icon without a label' => ['icon', ['icon' => 'star', 'size' => 48, 'decorative' => false, 'label' => '']];
|
||
yield 'decorative icon with a label' => ['icon', ['icon' => 'star', 'size' => 48, 'decorative' => true, 'label' => 'Doch etwas']];
|
||
yield 'progress beyond a hundred' => ['progress', ['label' => 'X', 'value' => 140, 'showValue' => true]];
|
||
yield 'progress with a text value' => ['progress', ['label' => 'X', 'value' => '70', 'showValue' => true]];
|
||
yield 'slider with seven visible' => ['slider', ['desktopVisible' => 7, 'tabletVisible' => 2, 'mobileVisible' => 1, 'snap' => true]];
|
||
yield 'slider missing a device' => ['slider', ['desktopVisible' => 3, 'tabletVisible' => 2, 'snap' => true]];
|
||
}
|
||
|
||
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('#<ul id="so1" class="block-social social-horizontal social-shape-circle social-size-32"[^>]*>.*facebook\.com.*instagram\.com.*</ul>#s', $html);
|
||
self::assertStringContainsString('<span class="visually-hidden">Facebook</span>', $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('#<ul id="so2"[^>]*>(.*?)</ul>#s', $html, $second);
|
||
self::assertStringNotContainsString('facebook', $second[1]);
|
||
self::assertStringContainsString('<span>Instagram</span>', $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('#<script type="application/ld\+json">(.*?)</script>#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<string, array{array<string, mixed>}> */
|
||
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('#<script type="application/ld\+json">(.*?)</script>#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<string, array{array<string, mixed>}> */
|
||
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 <strong>Baden-Baden</strong> & 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('#<script type="application/ld\+json">(.*?)</script>#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, array{array<string, mixed>, string}> */
|
||
public static function styleDeclarations(): iterable
|
||
{
|
||
// A border width per side is spelled border-top-width, not border-width-top. Written the
|
||
// wrong way the browser drops the declaration, so per-side borders had no effect at all.
|
||
yield 'border width per side' => [['borderWidth' => ['top' => '2px', 'left' => '4px']], '#b1{border-top-width:2px;border-left-width:4px}'];
|
||
yield 'padding keeps its own spelling' => [['padding' => ['top' => '12px']], '#b1{padding-top:12px}'];
|
||
yield 'corners' => [['borderRadius' => ['topLeft' => '18px']], '#b1{border-top-left-radius:18px}'];
|
||
yield 'outline' => [['outlineWidth' => '3px', 'outlineColor' => '#2c6b4f', 'outlineOffset' => '2px'], '#b1{outline-width:3px;outline-color:#2c6b4f;outline-offset:2px}'];
|
||
yield 'position and layer' => [['position' => 'absolute', 'top' => '0', 'right' => '12px', 'zIndex' => '5'], '#b1{position:absolute;top:0;right:12px;z-index:5}'];
|
||
yield 'flex item' => [['alignSelf' => 'center', 'flexGrow' => '2', 'flexBasis' => '40%'], '#b1{align-self:center;flex-grow:2;flex-basis:40%}', 'column'];
|
||
yield 'separate gaps' => [['rowGap' => '8px', 'columnGap' => '24px'], '#b1{row-gap:8px;column-gap:24px}'];
|
||
yield 'inner shadow' => [['shadowY' => '4px', 'shadowBlur' => '12px', 'shadowInset' => 'innen'], '#b1{box-shadow:inset 0 4px 12px 0 #00000026}'];
|
||
yield 'outer shadow stays outer' => [['shadowY' => '4px'], '#b1{box-shadow:0 4px 0 0 #00000026}'];
|
||
yield 'text shadow' => [['textShadowY' => '2px', 'textShadowBlur' => '6px', 'textShadowColor' => '#00000080'], '#b1{text-shadow:0 2px 6px #00000080}'];
|
||
// The filter order is fixed by the compiler, not by the order someone set the values in.
|
||
yield 'filters in a fixed order' => [['grayscale' => '0.4', 'blur' => '2', 'brightness' => '1.1'], '#b1{filter:blur(2px) brightness(1.1) grayscale(0.4)}'];
|
||
yield 'hue rotation' => [['hueRotate' => '-30'], '#b1{filter:hue-rotate(-30deg)}'];
|
||
yield 'blend mode' => [['mixBlendMode' => 'multiply'], '#b1{mix-blend-mode:multiply}'];
|
||
// These belong on the image inside the block: object-fit on a figure does nothing.
|
||
yield 'image properties target the image' => [['objectFit' => 'cover', 'imageRatio' => '16/9'], '#b1 img{object-fit:cover;aspect-ratio:16/9}'];
|
||
yield 'image radius and border' => [['imageRadius' => ['topLeft' => '12px'], 'imageBorderWidth' => ['top' => '3px'], 'imageBorderColor' => '#2c6b4f'], '#b1 img{border-top-left-radius:12px;border-top-width:3px;border-color:#2c6b4f}'];
|
||
yield 'both at once, in two rules' => [['padding' => ['top' => '8px'], 'objectFit' => 'contain'], '#b1{padding-top:8px}#b1 img{object-fit:contain}'];
|
||
}
|
||
|
||
#[DataProvider('styleDeclarations')]
|
||
public function testEveryStylePropertyCompilesToTheDeclarationItPromises(array $style, string $expected, string $type = 'image'): void
|
||
{
|
||
$compiler = new StyleCompiler();
|
||
self::assertSame($expected, $compiler->compileDocument([
|
||
'blocks' => [['id' => 'b1', 'type' => $type, 'style' => ['desktop' => $style]]],
|
||
]));
|
||
// And what compiles has to survive the validator, or the editor could offer it in vain.
|
||
$document = DemoContent::starter('Mit Stil');
|
||
$document['schemaVersion'] = 10;
|
||
$document['blocks'][0]['style'] = ['desktop' => $style];
|
||
$this->pages->saveDraft('page-1', $document, 0);
|
||
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', ['page-1']));
|
||
}
|
||
|
||
public function testTheStyleSchemaAndTheEditorPanelKnowTheSameGroups(): void
|
||
{
|
||
$groups = [];
|
||
foreach (StyleSchema::properties() as $property) {
|
||
$groups[$property['group']] = true;
|
||
}
|
||
$panel = (string) file_get_contents(__DIR__.'/../public/assets/design-panel.js');
|
||
foreach (array_keys($groups) as $group) {
|
||
// A group the panel does not know would hold its properties in an unnamed accordion.
|
||
self::assertMatchesRegularExpression('/^\h*'.preg_quote($group, '/').':\h/m', $panel, sprintf('Die Gruppe „%s“ fehlt im Panel.', $group));
|
||
}
|
||
}
|
||
|
||
public function testALegalPathMustPointAtAPageThatExists(): void
|
||
{
|
||
foreach (['privacyPath', 'imprintPath'] as $key) {
|
||
try {
|
||
$this->site->save(['homePath' => 'home', $key => 'gibt-es-nicht']);
|
||
self::fail('Expected '.$key.' to be refused.');
|
||
} catch (InvalidPage) {
|
||
}
|
||
self::assertSame('', $this->site->load()[$key]);
|
||
}
|
||
// A page that exists is accepted; placing its link remains an editorial choice.
|
||
$this->publishedPage('datenschutz', 'Datenschutz');
|
||
$this->site->save(['homePath' => 'home', 'privacyPath' => 'datenschutz']);
|
||
self::assertSame('datenschutz', $this->site->load()['privacyPath']);
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||
self::assertStringNotContainsString('<a href="/datenschutz">Datenschutz</a>', $this->request('/')->getContent());
|
||
}
|
||
|
||
public function testADeliveredPageIncrementsOneCounterAndTheAdministrationDoesNot(): void
|
||
{
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||
$this->request('/');
|
||
$this->request('/');
|
||
// A page that does not exist counts as a missing address, not as a page view.
|
||
$this->request('/gibt-es-nicht');
|
||
// Everything that is not a page of the site stays out of the numbers.
|
||
$this->request('/admin');
|
||
$this->request('/feed.xml');
|
||
$this->request('/robots.txt');
|
||
|
||
$rows = $this->db->fetchAllAssociative('SELECT path, status, count FROM stat_hit ORDER BY status');
|
||
self::assertSame([
|
||
['path' => '/', 'status' => 200, 'count' => 2],
|
||
['path' => '/gibt-es-nicht', 'status' => 404, 'count' => 1],
|
||
], array_map(static fn (array $row): array => ['path' => $row['path'], 'status' => (int) $row['status'], 'count' => (int) $row['count']], $rows));
|
||
}
|
||
|
||
public function testWithoutEmbedsThereIsNoConsentQuestion(): void
|
||
{
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||
$html = $this->request('/')->getContent();
|
||
// Nothing is loaded from anywhere else, so there is nothing to ask about - and a banner
|
||
// without a reason is only in the way.
|
||
self::assertStringNotContainsString('consent-bar', $html);
|
||
self::assertStringNotContainsString('Einwilligung ändern', $html);
|
||
self::assertSame(404, $this->request('/einwilligung')->getStatusCode());
|
||
}
|
||
|
||
public function 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<string, array{string, bool}> */
|
||
public static function consentCookies(): iterable
|
||
{
|
||
yield 'granted' => ['1|external|'.time(), true];
|
||
yield 'refused' => ['1|<EMPTY>|'.time(), false];
|
||
yield 'other group only' => ['1|statistics|'.time(), false];
|
||
yield 'expired' => ['1|external|'.(time() - Consent::MAX_AGE - 60), false];
|
||
yield 'older question' => ['0|external|'.time(), false];
|
||
yield 'nonsense' => ['ganz-egal', false];
|
||
yield 'unknown group' => ['1|erfunden|'.time(), false];
|
||
}
|
||
|
||
#[DataProvider('consentCookies')]
|
||
public function testAnEmbedLoadsOnlyWithAValidDecisionForItsGroup(string $cookie, bool $expected): void
|
||
{
|
||
$document = DemoContent::starter('Mit Karte');
|
||
$document['schemaVersion'] = 14;
|
||
$document['blocks'][] = [
|
||
'id' => 'karte', 'type' => 'embed',
|
||
'props' => ['group' => 'external', 'title' => 'Anfahrt', 'provider' => 'OpenStreetMap', 'url' => 'https://karten.example/einbetten?ort=kant', 'hint' => '', 'height' => 400],
|
||
];
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||
$this->askConsent();
|
||
|
||
$response = $this->request('/', 'GET', [], [Consent::COOKIE => str_replace('<EMPTY>', '', $cookie)]);
|
||
$html = $response->getContent();
|
||
$policy = (string) $response->headers->get('Content-Security-Policy');
|
||
if ($expected) {
|
||
self::assertStringContainsString('<iframe src="https://karten.example/einbetten?ort=kant"', $html);
|
||
self::assertStringContainsString('title="Anfahrt"', $html);
|
||
self::assertStringContainsString('frame-src https://karten.example', $policy);
|
||
} else {
|
||
// Not a hidden iframe, not a lazy one: the address does not appear at all.
|
||
self::assertStringNotContainsString('<iframe', $html);
|
||
self::assertStringNotContainsString('karten.example/einbetten', $html);
|
||
self::assertStringContainsString('OpenStreetMap', $html, 'The placeholder names the provider.');
|
||
self::assertStringContainsString("frame-src 'none'", $policy);
|
||
}
|
||
}
|
||
|
||
public function testTheEmbedButtonGrantsOnlyItsOwnGroupAndKeepsWhatWasGrantedBefore(): void
|
||
{
|
||
$document = DemoContent::starter('Mit Karte');
|
||
$document['schemaVersion'] = 14;
|
||
$document['blocks'][] = [
|
||
'id' => 'karte', 'type' => 'embed',
|
||
'props' => ['group' => 'external', 'title' => 'Anfahrt', 'provider' => 'Karten', 'url' => 'https://karten.example/x', 'hint' => '', 'height' => 300],
|
||
];
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||
$this->askConsent(['external', 'statistics']);
|
||
|
||
// Statistics were granted earlier; loading the map must not take that away.
|
||
$before = '1|statistics|'.time();
|
||
$html = $this->request('/', 'GET', [], [Consent::COOKIE => $before])->getContent();
|
||
self::assertStringContainsString('name="groups[]" value="statistics"', $html);
|
||
self::assertStringContainsString('name="groups[]" value="external"', $html);
|
||
|
||
$saved = $this->request('/einwilligung', 'POST', ['choice' => 'some', 'groups' => ['statistics', 'external'], 'back' => '/']);
|
||
$value = (string) $saved->headers->getCookies()[0]->getValue();
|
||
self::assertStringContainsString('statistics', $value);
|
||
self::assertStringContainsString('external', $value);
|
||
}
|
||
|
||
public function testTheConsentFormNeverSendsAnyoneElsewhere(): void
|
||
{
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||
$this->askConsent();
|
||
foreach (['https://fremd.example/', '//fremd.example/', 'fremd.example', ''] as $target) {
|
||
$response = $this->request('/einwilligung', 'POST', ['choice' => 'none', 'back' => $target]);
|
||
self::assertSame('/', $response->headers->get('Location'), $target);
|
||
}
|
||
// A local path is kept, so the decision returns to the page it was made on.
|
||
self::assertSame('/datenschutz', $this->request('/einwilligung', 'POST', ['choice' => 'none', 'back' => '/datenschutz'])->headers->get('Location'));
|
||
}
|
||
|
||
public function testSwitchingTheQuestionOffLetsTheEmbedLoadAgain(): void
|
||
{
|
||
$document = DemoContent::starter('Mit Karte');
|
||
$document['schemaVersion'] = 14;
|
||
$document['blocks'][] = [
|
||
'id' => 'karte', 'type' => 'embed',
|
||
'props' => ['group' => 'external', 'title' => 'Anfahrt', 'provider' => 'Karten', 'url' => 'https://karten.example/x', 'hint' => '', 'height' => 300],
|
||
];
|
||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||
// No question asked means nothing is withheld: otherwise turning the banner off would
|
||
// silently empty every page that embeds something.
|
||
$html = $this->request('/')->getContent();
|
||
self::assertStringContainsString('<iframe', $html);
|
||
self::assertStringNotContainsString('consent-bar', $html);
|
||
}
|
||
|
||
/** @return iterable<string, array{array<string, mixed>}> */
|
||
public static function refusedEmbeds(): iterable
|
||
{
|
||
yield 'http' => [['group' => 'external', 'title' => 'X', 'provider' => '', 'url' => 'http://karten.example/x', 'hint' => '', 'height' => 300]];
|
||
yield 'protocol relative' => [['group' => 'external', 'title' => 'X', 'provider' => '', 'url' => '//karten.example/x', 'hint' => '', 'height' => 300]];
|
||
yield 'javascript' => [['group' => 'external', 'title' => 'X', 'provider' => '', 'url' => 'javascript:alert(1)', 'hint' => '', 'height' => 300]];
|
||
yield 'data url' => [['group' => 'external', 'title' => 'X', 'provider' => '', 'url' => 'data:text/html,<script>alert(1)</script>', 'hint' => '', 'height' => 300]];
|
||
yield 'with a port' => [['group' => 'external', 'title' => 'X', 'provider' => '', 'url' => 'https://karten.example:8443/x', 'hint' => '', 'height' => 300]];
|
||
yield 'with credentials' => [['group' => 'external', 'title' => 'X', 'provider' => '', 'url' => 'https://nutzer:geheim@karten.example/x', 'hint' => '', 'height' => 300]];
|
||
yield 'host with a space' => [['group' => 'external', 'title' => 'X', 'provider' => '', 'url' => 'https://karten example/x', 'hint' => '', 'height' => 300]];
|
||
yield 'no title' => [['group' => 'external', 'title' => '', 'provider' => '', 'url' => 'https://karten.example/x', 'hint' => '', 'height' => 300]];
|
||
yield 'unknown group' => [['group' => 'erfunden', 'title' => 'X', 'provider' => '', 'url' => 'https://karten.example/x', 'hint' => '', 'height' => 300]];
|
||
yield 'height too small' => [['group' => 'external', 'title' => 'X', 'provider' => '', 'url' => 'https://karten.example/x', 'hint' => '', 'height' => 10]];
|
||
}
|
||
|
||
#[DataProvider('refusedEmbeds')]
|
||
public function testRefusedEmbedsNeverReachADocument(array $props): void
|
||
{
|
||
$document = DemoContent::starter('Mit Karte');
|
||
$document['schemaVersion'] = 14;
|
||
$document['blocks'][] = ['id' => 'karte', 'type' => 'embed', 'props' => $props];
|
||
try {
|
||
$this->pages->saveDraft('page-1', $document, 0);
|
||
self::fail('Expected the embed to be refused: '.json_encode($props['url']));
|
||
} catch (InvalidDocument) {
|
||
}
|
||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', ['page-1']));
|
||
}
|
||
|
||
public function testAnEmbedNeedsSchemaFourteen(): void
|
||
{
|
||
$document = DemoContent::starter('Zu alt');
|
||
$document['schemaVersion'] = 13;
|
||
$document['blocks'][] = [
|
||
'id' => 'karte', 'type' => 'embed',
|
||
'props' => ['group' => 'external', 'title' => 'X', 'provider' => '', 'url' => 'https://karten.example/x', 'hint' => '', 'height' => 300],
|
||
];
|
||
$this->expectException(InvalidDocument::class);
|
||
$this->pages->saveDraft('page-1', $document, 0);
|
||
}
|
||
|
||
public function testAConsentBannerNeedsAPrivacyPageThatExists(): void
|
||
{
|
||
foreach ([
|
||
'without a privacy page' => ['consentBanner' => '1', 'consentGroups' => ['external'], 'privacyPath' => ''],
|
||
'pointing nowhere' => ['consentBanner' => '1', 'consentGroups' => ['external'], 'privacyPath' => 'gibt-es-nicht'],
|
||
'without a group' => ['consentBanner' => '1', 'consentGroups' => [], 'privacyPath' => 'datenschutz'],
|
||
] as $case => $input) {
|
||
try {
|
||
$this->site->save([...$input, 'homePath' => 'home']);
|
||
self::fail('Expected '.$case.' to be refused.');
|
||
} catch (InvalidPage) {
|
||
}
|
||
self::assertFalse($this->site->load()['consentBanner'], $case);
|
||
}
|
||
}
|
||
|
||
private function request(string $url, string $method = 'GET', array $body = [], array $cookies = []): Response
|
||
{
|
||
$previous = [];
|
||
foreach (['APP_SECRET' => 'test-only', 'DATABASE_URL' => 'sqlite:///'.str_replace('\\', '/', $this->path), 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media', 'FONT_DIRECTORY' => $this->path.'-fonts', 'ASSET_DIRECTORY' => $this->path.'-assets'] as $key => $value) {
|
||
$previous[$key] = [$_SERVER[$key] ?? null, $_ENV[$key] ?? null];
|
||
$_SERVER[$key] = $_ENV[$key] = $value;
|
||
}
|
||
$kernel = new Kernel('test', false);
|
||
try {
|
||
$request = Request::create($url, $method, $body, $cookies);
|
||
$response = $kernel->handle($request);
|
||
// Counting happens on kernel.terminate, so a test that does not terminate would
|
||
// silently test a different application than the one that runs.
|
||
$kernel->terminate($request, $response);
|
||
|
||
return $response;
|
||
} finally {
|
||
$kernel->shutdown();
|
||
foreach ($previous as $key => [$server, $env]) {
|
||
if (null === $server) {
|
||
unset($_SERVER[$key]);
|
||
} else {
|
||
$_SERVER[$key] = $server;
|
||
}
|
||
if (null === $env) {
|
||
unset($_ENV[$key]);
|
||
} else {
|
||
$_ENV[$key] = $env;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|