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)); $this->menus = new MenuRepository($this->db, new SiteSettings($this->db)); $this->posts = new PostRepository($this->db, new MediaLibrary($this->db, $this->path.'-media'), new SiteSettings($this->db)); $this->site = new SiteSettings($this->db); $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 testDocumentRoundTripAndDraftPrivacy(): void { $document = DemoContent::pages()['home']; $id = $this->pages->saveDraft('page-1', $document, 0); self::assertNull($this->pages->findPublished('home')); self::assertSame($document, json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$id]), true)); $this->pages->publish('page-1', $id, 1); self::assertSame($document, $this->pages->findPublished('home')['document']); $document['title'] = 'Private change'; $this->pages->saveDraft('page-1', $document, 2); self::assertNotSame('Private change', $this->pages->findPublished('home')['document']['title']); } public function testStaleSaveRollsBackWithoutOrphanRevision(): void { $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0); try { $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0); self::fail('Stale write was accepted.'); } catch (StaleWrite) { self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision')); self::assertSame(1, (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['page-1'])); } } public function testForeignRevisionCannotBePublished(): void { $id = $this->pages->saveDraft('page-2', DemoContent::pages()['home'], 0); $this->expectException(\InvalidArgumentException::class); $this->pages->publish('page-1', $id, 0); } public function testPublicationRejectsStaleVersion(): void { $id = $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0); $this->expectException(StaleWrite::class); $this->pages->publish('page-1', $id, 0); } public function testPublishedRevisionCanBeRestoredAndImmediatelyChangesOutput(): void { $original = DemoContent::pages()['home']; $first = $this->pages->saveDraft('page-1', $original, 0); $this->pages->publish('page-1', $first, 1); $changed = $original; $changed['title'] = 'Second revision'; $second = $this->pages->saveDraft('page-1', $changed, 2); $this->pages->publish('page-1', $second, 3); self::assertSame('Second revision', $this->pages->findPublished('home')['document']['title']); $this->pages->publish('page-1', $first, 4); self::assertSame($original, $this->pages->findPublished('home')['document']); self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision')); } public static function invalidDocuments(): iterable { $base = DemoContent::pages()['home']; $bad = $base; $bad['schemaVersion'] = 999; yield 'future schema' => [$bad]; $bad = $base; $bad['blocks'][] = $base['blocks'][0]; yield 'duplicate IDs' => [$bad]; foreach (['javascript:alert(1)', 'data:text/html,hi', '//evil.test', '/\\evil.test', "https://example.test/\nfoo"] as $url) { $bad = $base; $bad['blocks'][0]['children'][3]['props']['href'] = $url; yield $url => [$bad]; } $bad = $base; $bad['blocks'][0]['layout']['tone'] = '" onclick="alert(1)'; yield 'style injection' => [$bad]; foreach ([['desktop' => ['paddingY' => 'url(evil)']], ['watch' => []], ['mobile' => ['columns' => 'two']], ['desktop' => ['unknown' => 'small']], ['mobile' => ['paddingY' => []]]] as $design) { $bad = $base; $bad['schemaVersion'] = 2; $bad['blocks'][0]['layout']['design'] = $design; yield 'invalid responsive design '.json_encode($design) => [$bad]; } $bad = $base; $bad['blocks'][0]['layout']['design'] = ['desktop' => ['paddingY' => 'large']]; yield 'v1 cannot silently use v2 settings' => [$bad]; foreach ([2, 3] as $version) { $bad = $base; $bad['schemaVersion'] = $version; $bad['blocks'][0]['layout']['effects'] = ['shadow' => 2 === $version ? 'soft' : 'evil']; yield 'invalid effect version '.$version => [$bad]; } $bad = $base; $bad['schemaVersion'] = 3; $bad['blocks'][] = ['id' => 'gallery', 'type' => 'gallery', 'children' => [$base['blocks'][0]]]; yield 'gallery forbids containers' => [$bad]; $bad = $base; $bad['blocks'][] = ['id' => 'gallery', 'type' => 'gallery', 'children' => []]; yield 'gallery requires v3' => [$bad]; foreach (['javascript:alert(1)', '/image,400w'] as $source) { $bad = $base; $bad['schemaVersion'] = 3; $bad['blocks'][] = ['id' => 'unsafe-image', 'type' => 'image', 'props' => ['src' => '/safe.png', 'alt' => 'Test', 'decorative' => false, 'width' => 400, 'height' => 300, 'sources' => [['src' => $source, 'width' => 400]]]]; yield 'unsafe srcset '.$source => [$bad]; } $bad = $base; $bad['blocks'][0]['children'][1]['props']['level'] = 9; yield 'invalid heading' => [$bad]; $bad = $base; $bad['blocks'][0]['children'][0]['type'] = 'unknown-plugin'; yield 'unnamespaced unknown type stays rejected' => [$bad]; foreach (['Acme.Slider', 'acme.', '.slider', 'acme..slider', 'acme.slider.extra', 'acme/slider'] as $type) { $bad = $base; $bad['schemaVersion'] = 4; $bad['blocks'][0]['children'][0]['type'] = $type; yield 'malformed plugin type '.$type => [$bad]; } $bad = $base; $bad['blocks'][0]['children'][0]['type'] = 'acme.slider'; yield 'plugin block needs schema v4' => [$bad]; $bad = $base; $bad['blocks'][0]['children'][] = ['id' => 'image', 'type' => 'image', 'props' => ['src' => '/image.png', 'alt' => '', 'decorative' => false, 'width' => 100, 'height' => 100]]; yield 'missing meaningful alt' => [$bad]; } #[DataProvider('invalidDocuments')] public function testRejectsInvalidDocumentsBeforeWriting(array $document): void { $this->expectException(InvalidDocument::class); $this->pages->saveDraft('page-1', $document, 0); } public function testInitialHtmlEscapesContentAndHasNoEditorScripts(): void { $document = DemoContent::pages()['home']; $document['blocks'][0]['children'][0]['props']['text'] = ''; $id = $this->pages->saveDraft('page-1', $document, 0); $this->pages->publish('page-1', $id, 1); $response = $this->request('/'); self::assertSame(200, $response->getStatusCode()); self::assertStringContainsString('<script>', $response->getContent()); self::assertStringNotContainsString('getContent()); self::assertStringContainsString('getContent()); self::assertStringContainsString('rel="canonical" href="https://cms.test/"', $response->getContent()); self::assertStringContainsString('no-store', $response->headers->get('Cache-Control')); } public function testResponsiveRevisionPreservesOriginalAndRendersSafeClasses(): void { $original = DemoContent::pages()['home']; $old = $this->pages->saveDraft('page-1', $original, 0); $this->pages->publish('page-1', $old, 1); $document = $original; $document['schemaVersion'] = 2; $document['blocks'][0]['layout']['design'] = ['desktop' => ['paddingY' => 'huge'], 'mobile' => ['paddingY' => 'small']]; $document['blocks'][0]['children'][1]['layout']['design'] = ['desktop' => ['fontSize' => 'hero'], 'mobile' => ['fontSize' => 'title']]; $new = $this->pages->saveDraft('page-1', $document, 2); self::assertSame($original, $this->pages->findPublished('home')['document']); self::assertSame($document, $this->pages->findForEditor('page-1')['document']); $this->pages->publish('page-1', $new, 3); $html = $this->request('/')->getContent(); self::assertStringContainsString('d-mobile-paddingY-small', $html); self::assertStringContainsString('d-desktop-fontSize-hero d-mobile-fontSize-title', $html); self::assertStringContainsString('/assets/design.css', $html); self::assertStringNotContainsString('style=', $html); self::assertStringNotContainsString('pages->publish('page-1', $old, 4); self::assertSame($original, $this->pages->findPublished('home')['document']); self::assertStringNotContainsString('/assets/design.css', $this->request('/')->getContent()); } public function testDraftAndUnknownUrlsAre404AndWritesAreNotExposed(): void { $this->pages->saveDraft('page-2', DemoContent::pages()['home'], 0); foreach (['/private', '/missing', '/editor'] as $url) { self::assertSame(404, $this->request($url)->getStatusCode()); } self::assertSame(405, $this->request('/', 'POST')->getStatusCode()); } public function 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', $response->getContent()); self::assertStringContainsString('href="https://cms.test/ueber-uns"', $response->getContent()); } public function testChildPagesBuildNestedPathsAndSiblingSlugsStayIndependent(): void { $parent = $this->pages->createPage('leistungen', null, 'page', DemoContent::starter('Leistungen')); $child = $this->pages->createPage('webdesign', $parent, 'page', DemoContent::starter('Webdesign')); $other = $this->pages->createPage('beratung', null, 'page', DemoContent::starter('Beratung')); // The same slug under a different parent is a different page, not a collision. $twin = $this->pages->createPage('webdesign', $other, 'page', DemoContent::starter('Webdesign kompakt')); foreach ([$parent => 'leistungen', $child => 'leistungen/webdesign', $twin => 'beratung/webdesign'] as $id => $path) { $draft = $this->pages->findForEditor($id); $this->pages->publish($id, $draft['draft_revision_id'], $draft['version']); self::assertSame(200, $this->request('/'.$path)->getStatusCode(), $path); } self::assertSame('Webdesign', $this->pages->findPublished('leistungen/webdesign')['document']['title']); self::assertSame('Webdesign kompakt', $this->pages->findPublished('beratung/webdesign')['document']['title']); } /** * @return iterable */ public static function rejectedPages(): iterable { yield 'uppercase slug' => ['Über Uns', null, 'page']; yield 'path separator in slug' => ['a/b', null, 'page']; yield 'empty slug' => ['', null, 'page']; yield 'reserved slug' => ['admin', null, 'page']; yield 'unknown type' => ['gueltig', null, 'template']; yield 'missing parent' => ['gueltig', 'does-not-exist', 'page']; } #[DataProvider('rejectedPages')] public function testRejectsInvalidAddressesWithoutWriting(string $slug, ?string $parent, string $type): void { $before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'); try { $this->pages->createPage($slug, $parent, $type, DemoContent::starter('Titel')); self::fail('Expected the page to be rejected.'); } catch (InvalidPage) { } self::assertSame($before, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page')); } public function testDuplicateAddressAndExcessiveDepthAreRejected(): void { $this->pages->createPage('kontakt', null, 'page', DemoContent::starter('Kontakt')); $this->expectException(InvalidPage::class); $this->pages->createPage('kontakt', null, 'page', DemoContent::starter('Kontakt erneut')); } public function testHierarchyStopsAtTheDocumentedDepthAndPostsTakeNoChildren(): void { $id = null; for ($level = 1; $level <= PageRepository::MAX_DEPTH; ++$level) { $id = $this->pages->createPage('ebene-'.$level, $id, 'page', DemoContent::starter('Ebene '.$level)); } try { $this->pages->createPage('zu-tief', $id, 'page', DemoContent::starter('Zu tief')); self::fail('Expected the depth limit to apply.'); } catch (InvalidPage) { } $post = $this->pages->createPage('erster-beitrag', null, 'post', DemoContent::starter('Erster Beitrag', 'post')); $this->expectException(InvalidPage::class); $this->pages->createPage('unter-beitrag', $post, 'page', DemoContent::starter('Unter Beitrag')); } public function testDeleteRemovesEveryRevisionButRefusesStaleVersionsAndParents(): void { $parent = $this->pages->createPage('eltern', null, 'page', DemoContent::starter('Eltern')); $child = $this->pages->createPage('kind', $parent, 'page', DemoContent::starter('Kind')); $version = $this->pages->findForEditor($parent)['version']; try { $this->pages->deletePage($parent, $version); self::fail('Expected the parent to be protected.'); } catch (InvalidPage) { } self::assertNotNull($this->pages->findForEditor($parent)); $childVersion = $this->pages->findForEditor($child)['version']; try { $this->pages->deletePage($child, $childVersion - 1); self::fail('Expected a stale delete to be refused.'); } catch (StaleWrite) { } self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', [$child])); $this->pages->deletePage($child, $childVersion); self::assertNull($this->pages->findForEditor($child)); self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', [$child])); // Deleting the child frees the parent and leaves every other page untouched. $this->pages->deletePage($parent, $this->pages->findForEditor($parent)['version']); self::assertSame(['home', 'private'], $this->db->fetchFirstColumn('SELECT slug FROM page ORDER BY slug')); } public function testCmsRoutesAreNotSwallowedByThePublicPathRoute(): void { // The catch-all would otherwise answer these as a missing public page with 404. foreach (['/admin', '/admin/media'] as $url) { self::assertSame(302, $this->request($url)->getStatusCode(), $url); } self::assertStringContainsString('name="_username"', $this->request('/admin/login')->getContent()); } public function testPluginBlocksSurviveSavePublishAndReloadWithoutRenderingTheirProps(): void { $document = DemoContent::starter('Mit Erweiterung'); $document['schemaVersion'] = 4; $document['blocks'][] = [ 'id' => 'plugin-block', 'type' => 'acme.slider', 'props' => ['slides' => [['src' => 'javascript:alert(1)', 'headline' => 'MARKER_FROM_PLUGIN_PROPS']], 'autoplay' => true], 'children' => [['id' => 'plugin-child', 'type' => 'text', 'props' => ['text' => 'MARKER_INSIDE_PLUGIN']]], ]; $revision = $this->pages->saveDraft('page-1', $document, 0); // Stored byte for byte: a page must never lose content because a plugin is absent. self::assertSame($document, json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]), true)); $this->pages->publish('page-1', $revision, 1); self::assertSame($document, $this->pages->findPublished('home')['document']); self::assertSame($document, $this->pages->findForEditor('page-1')['document']); $html = $this->request('/')->getContent(); self::assertSame(200, $this->request('/')->getStatusCode()); // The core knows no schema for these props, so none of them may reach the output. self::assertStringNotContainsString('MARKER_FROM_PLUGIN_PROPS', $html); self::assertStringNotContainsString('MARKER_INSIDE_PLUGIN', $html); self::assertStringNotContainsString('javascript:', $html); self::assertStringNotContainsString('block-unavailable', $html); self::assertStringContainsString('', $stripped->getContent()); self::assertSame('aufruf', $this->pages->findPublished('home')['document']['blocks'][2]['props']['partial']); } public function testSharedAreasRefuseAMainHeadingAndSectionsRefuseToContainThemselves(): void { // A page brings its own main heading; an area appearing on every page must not add one. try { $this->partials->create('section', 'mit-h1', 'Mit H1', [ 'schemaVersion' => 11, 'title' => 'Mit H1', 'description' => 'x', 'blocks' => [['id' => 'h', 'type' => 'heading', 'props' => ['text' => 'Titel', 'level' => 1]]], ]); self::fail('Expected a main heading in a shared area to be refused.'); } catch (InvalidDocument) { } $id = $this->partials->create('section', 'selbst', 'Selbstbezug', [ 'schemaVersion' => 11, 'title' => 'Selbstbezug', 'description' => 'x', 'blocks' => [['id' => 's', 'type' => 'section', 'children' => []]], ]); $cyclic = ['schemaVersion' => 11, 'title' => 'Selbstbezug', 'description' => 'x', 'blocks' => [ ['id' => 's', 'type' => 'partialref', 'props' => ['partial' => 'selbst']], ]]; try { $this->partials->save($id, $cyclic, $this->partials->find($id)['version']); self::fail('Expected a section containing itself to be refused.'); } catch (InvalidPage) { } // Nothing was written, so the section still holds its original block. self::assertSame('section', $this->partials->find($id)['document']['blocks'][0]['type']); // Only one header and one footer. try { $this->partials->create('header', 'zweiter', 'Zweiter Kopf', ['schemaVersion' => 11, 'title' => 'x', 'description' => 'x', 'blocks' => []]); self::fail('Expected a second header to be refused.'); } catch (InvalidPage) { } // And they cannot be deleted, only emptied. $this->expectException(InvalidPage::class); $this->partials->delete($this->partials->findByKind('header')['id']); } /** * @return iterable */ public static function richTextCases(): iterable { yield 'keeps allowed formatting' => ['Ganz fett und kursiv.', 'Ganz fett und kursiv.']; yield 'keeps lists' => ['
  • eins
  • zwei
', '
  • eins
  • zwei
']; yield 'drops a script and keeps the text after it' => ['Text danach', 'Text danach']; yield 'drops an event handler' => ['

Mit Ereignis

', '

Mit Ereignis

']; yield 'drops style and class' => ['
Verpackt
', 'Verpackt']; yield 'turns an unsafe link into text' => ['Boese', 'Boese']; yield 'removes an image with a handler' => ['', '']; yield 'turns a newline into a break next to markup' => ["Eins\nZwei", 'Eins
Zwei']; yield 'removes an iframe' => ['ok', 'ok']; yield 'closes unbalanced markup' => ['ungeschlossen', 'ungeschlossen']; yield 'keeps a local link' => ['hier', 'hier']; yield 'marks an external link' => ['dort', 'dort']; yield 'escapes plain text and keeps the line break' => ["Eins <3\nZwei", 'Eins <3
Zwei']; } #[DataProvider('richTextCases')] public function testTheAllowlistKeepsFormattingAndRemovesEverythingElse(string $input, string $expected): void { self::assertSame($expected, (new RichText())->sanitise($input)); } public function testFilteringHappensWhenTheDocumentIsStored(): void { $document = DemoContent::starter('Formatiert'); $document['schemaVersion'] = 12; $document['blocks'][1]['children'][1]['props']['text'] = 'Fett und verpackt'; $revision = $this->pages->saveDraft('page-1', $document, 0); // The filtering happens on the way in, so the database already holds the safe version and // no renderer has to remember to escape anything. $stored = json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]), true); self::assertSame('Fett und verpackt', $stored['blocks'][1]['children'][1]['props']['text']); // A text that consists only of disallowed markup ends up empty, and an empty text block // is refused - the same rule as for a text block somebody clears by hand. $document['blocks'][1]['children'][1]['props']['text'] = ''; try { $this->pages->saveDraft('page-1', $document, 1); self::fail('Expected an emptied text block to be refused.'); } catch (InvalidDocument) { } self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', ['page-1'])); } public function testFormattedTextIsPrintedAsMarkupFromVersionTwelveOnAndEscapedBefore(): void { $markup = 'Ein starker Satz mit Link.'; // v12: the stored markup reaches the page as markup. $twelve = DemoContent::starter('Formatiert'); $twelve['schemaVersion'] = 12; $twelve['blocks'][1]['children'][1]['props']['text'] = $markup; $this->pages->publish('page-1', $this->pages->saveDraft('page-1', $twelve, 0), 1); $html = $this->request('/')->getContent(); self::assertStringContainsString('starker', $html); self::assertStringContainsString('Link', $html); // A div, not a p: the allowlist includes lists and paragraphs, which may not nest in one. self::assertStringContainsString('
', $html); // An older document keeps plain text semantics, so nothing there is ever printed as markup. $old = DemoContent::starter('Einfach'); $old['schemaVersion'] = 3; $old['blocks'][1]['children'][1]['props']['text'] = $markup; $this->pages->publish('page-1', $this->pages->saveDraft('page-1', $old, 2), 3); $plain = $this->request('/')->getContent(); self::assertStringContainsString('<strong>starker</strong>', $plain); self::assertStringNotContainsString('starker', $plain); self::assertStringContainsString('

', $plain); } public function testFormattedTextInASharedAreaIsFilteredTheSameWay(): void { $header = $this->partials->findByKind('header'); $document = $header['document']; $document['schemaVersion'] = 12; $document['blocks'][0]['children'][0]['children'][0]['children'][0]['props']['text'] = 'kant cms'; $this->partials->save($header['id'], $document, $header['version']); $stored = $this->partials->findByKind('header')['document']; self::assertSame('kant cms', $stored['blocks'][0]['children'][0]['children'][0]['children'][0]['props']['text']); $this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1); $html = $this->request('/')->getContent(); self::assertStringContainsString('kant cms', $html); self::assertStringNotContainsString('alert(1)', $html); } public function testOversizedFormattedTextIsRefused(): void { $document = DemoContent::starter('Zu lang'); $document['schemaVersion'] = 12; $document['blocks'][1]['children'][1]['props']['text'] = str_repeat('a', RichText::MAX_LENGTH + 1); $this->expectException(InvalidDocument::class); $this->pages->saveDraft('page-1', $document, 0); } /** * @return iterable */ public static function ownCssCases(): iterable { yield 'declarations' => ['color: red; padding: 12px', '#b1{color:red;padding:12px}']; yield 'nested state' => ['&:hover { color: #000 }', '#b1:hover{color:#000}']; yield 'descendant keeps its space' => ['& .block-text { font-size: 20px }', '#b1 .block-text{font-size:20px}']; yield 'child combinator' => ['& > p { margin: 0 }', '#b1 > p{margin:0}']; yield 'local image' => ['background: url(/bild.png)', '#b1{background:url(/bild.png)}']; } #[DataProvider('ownCssCases')] public function testOwnCssIsScopedToItsElement(string $css, string $expected): void { self::assertSame($expected, (new CustomCode())->compileCss('b1', $css)); } /** * @return iterable */ public static function refusedOwnCss(): iterable { yield 'escaping the block' => ['color:red} body{display:none']; yield 'foreign selector' => ['body { color: red }']; yield 'at rule' => ['@import url(https://evil.example/x.css)']; yield 'media query' => ['@media (max-width: 100px) { color: red }']; yield 'javascript url' => ['background: url(javascript:alert(1))']; yield 'protocol relative url' => ['background: url(//evil.example/x.png)']; yield 'backslash escape' => ['content: "\\65"']; yield 'old expression syntax' => ['color: expression(alert(1))']; yield 'no value' => ['color']; yield 'nonsense property' => ['co lor: red']; } #[DataProvider('refusedOwnCss')] public function testRefusedOwnCssNeverReachesADocument(string $css): void { $document = DemoContent::starter('Mit CSS'); $document['schemaVersion'] = 13; $document['blocks'][0]['css'] = $css; try { $this->pages->saveDraft('page-1', $document, 0); self::fail(sprintf('Expected "%s" to be refused.', $css)); } catch (InvalidDocument) { } self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', ['page-1'])); } public function testOwnCssReachesThePageStylesheetAndOwnScriptItsOwnFile(): void { $document = DemoContent::starter('Mit eigenem Code'); $document['schemaVersion'] = 13; $document['blocks'][0]['css'] = 'letter-spacing: 2px; &:hover { opacity: 0.8 }'; $document['blocks'][0]['script'] = "element.dataset.ready = 'yes';"; $this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1); $response = $this->request('/'); $html = $response->getContent(); self::assertStringContainsString('#start-hero{letter-spacing:2px}', $html); self::assertStringContainsString('#start-hero:hover{opacity:0.8}', $html); // The script is a file, never inline, and the policy opens only for this origin. self::assertStringContainsString('', $html); self::assertStringNotContainsString('element.dataset.ready', $html); self::assertStringContainsString("script-src 'self'", (string) $response->headers->get('Content-Security-Policy')); $script = $this->request('/assets/seite/page-1.js'); self::assertSame(200, $script->getStatusCode()); self::assertStringContainsString('text/javascript', (string) $script->headers->get('Content-Type')); // Each script runs in its own closure with its element already looked up. self::assertStringContainsString('document.getElementById("start-hero")', $script->getContent()); self::assertStringContainsString("element.dataset.ready = 'yes';", $script->getContent()); // A page without own script keeps the strict policy and loads no file. $plain = $this->publishedPage('ohne-code', 'Ohne Code'); self::assertNotNull($this->pages->findForEditor($plain)); $other = $this->request('/ohne-code'); self::assertStringNotContainsString('/assets/seite/', $other->getContent()); self::assertStringContainsString("script-src 'none'", (string) $other->headers->get('Content-Security-Policy')); self::assertSame(404, $this->request('/assets/seite/'.$plain.'.js')->getStatusCode()); } public function testADraftScriptIsNotServedToVisitors(): void { $document = DemoContent::starter('Nur Entwurf'); $document['schemaVersion'] = 13; $document['blocks'][0]['script'] = "element.dataset.draft = 'yes';"; $this->pages->saveDraft('page-1', $document, 0); // Saved but not published, so the script file must not exist yet. self::assertSame(404, $this->request('/assets/seite/page-1.js')->getStatusCode()); } public function testOwnCodeNeedsSchemaThirteen(): void { $document = DemoContent::starter('Zu alt'); $document['schemaVersion'] = 12; $document['blocks'][0]['css'] = 'color: red'; $this->expectException(InvalidDocument::class); $this->pages->saveDraft('page-1', $document, 0); } public function testScriptChangesAreComparedAgainstWhatIsStored(): void { $code = new CustomCode(); $stored = DemoContent::starter('Mit Skript'); $stored['schemaVersion'] = 13; $stored['blocks'][0]['script'] = "element.dataset.a = '1';"; // Same scripts: an editor may keep saving the page. $unchanged = $stored; $unchanged['title'] = 'Anderer Titel'; $code->assertScriptsUnchanged($unchanged, $stored); foreach (['changed' => "element.dataset.a = '2';", 'removed' => null] as $case => $script) { $attempt = $stored; if (null === $script) { unset($attempt['blocks'][0]['script']); } else { $attempt['blocks'][0]['script'] = $script; } try { $code->assertScriptsUnchanged($attempt, $stored); self::fail(sprintf('Expected a %s script to be refused.', $case)); } catch (InvalidDocument) { } } // And adding one to a page that had none is refused as well. $added = DemoContent::starter('Neu'); $added['schemaVersion'] = 13; $before = $added; $added['blocks'][0]['script'] = 'alert(1)'; $this->expectException(InvalidDocument::class); $code->assertScriptsUnchanged($added, $before); } 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; } } } } }