Auf Entscheidung von Umut werden Tabs jetzt mit JavaScript umgesetzt. Die Regel "der Seiteninhalt braucht kein Browser-JavaScript" bleibt dabei eingehalten: der Server liefert eine Ankerliste und alle Panels mit eigenen Überschriften, versteckt nichts, und ohne Skript ist jeder Inhalt lesbar und über die Tastatur erreichbar. public/assets/tabs.js ergänzt tablist/tab/tabpanel, Auswahl per Pfeiltasten, Home und Ende sowie aria-selected und wählt bei einem passenden Sprungziel das zugehörige Panel. Die strenge Richtlinie bleibt Voreinstellung: öffentliche Seiten senden weiterhin script-src 'none'. Nur eine Seite mit Tab-Block sendet script-src 'self' und bindet die eine externe Datei ein; Inline-Skript ist nie erlaubt. Geprüft: 109 PHP-Tests / 395 Assertions unter PHP 8.2.12 und 8.4.22, 14 JavaScript-Modelltests, Lint/Format/Container. Das Skript selbst wurde nicht ausgeführt: Tastaturbedienung und ARIA-Verhalten sind ohne Browserlauf ungeprüft. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
884 lines
48 KiB
PHP
884 lines
48 KiB
PHP
<?php
|
|
|
|
namespace App\Tests;
|
|
|
|
use App\Content\DemoContent;
|
|
use App\Content\DocumentValidator;
|
|
use App\Content\MediaLibrary;
|
|
use App\Content\MenuRepository;
|
|
use App\Content\PageRepository;
|
|
use App\Content\PostRepository;
|
|
use App\Domain\InvalidDocument;
|
|
use App\Domain\InvalidMenu;
|
|
use App\Domain\InvalidPage;
|
|
use App\Domain\StaleWrite;
|
|
use App\Infrastructure\ConnectionFactory;
|
|
use App\Kernel;
|
|
use Doctrine\DBAL\Connection;
|
|
use Doctrine\DBAL\Schema\Schema;
|
|
use DoctrineMigrations\Version20260912000100;
|
|
use DoctrineMigrations\Version20260912000300;
|
|
use DoctrineMigrations\Version20260912000400;
|
|
use DoctrineMigrations\Version20260912000500;
|
|
use DoctrineMigrations\Version20260912000600;
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Psr\Log\NullLogger;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
final class CmsTest extends TestCase
|
|
{
|
|
private Connection $db;
|
|
private PageRepository $pages;
|
|
private MenuRepository $menus;
|
|
private PostRepository $posts;
|
|
private string $path;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->path = tempnam(sys_get_temp_dir(), 'kant-test-');
|
|
$this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->path));
|
|
$schema = new Schema();
|
|
(new Version20260912000100($this->db, new NullLogger()))->up($schema);
|
|
(new Version20260912000300($this->db, new NullLogger()))->up($schema);
|
|
(new Version20260912000400($this->db, new NullLogger()))->up($schema);
|
|
(new Version20260912000500($this->db, new NullLogger()))->up($schema);
|
|
(new Version20260912000600($this->db, new NullLogger()))->up($schema);
|
|
foreach ($schema->toSql($this->db->getDatabasePlatform()) as $sql) {
|
|
$this->db->executeStatement($sql);
|
|
}
|
|
Version20260912000400::applyIndexes($this->db);
|
|
Version20260912000600::applyIndexes($this->db);
|
|
$this->db->insert('website', ['id' => 'default', 'name' => 'Kant CMS', 'default_locale' => 'de', 'theme' => '{}']);
|
|
foreach (['page-1' => 'home', 'page-2' => 'private'] as $id => $slug) {
|
|
$this->db->insert('page', ['id' => $id, 'slug' => $slug, 'path' => $slug, 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]);
|
|
}
|
|
$this->pages = new PageRepository($this->db, new DocumentValidator());
|
|
$this->menus = new MenuRepository($this->db);
|
|
$this->posts = new PostRepository($this->db, new MediaLibrary($this->db, $this->path.'-media'));
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
$this->db->close();
|
|
gc_collect_cycles();
|
|
unlink($this->path);
|
|
}
|
|
|
|
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 testHomeAliasRedirectsToCanonical(): void
|
|
{
|
|
$response = $this->request('/home');
|
|
self::assertSame(301, $response->getStatusCode());
|
|
self::assertSame('/', $response->headers->get('Location'));
|
|
}
|
|
|
|
public function testCreatedPageStaysPrivateUntilPublishedAndKeepsItsAddress(): void
|
|
{
|
|
$id = $this->pages->createPage('ueber-uns', null, 'page', DemoContent::starter('Über uns'));
|
|
self::assertSame(404, $this->request('/ueber-uns')->getStatusCode());
|
|
$page = $this->pages->findForEditor($id);
|
|
self::assertSame('Über uns', $page['document']['title']);
|
|
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
|
|
$response = $this->request('/ueber-uns');
|
|
self::assertSame(200, $response->getStatusCode());
|
|
self::assertStringContainsString('>Über uns</h1>', $response->getContent());
|
|
self::assertStringContainsString('href="https://cms.test/ueber-uns"', $response->getContent());
|
|
}
|
|
|
|
public function testChildPagesBuildNestedPathsAndSiblingSlugsStayIndependent(): void
|
|
{
|
|
$parent = $this->pages->createPage('leistungen', null, 'page', DemoContent::starter('Leistungen'));
|
|
$child = $this->pages->createPage('webdesign', $parent, 'page', DemoContent::starter('Webdesign'));
|
|
$other = $this->pages->createPage('beratung', null, 'page', DemoContent::starter('Beratung'));
|
|
// The same slug under a different parent is a different page, not a collision.
|
|
$twin = $this->pages->createPage('webdesign', $other, 'page', DemoContent::starter('Webdesign kompakt'));
|
|
foreach ([$parent => 'leistungen', $child => 'leistungen/webdesign', $twin => 'beratung/webdesign'] as $id => $path) {
|
|
$draft = $this->pages->findForEditor($id);
|
|
$this->pages->publish($id, $draft['draft_revision_id'], $draft['version']);
|
|
self::assertSame(200, $this->request('/'.$path)->getStatusCode(), $path);
|
|
}
|
|
self::assertSame('Webdesign', $this->pages->findPublished('leistungen/webdesign')['document']['title']);
|
|
self::assertSame('Webdesign kompakt', $this->pages->findPublished('beratung/webdesign')['document']['title']);
|
|
}
|
|
|
|
/**
|
|
* @return iterable<string, array{string, string|null, string}>
|
|
*/
|
|
public static function rejectedPages(): iterable
|
|
{
|
|
yield 'uppercase slug' => ['Über Uns', null, 'page'];
|
|
yield 'path separator in slug' => ['a/b', null, 'page'];
|
|
yield 'empty slug' => ['', null, 'page'];
|
|
yield 'reserved slug' => ['admin', null, 'page'];
|
|
yield 'unknown type' => ['gueltig', null, 'template'];
|
|
yield 'missing parent' => ['gueltig', 'does-not-exist', 'page'];
|
|
}
|
|
|
|
#[DataProvider('rejectedPages')]
|
|
public function testRejectsInvalidAddressesWithoutWriting(string $slug, ?string $parent, string $type): void
|
|
{
|
|
$before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM page');
|
|
try {
|
|
$this->pages->createPage($slug, $parent, $type, DemoContent::starter('Titel'));
|
|
self::fail('Expected the page to be rejected.');
|
|
} catch (InvalidPage) {
|
|
}
|
|
self::assertSame($before, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
|
|
}
|
|
|
|
public function testDuplicateAddressAndExcessiveDepthAreRejected(): void
|
|
{
|
|
$this->pages->createPage('kontakt', null, 'page', DemoContent::starter('Kontakt'));
|
|
$this->expectException(InvalidPage::class);
|
|
$this->pages->createPage('kontakt', null, 'page', DemoContent::starter('Kontakt erneut'));
|
|
}
|
|
|
|
public function testHierarchyStopsAtTheDocumentedDepthAndPostsTakeNoChildren(): void
|
|
{
|
|
$id = null;
|
|
for ($level = 1; $level <= PageRepository::MAX_DEPTH; ++$level) {
|
|
$id = $this->pages->createPage('ebene-'.$level, $id, 'page', DemoContent::starter('Ebene '.$level));
|
|
}
|
|
try {
|
|
$this->pages->createPage('zu-tief', $id, 'page', DemoContent::starter('Zu tief'));
|
|
self::fail('Expected the depth limit to apply.');
|
|
} catch (InvalidPage) {
|
|
}
|
|
$post = $this->pages->createPage('erster-beitrag', null, 'post', DemoContent::starter('Erster Beitrag', 'post'));
|
|
$this->expectException(InvalidPage::class);
|
|
$this->pages->createPage('unter-beitrag', $post, 'page', DemoContent::starter('Unter Beitrag'));
|
|
}
|
|
|
|
public function testDeleteRemovesEveryRevisionButRefusesStaleVersionsAndParents(): void
|
|
{
|
|
$parent = $this->pages->createPage('eltern', null, 'page', DemoContent::starter('Eltern'));
|
|
$child = $this->pages->createPage('kind', $parent, 'page', DemoContent::starter('Kind'));
|
|
$version = $this->pages->findForEditor($parent)['version'];
|
|
try {
|
|
$this->pages->deletePage($parent, $version);
|
|
self::fail('Expected the parent to be protected.');
|
|
} catch (InvalidPage) {
|
|
}
|
|
self::assertNotNull($this->pages->findForEditor($parent));
|
|
|
|
$childVersion = $this->pages->findForEditor($child)['version'];
|
|
try {
|
|
$this->pages->deletePage($child, $childVersion - 1);
|
|
self::fail('Expected a stale delete to be refused.');
|
|
} catch (StaleWrite) {
|
|
}
|
|
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', [$child]));
|
|
|
|
$this->pages->deletePage($child, $childVersion);
|
|
self::assertNull($this->pages->findForEditor($child));
|
|
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', [$child]));
|
|
// Deleting the child frees the parent and leaves every other page untouched.
|
|
$this->pages->deletePage($parent, $this->pages->findForEditor($parent)['version']);
|
|
self::assertSame(['home', 'private'], $this->db->fetchFirstColumn('SELECT slug FROM page ORDER BY slug'));
|
|
}
|
|
|
|
public function testCmsRoutesAreNotSwallowedByThePublicPathRoute(): void
|
|
{
|
|
// The catch-all would otherwise answer these as a missing public page with 404.
|
|
foreach (['/admin', '/admin/media'] as $url) {
|
|
self::assertSame(302, $this->request($url)->getStatusCode(), $url);
|
|
}
|
|
self::assertStringContainsString('name="_username"', $this->request('/admin/login')->getContent());
|
|
}
|
|
|
|
public function testPluginBlocksSurviveSavePublishAndReloadWithoutRenderingTheirProps(): void
|
|
{
|
|
$document = DemoContent::starter('Mit Erweiterung');
|
|
$document['schemaVersion'] = 4;
|
|
$document['blocks'][] = [
|
|
'id' => 'plugin-block', 'type' => 'acme.slider',
|
|
'props' => ['slides' => [['src' => 'javascript:alert(1)', 'headline' => 'MARKER_FROM_PLUGIN_PROPS']], 'autoplay' => true],
|
|
'children' => [['id' => 'plugin-child', 'type' => 'text', 'props' => ['text' => 'MARKER_INSIDE_PLUGIN']]],
|
|
];
|
|
$revision = $this->pages->saveDraft('page-1', $document, 0);
|
|
// Stored byte for byte: a page must never lose content because a plugin is absent.
|
|
self::assertSame($document, json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]), true));
|
|
$this->pages->publish('page-1', $revision, 1);
|
|
self::assertSame($document, $this->pages->findPublished('home')['document']);
|
|
self::assertSame($document, $this->pages->findForEditor('page-1')['document']);
|
|
|
|
$html = $this->request('/')->getContent();
|
|
self::assertSame(200, $this->request('/')->getStatusCode());
|
|
// The core knows no schema for these props, so none of them may reach the output.
|
|
self::assertStringNotContainsString('MARKER_FROM_PLUGIN_PROPS', $html);
|
|
self::assertStringNotContainsString('MARKER_INSIDE_PLUGIN', $html);
|
|
self::assertStringNotContainsString('javascript:', $html);
|
|
self::assertStringNotContainsString('block-unavailable', $html);
|
|
self::assertStringContainsString('<!-- Block plugin-block (acme.slider)', $html);
|
|
// The rest of the page still renders.
|
|
self::assertStringContainsString('>Mit Erweiterung</h1>', $html);
|
|
}
|
|
|
|
public function testMenuRendersPageAnchorAndExternalTargetsWithCurrentMarker(): void
|
|
{
|
|
$about = $this->publishedPage('ueber-uns', 'Ueber uns');
|
|
$this->menus->save('header', 'Hauptnavigation', [
|
|
['label' => 'Start', 'type' => 'page', 'pageId' => 'page-1', 'anchor' => '', 'children' => [
|
|
['label' => 'Kontakt', 'type' => 'page', 'pageId' => 'page-1', 'anchor' => 'kontakt', 'children' => []],
|
|
]],
|
|
['label' => 'Ueber uns', 'type' => 'page', 'pageId' => $about, 'anchor' => '', 'children' => []],
|
|
['label' => 'HausKlar', 'type' => 'url', 'url' => 'https://hausklar.app', 'children' => []],
|
|
['label' => 'Anrufen', 'type' => 'url', 'url' => 'tel:+4972219957150', 'children' => []],
|
|
], 0);
|
|
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
|
|
|
$html = $this->request('/')->getContent();
|
|
self::assertStringContainsString('href="/#kontakt"', $html);
|
|
self::assertStringContainsString('href="/ueber-uns"', $html);
|
|
self::assertStringContainsString('href="https://hausklar.app"', $html);
|
|
self::assertStringContainsString('href="tel:+4972219957150"', $html);
|
|
// The home link is the current page; an anchor inside it is not.
|
|
self::assertSame(1, substr_count($html, 'aria-current="page"'));
|
|
self::assertStringContainsString('<a href="/" aria-current="page">Start</a>', $html);
|
|
|
|
// On another page the marker moves and nothing else changes.
|
|
$other = $this->request('/ueber-uns')->getContent();
|
|
self::assertStringContainsString('<a href="/ueber-uns" aria-current="page">Ueber uns</a>', $other);
|
|
self::assertStringNotContainsString('<a href="/" aria-current="page">', $other);
|
|
}
|
|
|
|
public function testMenuSurvivesDeletedAndUnpublishedPagesWithoutLosingOtherEntries(): void
|
|
{
|
|
$doomed = $this->publishedPage('temporaer', 'Temporaer');
|
|
$draft = $this->pages->createPage('entwurf', null, 'page', DemoContent::starter('Entwurf'));
|
|
$this->menus->save('header', 'Hauptnavigation', [
|
|
['label' => 'Verschwindet', 'type' => 'page', 'pageId' => $doomed, 'anchor' => '', 'children' => [
|
|
['label' => 'Bleibt als Kind', 'type' => 'url', 'url' => 'https://example.com', 'children' => []],
|
|
]],
|
|
['label' => 'Noch Entwurf', 'type' => 'page', 'pageId' => $draft, 'anchor' => '', 'children' => []],
|
|
['label' => 'Extern', 'type' => 'url', 'url' => 'https://hausklar.app', 'children' => []],
|
|
], 0);
|
|
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
|
$this->pages->deletePage($doomed, $this->pages->findForEditor($doomed)['version']);
|
|
|
|
$html = $this->request('/')->getContent();
|
|
self::assertStringNotContainsString('Verschwindet', $html);
|
|
// An unpublished page must not appear in public navigation.
|
|
self::assertStringNotContainsString('Noch Entwurf', $html);
|
|
self::assertStringNotContainsString('/entwurf', $html);
|
|
// The rest of the menu is intact, including the child of the removed branch.
|
|
self::assertStringContainsString('Bleibt als Kind', $html);
|
|
self::assertStringContainsString('href="https://hausklar.app"', $html);
|
|
// The stored items are untouched: publishing the draft brings its entry back.
|
|
self::assertCount(3, $this->menus->all()['header']['items']);
|
|
$page = $this->pages->findForEditor($draft);
|
|
$this->pages->publish($draft, $page['draft_revision_id'], $page['version']);
|
|
self::assertStringContainsString('Noch Entwurf', $this->request('/')->getContent());
|
|
}
|
|
|
|
/**
|
|
* @return iterable<string, array{array}>
|
|
*/
|
|
public static function rejectedMenuItems(): iterable
|
|
{
|
|
$page = static fn (array $extra = []): array => [...['label' => 'Eintrag', 'type' => 'page', 'pageId' => 'page-1', 'anchor' => '', 'children' => []], ...$extra];
|
|
yield 'empty label' => [[$page(['label' => ' '])]];
|
|
yield 'oversized label' => [[$page(['label' => str_repeat('a', 81)])]];
|
|
yield 'unknown type' => [[$page(['type' => 'script'])]];
|
|
yield 'missing page' => [[$page(['pageId' => ''])]];
|
|
yield 'anchor with hash' => [[$page(['anchor' => 'a#b'])]];
|
|
yield 'anchor starting with digit' => [[$page(['anchor' => '1kontakt'])]];
|
|
yield 'javascript url' => [[['label' => 'X', 'type' => 'url', 'url' => 'javascript:alert(1)', 'children' => []]]];
|
|
yield 'plain http url' => [[['label' => 'X', 'type' => 'url', 'url' => 'http://example.com', 'children' => []]]];
|
|
yield 'protocol relative url' => [[['label' => 'X', 'type' => 'url', 'url' => '//evil.example', 'children' => []]]];
|
|
yield 'third level' => [[$page(['children' => [$page(['children' => [$page()]])]])]];
|
|
}
|
|
|
|
#[DataProvider('rejectedMenuItems')]
|
|
public function testRejectsInvalidMenuItemsWithoutWriting(array $items): void
|
|
{
|
|
try {
|
|
$this->menus->save('header', 'Hauptnavigation', $items, 0);
|
|
self::fail('Expected the menu to be rejected.');
|
|
} catch (InvalidMenu) {
|
|
}
|
|
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM menu'));
|
|
}
|
|
|
|
public function testMenuRejectsUnknownSlotOversizeAndStaleWrites(): void
|
|
{
|
|
$item = ['label' => 'Start', 'type' => 'page', 'pageId' => 'page-1', 'anchor' => '', 'children' => []];
|
|
try {
|
|
$this->menus->save('sidebar', 'Seitenleiste', [$item], 0);
|
|
self::fail('Expected an unknown slot to be rejected.');
|
|
} catch (InvalidMenu) {
|
|
}
|
|
try {
|
|
$this->menus->save('header', 'Hauptnavigation', array_fill(0, MenuRepository::MAX_ITEMS + 1, $item), 0);
|
|
self::fail('Expected the item limit to apply.');
|
|
} catch (InvalidMenu) {
|
|
}
|
|
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM menu'));
|
|
|
|
$this->menus->save('header', 'Hauptnavigation', [$item], 0);
|
|
self::assertSame(1, $this->menus->all()['header']['version']);
|
|
$this->expectException(StaleWrite::class);
|
|
$this->menus->save('header', 'Geaendert', [$item], 0);
|
|
}
|
|
|
|
private function publishedPage(string $slug, string $title): string
|
|
{
|
|
$id = $this->pages->createPage($slug, null, 'page', DemoContent::starter($title));
|
|
$page = $this->pages->findForEditor($id);
|
|
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
|
|
|
|
return $id;
|
|
}
|
|
|
|
public function testArchiveOrdersPostsByDatePaginatesAndHidesPagesAndDrafts(): void
|
|
{
|
|
// Three published posts with distinct dates, one draft post and one ordinary page.
|
|
foreach ([['alt', '2024-01-05'], ['mittel', '2025-06-01'], ['neu', '2026-03-09']] as [$slug, $date]) {
|
|
$id = $this->publishedPost($slug, ucfirst($slug), $date);
|
|
self::assertNotNull($this->pages->findForEditor($id));
|
|
}
|
|
$this->pages->createPage('unveroeffentlicht', null, 'post', DemoContent::starter('Unveroeffentlicht', 'post'));
|
|
$this->publishedPage('eine-seite', 'Eine Seite');
|
|
|
|
$first = $this->posts->published(1, null, 2);
|
|
self::assertSame(3, $first['total']);
|
|
self::assertSame(2, $first['pages']);
|
|
self::assertSame(['Neu', 'Mittel'], array_column($first['posts'], 'title'));
|
|
self::assertSame(['Alt'], array_column($this->posts->published(2, null, 2)['posts'], 'title'));
|
|
// A page is not a post, and an unpublished post stays out of the archive.
|
|
foreach ($this->posts->published(1, null, 20)['posts'] as $post) {
|
|
self::assertNotSame('Eine Seite', $post['title']);
|
|
self::assertNotSame('Unveroeffentlicht', $post['title']);
|
|
}
|
|
// Out-of-range page numbers clamp instead of showing an empty archive.
|
|
self::assertSame(2, $this->posts->published(99, null, 2)['page']);
|
|
self::assertSame(1, $this->posts->published(-5, null, 2)['page']);
|
|
|
|
$html = $this->request('/beitraege')->getContent();
|
|
self::assertSame(200, $this->request('/beitraege')->getStatusCode());
|
|
self::assertStringContainsString('<time datetime="2026-03-09">09.03.2026</time>', $html);
|
|
self::assertStringNotContainsString('Unveroeffentlicht', $html);
|
|
self::assertStringContainsString('href="https://cms.test/beitraege"', $html);
|
|
}
|
|
|
|
public function testCategoriesAndTagsGroupPostsAndGetTheirOwnArchives(): void
|
|
{
|
|
$projekt = $this->publishedPost('kanttresor', 'KantTresor', '2026-02-01', ['Projekte'], ['WordPress', 'DSGVO']);
|
|
$this->publishedPost('andere', 'Andere', '2026-01-01', ['Notizen'], []);
|
|
|
|
$terms = $this->posts->termsFor($projekt);
|
|
self::assertSame(['Projekte'], array_column($terms['category'], 'name'));
|
|
self::assertSame(['DSGVO', 'WordPress'], array_column($terms['tag'], 'name'));
|
|
self::assertSame('/kategorie/projekte', $terms['category'][0]['href']);
|
|
self::assertSame('/schlagwort/dsgvo', $terms['tag'][0]['href']);
|
|
|
|
$category = $this->request('/kategorie/projekte');
|
|
self::assertSame(200, $category->getStatusCode());
|
|
self::assertStringContainsString('KantTresor', $category->getContent());
|
|
self::assertStringNotContainsString('Andere', $category->getContent());
|
|
|
|
self::assertStringContainsString('KantTresor', $this->request('/schlagwort/wordpress')->getContent());
|
|
self::assertSame(404, $this->request('/kategorie/gibt-es-nicht')->getStatusCode());
|
|
// A tag slug is not a category slug.
|
|
self::assertSame(404, $this->request('/kategorie/dsgvo')->getStatusCode());
|
|
|
|
// Saving metadata again replaces the assignment instead of adding to it.
|
|
$version = $this->pages->findForEditor($projekt)['version'];
|
|
$this->posts->saveMetadata($projekt, ['publishedAt' => '2026-02-01'], ['Projekte'], [], $version);
|
|
self::assertSame([], $this->posts->termsFor($projekt)['tag']);
|
|
// The term itself survives as a reusable label, so its archive answers but lists nothing.
|
|
$emptied = $this->request('/schlagwort/wordpress');
|
|
self::assertSame(200, $emptied->getStatusCode());
|
|
self::assertStringNotContainsString('KantTresor', $emptied->getContent());
|
|
}
|
|
|
|
public function testPostListBlockIsFilledByTheRendererAndRespectsItsFilter(): void
|
|
{
|
|
$this->publishedPost('projekt-eins', 'Projekt eins', '2026-02-01', ['Projekte'], []);
|
|
$this->publishedPost('notiz', 'Eine Notiz', '2026-02-02', ['Notizen'], []);
|
|
|
|
$document = DemoContent::starter('Startseite');
|
|
$document['schemaVersion'] = 5;
|
|
$document['blocks'][] = ['id' => 'liste', 'type' => 'postlist', 'props' => ['count' => 5, 'category' => 'projekte', 'tag' => ''], 'children' => []];
|
|
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
|
|
|
$html = $this->request('/')->getContent();
|
|
self::assertStringContainsString('Projekt eins', $html);
|
|
self::assertStringNotContainsString('Eine Notiz', $html);
|
|
// The stored block carries no post data; only the filter and the count are content.
|
|
$stored = $this->pages->findPublished('home')['document'];
|
|
self::assertSame(['count' => 5, 'category' => 'projekte', 'tag' => ''], $stored['blocks'][2]['props']);
|
|
}
|
|
|
|
public function testFeedListsPublishedPostsWithAbsoluteAddresses(): void
|
|
{
|
|
$this->publishedPost('erster', 'Erster Beitrag', '2026-02-01', ['Projekte'], []);
|
|
$this->pages->createPage('geheim', null, 'post', DemoContent::starter('Geheim', 'post'));
|
|
|
|
$response = $this->request('/feed.xml');
|
|
self::assertSame(200, $response->getStatusCode());
|
|
self::assertStringContainsString('application/xml', (string) $response->headers->get('Content-Type'));
|
|
$xml = $response->getContent();
|
|
self::assertStringContainsString('<link>https://cms.test/erster</link>', $xml);
|
|
self::assertStringContainsString('<category>Projekte</category>', $xml);
|
|
self::assertStringNotContainsString('Geheim', $xml);
|
|
self::assertNotFalse(simplexml_load_string($xml), 'The feed must be well-formed XML.');
|
|
}
|
|
|
|
/**
|
|
* @return iterable<string, array{array, array, array}>
|
|
*/
|
|
public static function rejectedPostMetadata(): iterable
|
|
{
|
|
yield 'oversized excerpt' => [['excerpt' => str_repeat('a', 501)], [], []];
|
|
yield 'oversized author' => [['author' => str_repeat('a', 121)], [], []];
|
|
yield 'malformed date' => [['publishedAt' => '09.03.2026'], [], []];
|
|
yield 'unknown media' => [['featuredMediaId' => str_repeat('a', 32)], [], []];
|
|
yield 'malformed media id' => [['featuredMediaId' => 'not-a-media-id'], [], []];
|
|
yield 'unusable term name' => [[], ['---'], []];
|
|
}
|
|
|
|
#[DataProvider('rejectedPostMetadata')]
|
|
public function testRejectsInvalidPostMetadataWithoutWriting(array $values, array $categories, array $tags): void
|
|
{
|
|
$id = $this->pages->createPage('beitrag', null, 'post', DemoContent::starter('Beitrag', 'post'));
|
|
$version = $this->pages->findForEditor($id)['version'];
|
|
try {
|
|
$this->posts->saveMetadata($id, $values, $categories, $tags, $version);
|
|
self::fail('Expected the metadata to be rejected.');
|
|
} catch (InvalidPage) {
|
|
}
|
|
self::assertSame($version, $this->pages->findForEditor($id)['version']);
|
|
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page_term'));
|
|
}
|
|
|
|
public function testDeletingAPostReleasesItsTermAssignments(): void
|
|
{
|
|
$id = $this->publishedPost('weg', 'Weg', '2026-01-01', ['Projekte'], ['Tag']);
|
|
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page_term'));
|
|
$this->pages->deletePage($id, $this->pages->findForEditor($id)['version']);
|
|
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page_term'));
|
|
// The terms themselves stay: they are reusable labels, not content of one post.
|
|
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM term'));
|
|
$archive = $this->request('/kategorie/projekte');
|
|
self::assertSame(200, $archive->getStatusCode());
|
|
self::assertStringNotContainsString('Weg', $archive->getContent());
|
|
self::assertSame(0, $this->posts->published(1, $this->posts->findTerm('category', 'projekte')['id'])['total']);
|
|
}
|
|
|
|
public function testPublishingStampsAMissingDateButKeepsAGivenOne(): void
|
|
{
|
|
$given = $this->publishedPost('mit-datum', 'Mit Datum', '2024-07-04');
|
|
self::assertStringStartsWith('2024-07-04', (string) $this->db->fetchOne('SELECT published_at FROM page WHERE id = ?', [$given]));
|
|
|
|
$without = $this->pages->createPage('ohne-datum', null, 'post', DemoContent::starter('Ohne Datum', 'post'));
|
|
$page = $this->pages->findForEditor($without);
|
|
$this->pages->publish($without, $page['draft_revision_id'], $page['version']);
|
|
self::assertNotNull($this->db->fetchOne('SELECT published_at FROM page WHERE id = ?', [$without]));
|
|
|
|
// An ordinary page is never stamped.
|
|
$plain = $this->publishedPage('normale-seite', 'Normale Seite');
|
|
self::assertNull($this->db->fetchOne('SELECT published_at FROM page WHERE id = ?', [$plain]));
|
|
}
|
|
|
|
private function publishedPost(string $slug, string $title, string $date, array $categories = [], array $tags = []): string
|
|
{
|
|
$id = $this->pages->createPage($slug, null, 'post', DemoContent::starter($title, 'post'));
|
|
$this->posts->saveMetadata($id, ['publishedAt' => $date, 'author' => 'Umut'], $categories, $tags, $this->pages->findForEditor($id)['version']);
|
|
$page = $this->pages->findForEditor($id);
|
|
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
|
|
|
|
return $id;
|
|
}
|
|
|
|
/** A page built from the section kinds the pilot start page actually uses. */
|
|
private static function pilotDocument(): array
|
|
{
|
|
return [
|
|
'schemaVersion' => 6,
|
|
'title' => 'Kant-IT',
|
|
'description' => 'Ihr IT-Partner.',
|
|
'blocks' => [
|
|
['id' => 'hero', 'type' => 'section', 'children' => [
|
|
['id' => 'h1', 'type' => 'heading', 'props' => ['text' => 'Digitale Exzellenz', 'level' => 1]],
|
|
['id' => 'stat1', 'type' => 'stat', 'props' => ['value' => '15+', 'label' => 'Jahre Erfahrung']],
|
|
['id' => 'sp', 'type' => 'spacer', 'props' => ['size' => 'large']],
|
|
['id' => 'hr', 'type' => 'divider'],
|
|
]],
|
|
['id' => 'leistungen', 'type' => 'section', 'children' => [
|
|
['id' => 'card1', 'type' => 'iconcard', 'props' => ['icon' => 'app', 'title' => 'App-Entwicklung', 'text' => 'Für iOS und Android.', 'href' => '/leistungen']],
|
|
['id' => 'card2', 'type' => 'iconcard', 'props' => ['icon' => 'shop', 'title' => 'E-Commerce', 'text' => '', 'href' => '']],
|
|
]],
|
|
['id' => 'stimmen', 'type' => 'section', 'children' => [
|
|
['id' => 'q1', 'type' => 'quote', 'props' => ['text' => 'Kant-IT denkt mit.', 'author' => 'Malerservice Drews', 'role' => 'Zufriedener Kunde', 'rating' => 5]],
|
|
['id' => 'logos', 'type' => 'logos', 'children' => [
|
|
['id' => 'logo1', 'type' => 'image', 'props' => ['src' => '/assets/image-placeholder.svg', 'alt' => 'Partner', 'decorative' => false, 'width' => 200, 'height' => 80]],
|
|
]],
|
|
]],
|
|
['id' => 'fragen', 'type' => 'accordion', 'children' => [
|
|
['id' => 'f1', 'type' => 'accordionitem', 'props' => ['title' => 'Wie schnell antwortet ihr?', 'open' => true], 'children' => [
|
|
['id' => 'f1t', 'type' => 'text', 'props' => ['text' => 'In der Regel innerhalb von 24 Stunden.']],
|
|
]],
|
|
]],
|
|
['id' => 'kontakt', 'type' => 'section', 'children' => [
|
|
['id' => 'cta1', 'type' => 'cta', 'props' => ['title' => 'Bereit?', 'text' => 'Kostenlose Erstberatung.', 'buttonText' => 'Jetzt anfragen', 'href' => '#kontakt']],
|
|
['id' => 'adr', 'type' => 'contact', 'props' => ['phone' => '+49 7221 99 57 150', 'email' => 'info@kant-it.de', 'address' => 'Ooser Hauptstr. 51, Baden-Baden']],
|
|
]],
|
|
],
|
|
];
|
|
}
|
|
|
|
public function testPilotSectionsRoundTripAndRenderWithoutJavaScript(): void
|
|
{
|
|
$document = self::pilotDocument();
|
|
$revision = $this->pages->saveDraft('page-1', $document, 0);
|
|
self::assertSame($document, json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]), true));
|
|
$this->pages->publish('page-1', $revision, 1);
|
|
|
|
$html = $this->request('/')->getContent();
|
|
self::assertSame(200, $this->request('/')->getStatusCode());
|
|
// Every new block reaches the output.
|
|
foreach (['block-iconcard', 'block-stat', 'block-quote', 'block-logos', 'block-cta', 'block-contact', 'block-divider', 'block-spacer', 'block-accordionitem'] as $marker) {
|
|
self::assertStringContainsString($marker, $html, $marker);
|
|
}
|
|
// Contact details become real links, with the phone number stripped for the href.
|
|
self::assertStringContainsString('href="tel:+4972219957150"', $html);
|
|
self::assertStringContainsString('href="mailto:info@kant-it.de"', $html);
|
|
// The accordion is a native details element, so it works without scripting.
|
|
self::assertStringContainsString('<details id="f1" class="block-accordionitem" open>', $html);
|
|
self::assertStringContainsString('<summary>Wie schnell antwortet ihr?</summary>', $html);
|
|
// Icons are inline SVG, not an icon font or an external request.
|
|
self::assertStringContainsString('<svg class="block-icon"', $html);
|
|
self::assertStringNotContainsString('<script', $html);
|
|
// The rating is announced for screen readers rather than left as decorative stars.
|
|
self::assertStringContainsString('Bewertung: 5 von 5', $html);
|
|
}
|
|
|
|
/**
|
|
* @return iterable<string, array{array}>
|
|
*/
|
|
public static function rejectedV6Blocks(): iterable
|
|
{
|
|
$wrap = static function (array $block): array {
|
|
$document = DemoContent::starter('Titel');
|
|
$document['schemaVersion'] = 6;
|
|
$document['blocks'][] = $block;
|
|
|
|
return [$document];
|
|
};
|
|
yield 'unknown icon' => $wrap(['id' => 'x', 'type' => 'iconcard', 'props' => ['icon' => 'rocket', 'title' => 'T', 'text' => '']]);
|
|
yield 'iconcard without title' => $wrap(['id' => 'x', 'type' => 'iconcard', 'props' => ['icon' => 'app', 'title' => ' ', 'text' => '']]);
|
|
yield 'iconcard with unsafe link' => $wrap(['id' => 'x', 'type' => 'iconcard', 'props' => ['icon' => 'app', 'title' => 'T', 'text' => '', 'href' => 'javascript:alert(1)']]);
|
|
yield 'rating out of range' => $wrap(['id' => 'x', 'type' => 'quote', 'props' => ['text' => 'T', 'author' => 'A', 'role' => '', 'rating' => 6]]);
|
|
yield 'rating not a number' => $wrap(['id' => 'x', 'type' => 'quote', 'props' => ['text' => 'T', 'author' => 'A', 'role' => '', 'rating' => '5']]);
|
|
yield 'empty contact block' => $wrap(['id' => 'x', 'type' => 'contact', 'props' => ['phone' => '', 'email' => '', 'address' => '']]);
|
|
yield 'invalid phone' => $wrap(['id' => 'x', 'type' => 'contact', 'props' => ['phone' => 'ruf mich an', 'email' => '', 'address' => '']]);
|
|
yield 'invalid email' => $wrap(['id' => 'x', 'type' => 'contact', 'props' => ['phone' => '', 'email' => 'keine-adresse', 'address' => '']]);
|
|
yield 'unknown spacer size' => $wrap(['id' => 'x', 'type' => 'spacer', 'props' => ['size' => 'gigantic']]);
|
|
yield 'cta without target' => $wrap(['id' => 'x', 'type' => 'cta', 'props' => ['title' => 'T', 'text' => '', 'buttonText' => 'Los', 'href' => 'ftp://example.com']]);
|
|
yield 'logos with a text child' => $wrap(['id' => 'x', 'type' => 'logos', 'children' => [['id' => 'y', 'type' => 'text', 'props' => ['text' => 'kein Logo']]]]);
|
|
yield 'accordion with a text child' => $wrap(['id' => 'x', 'type' => 'accordion', 'children' => [['id' => 'y', 'type' => 'text', 'props' => ['text' => 'direkt']]]]);
|
|
yield 'accordion entry outside an accordion' => $wrap(['id' => 'x', 'type' => 'accordionitem', 'props' => ['title' => 'T', 'open' => false], 'children' => []]);
|
|
yield 'divider with children' => $wrap(['id' => 'x', 'type' => 'divider', 'children' => [['id' => 'y', 'type' => 'text', 'props' => ['text' => 'a']]]]);
|
|
}
|
|
|
|
#[DataProvider('rejectedV6Blocks')]
|
|
public function testRejectsInvalidV6BlocksBeforeWriting(array $document): void
|
|
{
|
|
$this->expectException(InvalidDocument::class);
|
|
$this->pages->saveDraft('page-1', $document, 0);
|
|
}
|
|
|
|
public function testV6BlocksNeedSchemaSixAndStayRejectedInOlderDocuments(): void
|
|
{
|
|
$document = DemoContent::starter('Titel');
|
|
$document['schemaVersion'] = 5;
|
|
$document['blocks'][] = ['id' => 'karte', 'type' => 'iconcard', 'props' => ['icon' => 'app', 'title' => 'T', 'text' => '']];
|
|
try {
|
|
$this->pages->saveDraft('page-1', $document, 0);
|
|
self::fail('Expected schema v5 to reject a v6 block.');
|
|
} catch (InvalidDocument) {
|
|
}
|
|
$document['schemaVersion'] = 6;
|
|
self::assertNotEmpty($this->pages->saveDraft('page-1', $document, 0));
|
|
}
|
|
|
|
private static function tabsDocument(): array
|
|
{
|
|
$document = DemoContent::starter('Mit Tabs');
|
|
$document['schemaVersion'] = 7;
|
|
$document['blocks'][] = ['id' => 'tabs', 'type' => 'tabs', 'children' => [
|
|
['id' => 'tab-web', 'type' => 'tabitem', 'props' => ['title' => 'Web Design'], 'children' => [
|
|
['id' => 'tab-web-text', 'type' => 'text', 'props' => ['text' => 'Inhalt des ersten Tabs.']],
|
|
]],
|
|
['id' => 'tab-app', 'type' => 'tabitem', 'props' => ['title' => 'App-Entwicklung'], 'children' => [
|
|
['id' => 'tab-app-text', 'type' => 'text', 'props' => ['text' => 'Inhalt des zweiten Tabs.']],
|
|
]],
|
|
]];
|
|
|
|
return $document;
|
|
}
|
|
|
|
public function testTabsRenderCompletelyWithoutScriptingAndLoadTheEnhancementOnlyWhereNeeded(): void
|
|
{
|
|
$document = self::tabsDocument();
|
|
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
|
|
|
$response = $this->request('/');
|
|
$html = $response->getContent();
|
|
// Without the script the page still carries both panels, their headings and their content.
|
|
self::assertStringContainsString('Inhalt des ersten Tabs.', $html);
|
|
self::assertStringContainsString('Inhalt des zweiten Tabs.', $html);
|
|
self::assertStringContainsString('<h3 class="tabs-heading">Web Design</h3>', $html);
|
|
self::assertStringContainsString('<a href="#tab-app">App-Entwicklung</a>', $html);
|
|
// Neither panel is hidden server side; hiding is the script's job alone.
|
|
self::assertStringContainsString('<section id="tab-web" class="block-tabitem">', $html);
|
|
self::assertStringContainsString('<section id="tab-app" class="block-tabitem">', $html);
|
|
// The enhancement is an external file; inline script never appears.
|
|
self::assertStringContainsString('<script src="/assets/tabs.js" defer></script>', $html);
|
|
// Scripting is permitted for this page only, and only from this origin.
|
|
self::assertStringContainsString("script-src 'self'", (string) $response->headers->get('Content-Security-Policy'));
|
|
|
|
// A page without tabs keeps the strict policy and loads no script at all.
|
|
$plain = $this->publishedPage('ohne-tabs', 'Ohne Tabs');
|
|
self::assertNotNull($this->pages->findForEditor($plain));
|
|
$other = $this->request('/ohne-tabs');
|
|
self::assertStringNotContainsString('tabs.js', $other->getContent());
|
|
self::assertStringContainsString("script-src 'none'", (string) $other->headers->get('Content-Security-Policy'));
|
|
self::assertSame(404, $this->request('/gibt-es-nicht')->getStatusCode());
|
|
self::assertStringContainsString("script-src 'none'", (string) $this->request('/gibt-es-nicht')->headers->get('Content-Security-Policy'));
|
|
}
|
|
|
|
public function testTabsSurviveSavingAndRejectMisplacedEntries(): void
|
|
{
|
|
$document = self::tabsDocument();
|
|
$revision = $this->pages->saveDraft('page-1', $document, 0);
|
|
self::assertSame($document, json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]), true));
|
|
|
|
// A tab outside a tab group, and a tab group holding anything else, are both refused.
|
|
$loose = DemoContent::starter('Titel');
|
|
$loose['schemaVersion'] = 7;
|
|
$loose['blocks'][] = ['id' => 'x', 'type' => 'tabitem', 'props' => ['title' => 'Allein'], 'children' => []];
|
|
try {
|
|
$this->pages->saveDraft('page-1', $loose, 1);
|
|
self::fail('Expected a tab outside a tab group to be rejected.');
|
|
} catch (InvalidDocument) {
|
|
}
|
|
$wrong = DemoContent::starter('Titel');
|
|
$wrong['schemaVersion'] = 7;
|
|
$wrong['blocks'][] = ['id' => 'x', 'type' => 'tabs', 'children' => [['id' => 'y', 'type' => 'text', 'props' => ['text' => 'direkt']]]];
|
|
try {
|
|
$this->pages->saveDraft('page-1', $wrong, 1);
|
|
self::fail('Expected a tab group with a text child to be rejected.');
|
|
} catch (InvalidDocument) {
|
|
}
|
|
// And tabs need schema v7.
|
|
$old = self::tabsDocument();
|
|
$old['schemaVersion'] = 6;
|
|
$this->expectException(InvalidDocument::class);
|
|
$this->pages->saveDraft('page-1', $old, 1);
|
|
}
|
|
|
|
private function request(string $url, string $method = 'GET'): Response
|
|
{
|
|
$previous = [];
|
|
foreach (['APP_SECRET' => 'test-only', 'DATABASE_URL' => 'sqlite:///'.str_replace('\\', '/', $this->path), 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media'] as $key => $value) {
|
|
$previous[$key] = [$_SERVER[$key] ?? null, $_ENV[$key] ?? null];
|
|
$_SERVER[$key] = $_ENV[$key] = $value;
|
|
}
|
|
$kernel = new Kernel('test', false);
|
|
try {
|
|
return $kernel->handle(Request::create($url, $method));
|
|
} finally {
|
|
$kernel->shutdown();
|
|
foreach ($previous as $key => [$server, $env]) {
|
|
if (null === $server) {
|
|
unset($_SERVER[$key]);
|
|
} else {
|
|
$_SERVER[$key] = $server;
|
|
}
|
|
if (null === $env) {
|
|
unset($_ENV[$key]);
|
|
} else {
|
|
$_ENV[$key] = $env;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|