', $html);
// An older document keeps plain text semantics, so nothing there is ever printed as markup.
$old = DemoContent::starter('Einfach');
$old['schemaVersion'] = 3;
$old['blocks'][1]['children'][1]['props']['text'] = $markup;
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $old, 2), 3);
$plain = $this->request('/')->getContent();
self::assertStringContainsString('<strong>starker</strong>', $plain);
self::assertStringNotContainsString('
starker', $plain);
self::assertStringContainsString('
', $plain);
}
public function testFormattedTextInASharedAreaIsFilteredTheSameWay(): void
{
$header = $this->partials->findByKind('header');
$document = $header['document'];
$document['schemaVersion'] = 12;
$document['blocks'][0]['children'][0]['children'][0]['children'][0]['props']['text'] =
'kant cms';
$this->partials->save($header['id'], $document, $header['version']);
$stored = $this->partials->findByKind('header')['document'];
self::assertSame('kant cms', $stored['blocks'][0]['children'][0]['children'][0]['children'][0]['props']['text']);
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('kant cms', $html);
self::assertStringNotContainsString('alert(1)', $html);
}
public function testOversizedFormattedTextIsRefused(): void
{
$document = DemoContent::starter('Zu lang');
$document['schemaVersion'] = 12;
$document['blocks'][1]['children'][1]['props']['text'] = str_repeat('a', RichText::MAX_LENGTH + 1);
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
}
/**
* @return iterable
*/
public static function ownCssCases(): iterable
{
yield 'declarations' => ['color: red; padding: 12px', '#b1{color:red;padding:12px}'];
yield 'nested state' => ['&:hover { color: #000 }', '#b1:hover{color:#000}'];
yield 'descendant keeps its space' => ['& .block-text { font-size: 20px }', '#b1 .block-text{font-size:20px}'];
yield 'child combinator' => ['& > p { margin: 0 }', '#b1 > p{margin:0}'];
yield 'local image' => ['background: url(/bild.png)', '#b1{background:url(/bild.png)}'];
}
#[DataProvider('ownCssCases')]
public function testOwnCssIsScopedToItsElement(string $css, string $expected): void
{
self::assertSame($expected, (new CustomCode())->compileCss('b1', $css));
}
/**
* @return iterable
*/
public static function refusedOwnCss(): iterable
{
yield 'escaping the block' => ['color:red} body{display:none'];
yield 'foreign selector' => ['body { color: red }'];
yield 'at rule' => ['@import url(https://evil.example/x.css)'];
yield 'media query' => ['@media (max-width: 100px) { color: red }'];
yield 'javascript url' => ['background: url(javascript:alert(1))'];
yield 'protocol relative url' => ['background: url(//evil.example/x.png)'];
yield 'backslash escape' => ['content: "\\65"'];
yield 'old expression syntax' => ['color: expression(alert(1))'];
yield 'no value' => ['color'];
yield 'nonsense property' => ['co lor: red'];
}
#[DataProvider('refusedOwnCss')]
public function testRefusedOwnCssNeverReachesADocument(string $css): void
{
$document = DemoContent::starter('Mit CSS');
$document['schemaVersion'] = 13;
$document['blocks'][0]['css'] = $css;
try {
$this->pages->saveDraft('page-1', $document, 0);
self::fail(sprintf('Expected "%s" to be refused.', $css));
} catch (InvalidDocument) {
}
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', ['page-1']));
}
public function testOwnCssReachesThePageStylesheetAndOwnScriptItsOwnFile(): void
{
$document = DemoContent::starter('Mit eigenem Code');
$document['schemaVersion'] = 13;
$document['blocks'][0]['css'] = 'letter-spacing: 2px; &:hover { opacity: 0.8 }';
$document['blocks'][0]['script'] = "element.dataset.ready = 'yes';";
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$response = $this->request('/');
$html = $response->getContent();
self::assertStringContainsString('#start-hero{letter-spacing:2px}', $html);
self::assertStringContainsString('#start-hero:hover{opacity:0.8}', $html);
// The script is a file, never inline, and the policy opens only for this origin.
self::assertStringContainsString('', $html);
self::assertStringNotContainsString('element.dataset.ready', $html);
self::assertStringContainsString("script-src 'self'", (string) $response->headers->get('Content-Security-Policy'));
$script = $this->request('/assets/seite/page-1.js');
self::assertSame(200, $script->getStatusCode());
self::assertStringContainsString('text/javascript', (string) $script->headers->get('Content-Type'));
// Each script runs in its own closure with its element already looked up.
self::assertStringContainsString('document.getElementById("start-hero")', $script->getContent());
self::assertStringContainsString("element.dataset.ready = 'yes';", $script->getContent());
// A page without own script keeps the strict policy and loads no file.
$plain = $this->publishedPage('ohne-code', 'Ohne Code');
self::assertNotNull($this->pages->findForEditor($plain));
$other = $this->request('/ohne-code');
self::assertStringNotContainsString('/assets/seite/', $other->getContent());
self::assertStringContainsString("script-src 'none'", (string) $other->headers->get('Content-Security-Policy'));
self::assertSame(404, $this->request('/assets/seite/'.$plain.'.js')->getStatusCode());
}
public function testADraftScriptIsNotServedToVisitors(): void
{
$document = DemoContent::starter('Nur Entwurf');
$document['schemaVersion'] = 13;
$document['blocks'][0]['script'] = "element.dataset.draft = 'yes';";
$this->pages->saveDraft('page-1', $document, 0);
// Saved but not published, so the script file must not exist yet.
self::assertSame(404, $this->request('/assets/seite/page-1.js')->getStatusCode());
}
public function testOwnCodeNeedsSchemaThirteen(): void
{
$document = DemoContent::starter('Zu alt');
$document['schemaVersion'] = 12;
$document['blocks'][0]['css'] = 'color: red';
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
}
public function testScriptChangesAreComparedAgainstWhatIsStored(): void
{
$code = new CustomCode();
$stored = DemoContent::starter('Mit Skript');
$stored['schemaVersion'] = 13;
$stored['blocks'][0]['script'] = "element.dataset.a = '1';";
// Same scripts: an editor may keep saving the page.
$unchanged = $stored;
$unchanged['title'] = 'Anderer Titel';
$code->assertScriptsUnchanged($unchanged, $stored);
foreach (['changed' => "element.dataset.a = '2';", 'removed' => null] as $case => $script) {
$attempt = $stored;
if (null === $script) {
unset($attempt['blocks'][0]['script']);
} else {
$attempt['blocks'][0]['script'] = $script;
}
try {
$code->assertScriptsUnchanged($attempt, $stored);
self::fail(sprintf('Expected a %s script to be refused.', $case));
} catch (InvalidDocument) {
}
}
// And adding one to a page that had none is refused as well.
$added = DemoContent::starter('Neu');
$added['schemaVersion'] = 13;
$before = $added;
$added['blocks'][0]['script'] = 'alert(1)';
$this->expectException(InvalidDocument::class);
$code->assertScriptsUnchanged($added, $before);
}
/** Turns on the consent question and publishes a privacy page it can point to. */
private function askConsent(array $groups = ['external']): void
{
$this->publishedPage('datenschutz', 'Datenschutz');
$this->site->save([
'homePath' => 'home',
'consentBanner' => '1',
'consentGroups' => $groups,
'privacyPath' => 'datenschutz',
]);
}
public function testAPageCanUseAnotherHeaderOrNoneAtAll(): void
{
// The seeded areas plus a second header for landing pages.
$this->partials->create('header', 'landing', 'Landing-Kopf', [
'schemaVersion' => 11, 'title' => 'Landing', 'description' => 'x',
'blocks' => [['id' => 'landing-text', 'type' => 'text', 'props' => ['text' => 'Nur ein Logo']]],
]);
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
// By default the standard header is used.
$html = $this->request('/')->getContent();
self::assertStringContainsString('area-header-name', $html);
self::assertStringNotContainsString('landing-text', $html);
// The page can ask for the other one.
$version = (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['page-1']);
$this->pages->saveAreas('page-1', 'landing', null, $version);
$html = $this->request('/')->getContent();
self::assertStringContainsString('landing-text', $html);
self::assertStringNotContainsString('area-header-name', $html);
// Or for none at all - a landing page without navigation.
$this->pages->saveAreas('page-1', 'none', 'none', $version + 1);
$html = $this->request('/')->getContent();
self::assertStringNotContainsString('site-header', $html);
self::assertStringNotContainsString('site-footer', $html);
self::assertStringContainsString('Deine Inhalte', $html, 'The page itself is still there.');
// An address that no longer exists falls back to the standard area instead of silently
// losing the header.
$this->pages->saveAreas('page-1', 'weg', null, $version + 2);
self::assertStringContainsString('area-header-name', $this->request('/')->getContent());
// A stale version is refused, and an invalid address too.
$this->expectException(StaleWrite::class);
$this->pages->saveAreas('page-1', null, null, $version);
}
public function testAnInvalidAreaAddressIsRefused(): void
{
$this->expectException(InvalidPage::class);
$this->pages->saveAreas('page-1', 'Kein Slug!', null, 0);
}
/** @return iterable */
public static function menuLayouts(): iterable
{
yield 'horizontal' => ['horizontal', true];
yield 'vertical' => ['vertical', true];
yield 'burger without a script' => ['burger', true];
yield 'invented' => ['mega', false];
}
#[DataProvider('menuLayouts')]
public function testAMenuCanBeLaidOutInThreeWays(string $layout, bool $accepted): void
{
$document = DemoContent::starter('Mit Menü');
$document['schemaVersion'] = 16;
$document['blocks'][0]['children'][] = ['id' => 'nav', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => $layout]];
if (!$accepted) {
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
return;
}
$this->menus->save('header', 'Hauptnavigation', [['label' => 'Start', 'type' => 'page', 'level' => 1, 'pageId' => 'page-1', 'position' => 10]], 0);
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('class="block-menu menu-'.$layout, $html);
// The burger opens with a details element, so it works on a page that runs no script.
self::assertSame('burger' === $layout, str_contains($html, 'menu-burger'));
self::assertStringContainsString("script-src 'none'", (string) $this->request('/')->headers->get('Content-Security-Policy'));
}
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 testTheGlobalContentWidthCanMakeThePageWiderNotOnlyNarrower(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
// The stylesheet takes the number from the theme instead of a hard 1180px, which capped
// every larger value and made the setting look broken.
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
self::assertStringContainsString('.shell{width:min(var(--k-container,1180px),calc(100% - 64px))', $css);
$theme = $this->themes->load();
$this->themes->save($theme['colors'], $theme['fonts'], [...$theme['base'], 'containerWidth' => '1440px']);
$html = $this->request('/')->getContent();
self::assertStringContainsString('--k-container:1440px', $html);
}
/** @return iterable */
public static function sectionWidths(): iterable
{
yield 'normal' => ['normal', true];
yield 'narrow' => ['narrow', true];
yield 'wide' => ['wide', true];
yield 'edge to edge' => ['full', true];
yield 'invented' => ['riesig', false];
}
#[DataProvider('sectionWidths')]
public function testASectionCanRunEdgeToEdge(string $width, bool $accepted): void
{
$document = DemoContent::starter('Breite');
$document['blocks'][0]['layout'] = ['tone' => 'paper', 'width' => $width];
if (!$accepted) {
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
return;
}
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
self::assertStringContainsString('width-'.$width, $this->request('/')->getContent());
}
/** @return iterable */
public static function heights(): iterable
{
// 100vh is wrong on a phone - it means the viewport without the address bar. The unit
// people actually want is dvh, so it has to be allowed.
yield 'dynamic viewport height' => ['100dvh', true];
yield 'small viewport height' => ['100svh', true];
yield 'large viewport height' => ['100lvh', true];
yield 'classic viewport height' => ['100vh', true];
yield 'pixels' => ['640px', true];
yield 'invented unit' => ['100qh', false];
yield 'no unit' => ['100', false];
}
#[DataProvider('heights')]
public function testAFullHeightSectionIsPossibleWithTheUnitThatWorksOnPhones(string $height, bool $accepted): void
{
$document = DemoContent::starter('Hoch');
$document['schemaVersion'] = 10;
$document['blocks'][0]['style'] = ['desktop' => ['minHeight' => $height, 'display' => 'flex', 'alignItems' => 'center']];
if (!$accepted) {
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
return;
}
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('min-height:'.$height, $html);
// Full height alone would put the content at the top; the flex properties centre it.
self::assertStringContainsString('display:flex;align-items:center', $html);
}
public function testAFullBleedImageFillsTheSection(): void
{
$document = DemoContent::starter('Bildfläche');
$document['schemaVersion'] = 15;
$document['blocks'][0]['layout'] = ['tone' => 'paper', 'width' => 'full'];
$document['blocks'][0]['style'] = ['desktop' => ['padding' => ['top' => '0', 'bottom' => '0']]];
$document['blocks'][0]['children'][] = [
'id' => 'flaeche', 'type' => 'image',
'props' => ['src' => '/assets/image-placeholder.svg', 'alt' => 'Weite Landschaft', 'decorative' => false, 'width' => 2400, 'height' => 1200],
'style' => ['desktop' => ['width' => '100%', 'objectFit' => 'cover', 'imageHeight' => '100dvh']],
];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('width-full', $html);
self::assertStringContainsString('#flaeche{width:100%}', $html);
// The height and the crop belong on the image itself, not on its figure.
self::assertStringContainsString('#flaeche img{object-fit:cover;height:100dvh}', $html);
}
/** @return iterable, list}> */
public static function newBlocks(): iterable
{
yield 'a decorative icon is hidden from screen readers' => [
'icon', ['icon' => 'star', 'size' => 48, 'decorative' => true, 'label' => '', 'href' => ''],
['class="block-icon icon-size-48', 'aria-hidden="true"'],
];
yield 'a meaningful icon carries its label as text' => [
'icon', ['icon' => 'phone', 'size' => 32, 'decorative' => false, 'label' => 'Anrufen', 'href' => '/kontakt'],
['Anrufen'],
];
yield 'a progress bar states its value in the markup' => [
'progress', ['label' => 'Fortschritt', 'value' => 70, 'showValue' => true],
['role="meter"', 'aria-valuenow="70"', 'progress-fill-70', '70 %'],
];
yield 'a slider is a scroll container with a class per device' => [
'slider', ['desktopVisible' => 3, 'tabletVisible' => 2, 'mobileVisible' => 1, 'snap' => true],
['class="block-slider slide-desktop-3 slide-tablet-2 slide-mobile-1 slider-snap"', 'tabindex="0"', 'role="group"'],
];
}
#[DataProvider('newBlocks')]
public function testTheNewBlocksRenderWhatTheyPromise(string $type, array $props, array $expected): void
{
$document = DemoContent::starter('Mit '.$type);
$document['schemaVersion'] = 15;
$document['blocks'][] = ['id' => 'i1', 'type' => $type, 'props' => $props];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
foreach ($expected as $needle) {
self::assertStringContainsString($needle, $html, $type);
}
// No script is needed for any of them: the public page still forbids scripting.
self::assertStringContainsString("script-src 'none'", (string) $this->request('/')->headers->get('Content-Security-Policy'));
}
/** @return iterable}> */
public static function refusedNewBlocks(): iterable
{
// A size without a class would leave the icon without dimensions, so only the listed ones.
yield 'icon size off the list' => ['icon', ['icon' => 'star', 'size' => 37, 'decorative' => true, 'label' => '']];
yield 'unknown icon' => ['icon', ['icon' => 'erfunden', 'size' => 48, 'decorative' => true, 'label' => '']];
yield 'meaningful icon without a label' => ['icon', ['icon' => 'star', 'size' => 48, 'decorative' => false, 'label' => '']];
yield 'decorative icon with a label' => ['icon', ['icon' => 'star', 'size' => 48, 'decorative' => true, 'label' => 'Doch etwas']];
yield 'progress beyond a hundred' => ['progress', ['label' => 'X', 'value' => 140, 'showValue' => true]];
yield 'progress with a text value' => ['progress', ['label' => 'X', 'value' => '70', 'showValue' => true]];
yield 'slider with seven visible' => ['slider', ['desktopVisible' => 7, 'tabletVisible' => 2, 'mobileVisible' => 1, 'snap' => true]];
yield 'slider missing a device' => ['slider', ['desktopVisible' => 3, 'tabletVisible' => 2, 'snap' => true]];
}
#[DataProvider('refusedNewBlocks')]
public function testRefusedNewBlocksNeverReachADocument(string $type, array $props): void
{
$document = DemoContent::starter('Mit '.$type);
$document['schemaVersion'] = 15;
$document['blocks'][] = ['id' => 'i1', 'type' => $type, 'props' => $props];
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
}
public function testTheNewBlocksNeedSchemaFifteen(): void
{
$document = DemoContent::starter('Zu alt');
$document['schemaVersion'] = 14;
$document['blocks'][] = ['id' => 'i1', 'type' => 'icon', 'props' => ['icon' => 'star', 'size' => 48, 'decorative' => true, 'label' => '']];
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
}
/** @return iterable, string}> */
public static function styleDeclarations(): iterable
{
// A border width per side is spelled border-top-width, not border-width-top. Written the
// wrong way the browser drops the declaration, so per-side borders had no effect at all.
yield 'border width per side' => [['borderWidth' => ['top' => '2px', 'left' => '4px']], '#b1{border-top-width:2px;border-left-width:4px}'];
yield 'padding keeps its own spelling' => [['padding' => ['top' => '12px']], '#b1{padding-top:12px}'];
yield 'corners' => [['borderRadius' => ['topLeft' => '18px']], '#b1{border-top-left-radius:18px}'];
yield 'outline' => [['outlineWidth' => '3px', 'outlineColor' => '#2c6b4f', 'outlineOffset' => '2px'], '#b1{outline-width:3px;outline-color:#2c6b4f;outline-offset:2px}'];
yield 'position and layer' => [['position' => 'absolute', 'top' => '0', 'right' => '12px', 'zIndex' => '5'], '#b1{position:absolute;top:0;right:12px;z-index:5}'];
yield 'flex item' => [['alignSelf' => 'center', 'flexGrow' => '2', 'flexBasis' => '40%'], '#b1{align-self:center;flex-grow:2;flex-basis:40%}'];
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): void
{
$compiler = new StyleCompiler();
self::assertSame($expected, $compiler->compileDocument([
'blocks' => [['id' => 'b1', 'type' => 'image', '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('/
'.$group.': /', $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 and then linked on every page.
$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::assertStringContainsString('Datenschutz', $this->request('/')->getContent());
}
public function testADeliveredPageIncrementsOneCounterAndTheAdministrationDoesNot(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
$this->request('/');
$this->request('/');
// A page that does not exist counts as a missing address, not as a page view.
$this->request('/gibt-es-nicht');
// Everything that is not a page of the site stays out of the numbers.
$this->request('/admin');
$this->request('/feed.xml');
$this->request('/robots.txt');
$rows = $this->db->fetchAllAssociative('SELECT path, status, count FROM stat_hit ORDER BY status');
self::assertSame([
['path' => '/', 'status' => 200, 'count' => 2],
['path' => '/gibt-es-nicht', 'status' => 404, 'count' => 1],
], array_map(static fn (array $row): array => ['path' => $row['path'], 'status' => (int) $row['status'], 'count' => (int) $row['count']], $rows));
}
public function testWithoutEmbedsThereIsNoConsentQuestion(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
$html = $this->request('/')->getContent();
// Nothing is loaded from anywhere else, so there is nothing to ask about - and a banner
// without a reason is only in the way.
self::assertStringNotContainsString('consent-bar', $html);
self::assertStringNotContainsString('Einwilligung ändern', $html);
self::assertSame(404, $this->request('/einwilligung')->getStatusCode());
}
public function testTheQuestionAppearsUntilItIsAnsweredAndCanBeChangedAgain(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
$this->askConsent();
$response = $this->request('/');
$html = $response->getContent();
self::assertStringContainsString('consent-bar', $html);
// Accepting and refusing are both buttons of the same form, so neither is hidden away.
self::assertStringContainsString('value="all"', $html);
self::assertStringContainsString('value="none"', $html);
self::assertStringContainsString('/datenschutz', $html);
// No script anywhere: the strict policy stays as it is.
self::assertStringContainsString("script-src 'none'", (string) $response->headers->get('Content-Security-Policy'));
$saved = $this->request('/einwilligung', 'POST', ['choice' => 'all', 'back' => '/']);
self::assertSame(302, $saved->getStatusCode());
self::assertSame('/', $saved->headers->get('Location'));
$cookie = $saved->headers->getCookies()[0];
self::assertSame(Consent::COOKIE, $cookie->getName());
self::assertTrue($cookie->isHttpOnly());
self::assertSame('lax', $cookie->getSameSite());
self::assertStringContainsString('external', (string) $cookie->getValue());
// With a decision in hand the banner is gone, but the way back to it is not.
$decided = $this->request('/', 'GET', [], [Consent::COOKIE => (string) $cookie->getValue()]);
self::assertStringNotContainsString('consent-bar', $decided->getContent());
self::assertStringContainsString('/einwilligung', $decided->getContent());
self::assertSame(200, $this->request('/einwilligung', 'GET', [], [Consent::COOKIE => (string) $cookie->getValue()])->getStatusCode());
}
/** @return iterable */
public static function consentCookies(): iterable
{
yield 'granted' => ['1|external|'.time(), true];
yield 'refused' => ['1||'.time(), false];
yield 'other group only' => ['1|statistics|'.time(), false];
yield 'expired' => ['1|external|'.(time() - Consent::MAX_AGE - 60), false];
yield 'older question' => ['0|external|'.time(), false];
yield 'nonsense' => ['ganz-egal', false];
yield 'unknown group' => ['1|erfunden|'.time(), false];
}
#[DataProvider('consentCookies')]
public function testAnEmbedLoadsOnlyWithAValidDecisionForItsGroup(string $cookie, bool $expected): void
{
$document = DemoContent::starter('Mit Karte');
$document['schemaVersion'] = 14;
$document['blocks'][] = [
'id' => 'karte', 'type' => 'embed',
'props' => ['group' => 'external', 'title' => 'Anfahrt', 'provider' => 'OpenStreetMap', 'url' => 'https://karten.example/einbetten?ort=kant', 'hint' => '', 'height' => 400],
];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$this->askConsent();
$response = $this->request('/', 'GET', [], [Consent::COOKIE => str_replace('', '', $cookie)]);
$html = $response->getContent();
$policy = (string) $response->headers->get('Content-Security-Policy');
if ($expected) {
self::assertStringContainsString('