path = tempnam(sys_get_temp_dir(), 'kant-auth-'); $url = 'sqlite:///'.str_replace('\\', '/', $this->path); $this->previous = [$_SERVER, $_ENV]; foreach (['APP_SECRET' => 'isolated-test-secret', 'DATABASE_URL' => $url, 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media'] as $key => $value) { $_SERVER[$key] = $_ENV[$key] = $value; } $this->db = ConnectionFactory::create($url); TestSchema::build($this->db); $this->db->insert('website', ['id' => 'default', 'name' => 'Test', 'default_locale' => 'de', 'theme' => '{}']); $this->db->insert('page', ['id' => 'p1', 'slug' => 'home', 'path' => 'home', 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]); $pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media'))); $revision = $pages->saveDraft('p1', DemoContent::pages()['home'], 0); $pages->publish('p1', $revision, 1); // The navigation is part of the header area, so the fixture needs one. $partials = new PartialRepository($this->db, new DocumentValidator()); foreach (DemoContent::areas() as $kind => $area) { $partials->create($kind, 'default', $area['name'], $area['document']); } foreach (['admin' => 'ROLE_ADMIN', 'publisher' => 'ROLE_PUBLISHER', 'editor' => 'ROLE_EDITOR'] as $username => $role) { $this->db->insert('cms_user', ['id' => $username, 'username' => $username, 'password_hash' => password_hash('Fixture-only-password', PASSWORD_BCRYPT, ['cost' => 4]), 'roles' => json_encode([$role])]); } } protected function tearDown(): void { parent::tearDown(); $this->db->close(); gc_collect_cycles(); unlink($this->path); foreach (glob($this->path.'-media/*') ?: [] as $file) { unlink($file); } if (is_dir($this->path.'-media')) { rmdir($this->path.'-media'); } [$_SERVER, $_ENV] = $this->previous; } /** Ein Client je Test (der Kernel startet nur einmal); ein zweiter Benutzer meldet sich im selben an. */ private function login(string $username = 'admin', ?KernelBrowser $client = null): KernelBrowser { $client ??= self::createClient(); $client->getCookieJar()->clear(); $crawler = $client->request('GET', '/admin/login'); $client->submit($crawler->selectButton('Anmelden')->form(['_username' => $username, '_password' => 'Fixture-only-password'])); self::assertResponseRedirects('/admin'); $client->followRedirect(); self::assertResponseIsSuccessful(); return $client; } public function testTheSessionCookieFollowsARenamedAdministration(): void { // Heisst die Verwaltung nicht mehr /admin, muss das Sitzungscookie auf ihren Pfad lauten - // sonst schickt der Browser es nie mit, und jede Anmeldung landet wieder vor der Tuer. $_SERVER['ADMIN_PATH'] = $_ENV['ADMIN_PATH'] = 'stiller-eingang'; $client = self::createClient(); $crawler = $client->request('GET', '/stiller-eingang/login'); self::assertResponseIsSuccessful(); $client->submit($crawler->selectButton('Anmelden')->form(['_username' => 'admin', '_password' => 'Fixture-only-password'])); self::assertResponseRedirects('/stiller-eingang'); // Im Test heisst das Sitzungscookie anders als im Betrieb; der Pfad ist, was zaehlt. $paths = array_map(static fn ($cookie): string => $cookie->getPath(), $client->getCookieJar()->all()); self::assertContains('/stiller-eingang', $paths, 'Das Sitzungscookie muss fuer den neuen Pfad gelten.'); self::assertNotContains('/admin', $paths); $client->followRedirect(); self::assertResponseIsSuccessful(); self::assertSelectorExists('a[href="/stiller-eingang/logout"], form[action="/stiller-eingang/logout"]'); } public function test404SettingsCreateDraftAndProtectAssignmentFromCsrfAndStaleWrites(): void { $client = $this->login(); $crawler = $client->request('GET', '/admin/settings'); self::assertSelectorExists('#not-found-picker[role="combobox"]'); $form = $crawler->selectButton('404-Vorlage anlegen')->form(); $values = $form->getPhpValues(); $client->request('POST', '/admin/settings/not-found', [...$values, '_csrf_token' => 'invalid']); self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page')); $client->submit($form); self::assertResponseRedirects(); $id = json_decode($this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']), true)['notFoundPageId']; self::assertResponseRedirects('/admin/pages/'.$id); self::assertNull($this->db->fetchOne('SELECT published_revision_id FROM page WHERE id = ?', [$id])); $client->request('POST', '/admin/settings/not-found', ['_csrf_token' => $values['_csrf_token'], 'notFoundPageId' => '', 'expectedId' => '']); self::assertSame($id, json_decode($this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']), true)['notFoundPageId']); $client->followRedirect(); self::assertSelectorTextContains('body', 'inzwischen geändert'); $crawler = $client->request('GET', '/admin/settings'); $client->submit($crawler->selectButton('404-Zuordnung speichern')->form(['notFoundPageId' => ''])); self::assertSame('', json_decode($this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']), true)['notFoundPageId']); } public function test404AssignmentRequiresPublicationRights(): void { // Ein Redakteur sieht die Einstellungen gar nicht erst: alles dort wirkt sofort. $client = $this->login('editor'); $client->request('GET', '/admin/settings'); self::assertResponseStatusCodeSame(403); $client->request('POST', '/admin/settings/not-found', ['action' => 'create']); self::assertResponseStatusCodeSame(403); self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page')); // Ein Veroeffentlicher darf die 404-Seite zuordnen, aber die Unternehmensdaten nicht aendern. $this->login('publisher', $client); $client->request('GET', '/admin/settings'); self::assertResponseIsSuccessful(); self::assertSelectorExists('#not-found-form'); self::assertSelectorNotExists('form[action="/admin/settings/company"]'); } /** * Wer was aendern darf, steht in der Rollenhilfe - und hier, damit es so bleibt. * * Jede Zeile: eine Route, die Rolle, die zu wenig ist, und die Antwort 403. Ohne Anmeldung * fuehrt jede dieser Adressen zur Anmeldung. Die Rollen, die duerfen, pruefen die uebrigen Tests. */ public function testEveryLiveChangeNeedsTheRoleTheRoleHelpNames(): void { $tooLittle = [ // Design, Unternehmensdaten, System, Benutzer, endgueltiges Loeschen: Administrator. ['publisher', 'GET', '/admin/theme'], ['publisher', 'POST', '/admin/theme/save'], ['publisher', 'POST', '/admin/theme/fonts'], ['publisher', 'POST', '/admin/settings/company'], ['publisher', 'GET', '/admin/system'], ['publisher', 'POST', '/admin/system/wartung'], ['publisher', 'POST', '/admin/system/sicherung'], ['publisher', 'GET', '/admin/aufgaben'], ['publisher', 'POST', '/admin/aufgaben/ausfuehren'], ['publisher', 'GET', '/admin/benutzer'], ['publisher', 'POST', '/admin/benutzer/neu'], // Ein Plugin bringt Code auf die Website: Administrator. ['publisher', 'GET', '/admin/plugins'], ['publisher', 'POST', '/admin/plugins/vergleich/an'], ['publisher', 'POST', '/admin/plugins/vergleich/einstellungen'], ['publisher', 'POST', '/admin/pages/p1/delete'], // Alles, was sofort auf der Website steht: Veroeffentlicher. ['editor', 'GET', '/admin/settings'], ['editor', 'POST', '/admin/settings/redirects'], ['editor', 'POST', '/admin/settings/rechtliches'], ['editor', 'POST', '/admin/settings/not-found'], ['editor', 'GET', '/admin/partials'], ['editor', 'POST', '/admin/partials/new'], ['editor', 'GET', '/admin/menus'], ['editor', 'POST', '/admin/menus/header'], ['editor', 'GET', '/admin/forms'], ['editor', 'POST', '/admin/forms/neu'], ['editor', 'POST', '/admin/pages/p1/schedule'], ['editor', 'POST', '/admin/api/pages/p1/publish'], ]; $client = null; foreach (['publisher', 'editor'] as $role) { $client = $this->login($role, $client); foreach (array_filter($tooLittle, static fn (array $row): bool => $row[0] === $role) as [, $method, $path]) { $client->request($method, $path); self::assertSame(403, $client->getResponse()->getStatusCode(), sprintf('%s %s %s', $role, $method, $path)); } } // Ein Redakteur sieht in der Navigation nur, was er oeffnen darf. $crawler = $client->request('GET', '/admin'); self::assertResponseIsSuccessful(); $links = $crawler->filter('.nav-item')->each(static fn ($node): string => (string) $node->attr('href')); foreach (['/admin/partials', '/admin/menus', '/admin/forms', '/admin/theme', '/admin/settings', '/admin/system', '/admin/plugins', '/admin/benutzer'] as $hidden) { self::assertNotContains($hidden, $links, $hidden.' gehoert nicht in die Navigation eines Redakteurs.'); } self::assertContains('/admin/media', $links); // Ohne Anmeldung: keine 403-Auskunft, sondern die Anmeldung. $anonymous = $client; $anonymous->getCookieJar()->clear(); foreach ([['GET', '/admin'], ['POST', '/admin/api/pages/p1/draft'], ['POST', '/admin/theme/save'], ['POST', '/admin/menus/header']] as [$method, $path]) { $anonymous->request($method, $path); self::assertResponseRedirects('http://localhost/admin/login', null, $method.' '.$path); } // Und ohne Anmeldung ist nichts geschrieben worden. self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page')); self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM menu')); } public function testEverySharedAreaOpensPreviewsAndSavesWithConflictProtection(): void { $client = $this->login(); $partials = new PartialRepository($this->db, new DocumentValidator()); $partials->create('section', 'shared-test', 'Gemeinsamer Abschnitt', DemoContent::areas()['footer']['document']); foreach ($partials->all() as $partial) { $base = '/admin/partials/'.$partial['id']; $client->request('GET', $base); self::assertResponseIsSuccessful(); self::assertSelectorExists('#editor[data-api="'.$base.'"]'); self::assertSelectorNotExists('#publish'); $client->request('GET', $base.'/document'); $data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR); $document = $data['document']; $document['title'] .= ' bearbeitet'; $headers = ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $data['csrfToken']]; $body = json_encode(['document' => $document, 'expectedVersion' => $data['version']], JSON_THROW_ON_ERROR); $client->request('POST', $base.'/preview', server: $headers, content: $body); self::assertResponseIsSuccessful(); $client->request('POST', $base.'/draft', server: $headers, content: $body); self::assertResponseIsSuccessful(); $client->request('POST', $base.'/draft', server: $headers, content: $body); self::assertResponseStatusCodeSame(409); $client->request('GET', $base.'/document'); self::assertSame($document['title'], json_decode($client->getResponse()->getContent(), true)['document']['title']); } } private function data(KernelBrowser $client): array { $client->request('GET', '/admin/api/pages/p1'); self::assertResponseIsSuccessful(); return json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR); } private function post(KernelBrowser $client, string $action, array $data, string $token = ''): void { $client->request('POST', '/admin/api/pages/p1/'.$action, server: ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $token], content: json_encode($data)); } public function testAnonymousCannotReadDraftOrWrite(): void { $client = self::createClient(); foreach (['/admin', '/admin/pages/p1', '/admin/api/pages/p1', '/admin/media', '/admin/api/media', '/admin/menus', '/admin/forms', '/admin/statistik', '/admin/statistik.csv'] as $url) { $client->request('GET', $url); self::assertResponseRedirects('http://localhost/admin/login'); self::assertStringNotContainsString('schemaVersion', $client->getResponse()->getContent()); } foreach (['draft', 'publish', 'preview'] as $action) { $this->post($client, $action, []); self::assertResponseRedirects('http://localhost/admin/login'); } foreach ([['/admin/pages', ['title' => 'Geschmuggelt', 'slug' => 'geschmuggelt']], ['/admin/pages/p1/delete', ['expectedVersion' => 2]], ['/admin/menus/header', ['name' => 'X', 'expectedVersion' => 0]], ['/admin/api/style-presets', ['name' => 'X']]] as [$url, $body]) { $client->request('POST', $url, $body); self::assertResponseRedirects('http://localhost/admin/login'); } self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page')); } public function testLiveSearchDoesNotExposeContentToAnonymousRequests(): void { $client = self::createClient(); foreach (['/admin', '/admin/posts', '/admin/media', '/admin/api/media/'.str_repeat('a', 32)] as $url) { $client->request('GET', $url.'?q=secret', server: ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest', 'HTTP_ACCEPT' => 'application/json']); self::assertResponseRedirects('http://localhost/admin/login'); self::assertStringNotContainsString('"html":', $client->getResponse()->getContent()); } } public function testImageSettingsOfAPostArePickedFromTheLibraryNotTypedAsAnId(): void { // Umut: „nicht dein ernst? ID aus Mediathek? schöne auswahl rein! auch bei Beitragsbild" $client = $this->login('editor'); $pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media'))); $id = $pages->createPage('mit-bild', null, 'post', DemoContent::pages()['home']); $media = str_repeat('c', 32); $this->db->insert('media', ['id' => $media, 'title' => 'Klassenzimmer', 'alt' => '', 'variants' => json_encode([['src' => '/media/'.$media.'/480', 'width' => 480, 'height' => 320, 'bytes' => 1]]), 'archived' => 0, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')]); $this->db->executeStatement('UPDATE page SET featured_media_id = ? WHERE id = ?', [$media, $id]); $crawler = $client->request('GET', '/admin/posts'); self::assertResponseIsSuccessful(); self::assertStringNotContainsString('ID aus der Mediathek', $client->getResponse()->getContent()); self::assertCount(1, $crawler->filter('script[src^="/assets/media-field.js"]')); // Das gewählte Beitragsbild: Kennung versteckt, Vorschau und Titel sichtbar. $featured = $crawler->filter('input[name="featuredMediaId"]')->closest('[data-media-field]'); self::assertCount(1, $featured); self::assertSame('hidden', $featured->filter('input[name="featuredMediaId"]')->attr('type')); self::assertSame($media, $featured->filter('input[name="featuredMediaId"]')->attr('value')); self::assertSame('/media/'.$media.'/480', $featured->filter('img')->attr('src')); self::assertStringContainsString('Klassenzimmer', $featured->text()); self::assertStringContainsString('Anderes Bild wählen', $featured->text()); // Ohne Vorschaubild: Wählen statt Kennung eintippen. $og = $crawler->filter('input[name="ogMediaId"]')->closest('[data-media-field]'); self::assertCount(1, $og); self::assertStringContainsString('Bild wählen oder hochladen', $og->text()); self::assertNull($og->filter('img')->attr('src')); } public function testLiveSearchReturnsFilteredEscapedResultsWithTheSamePermissionsAsTheFullScreen(): void { $client = $this->login('editor'); $pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media'))); foreach (['page', 'post'] as $type) { $document = DemoContent::pages()['home']; $document['title'] = 'Suchprobe Über uns '.$type; $pages->createPage('suchprobe-'.$type, null, $type, $document); } foreach (['/admin' => 'page', '/admin/posts' => 'post'] as $url => $type) { $headers = ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest']; $client->request('GET', $url.'?q=suchprobe&status=draft&seite=99&limit=50', server: $headers); self::assertResponseIsSuccessful(); self::assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR); self::assertSame(1, $data['total']); self::assertSame(['q' => 'suchprobe', 'status' => 'draft', 'limit' => 50, 'seite' => 1], $data['parameters']); self::assertStringContainsString('suchprobe-'.$type, $data['html']); self::assertStringNotContainsString('suchprobe-'.('page' === $type ? 'post' : 'page'), $data['html']); self::assertStringNotContainsString('request('GET', $url.'?q='.rawurlencode('über uns'), server: $headers); self::assertSame(1, json_decode($client->getResponse()->getContent(), true)['total']); $client->request('GET', $url.'?q=suchprobe&status=published', server: $headers); self::assertSame(0, json_decode($client->getResponse()->getContent(), true)['total']); } } public function testCreatingAPageNeedsACsrfTokenAndStartsAsAPrivateDraft(): void { $client = $this->login('editor'); $client->request('POST', '/admin/pages', ['title' => 'Ohne Token', 'slug' => 'ohne-token', '_csrf_token' => 'wrong']); self::assertResponseRedirects('/admin'); self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page')); $crawler = $client->request('GET', '/admin'); $client->submit($crawler->filter('.create-form')->form(['title' => 'Über uns', 'slug' => '', 'type' => 'page', 'parent_id' => ''])); self::assertResponseRedirects(); // The address is derived from the title when the field is left empty. self::assertSame('ueber-uns', $this->db->fetchOne('SELECT path FROM page WHERE slug <> ?', ['home'])); // A new page is a draft, so it must not answer publicly yet. $client->request('GET', '/ueber-uns'); self::assertResponseStatusCodeSame(404); } public function testEditorsAreNeitherOfferedNorAllowedToDelete(): void { $client = $this->login('editor'); $crawler = $client->request('GET', '/admin'); self::assertCount(0, $crawler->filter('.delete-panel'), 'An editor must not be offered the delete action.'); $client->request('POST', '/admin/pages/p1/delete', ['_csrf_token' => $crawler->filter('input[name="_csrf_token"]')->last()->attr('value'), 'expectedVersion' => 2]); self::assertResponseStatusCodeSame(403); self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page')); } public function testAdministratorsDeleteAPageWithAllRevisionsOnlyWithACsrfToken(): void { $client = $this->login('admin'); $client->request('POST', '/admin/pages/p1/delete', ['_csrf_token' => 'wrong', 'expectedVersion' => 2]); self::assertResponseRedirects('/admin'); self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page')); $crawler = $client->request('GET', '/admin'); $client->submit($crawler->filter('.delete-panel form')->form()); self::assertResponseRedirects('/admin'); self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page')); self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision')); $client->request('GET', '/'); self::assertResponseStatusCodeSame(404); } public function testMenuFormBuildsATreeFromFlatRowsAndReplacesTheFixedNavigation(): void { $client = $this->login('publisher'); $crawler = $client->request('GET', '/admin/menus'); self::assertResponseIsSuccessful(); // Rows are ordered by position and nested by level, so the form needs no JavaScript. $client->request('POST', '/admin/menus/header', [ '_csrf_token' => $crawler->filter('.menu-form input[name="_csrf_token"]')->first()->attr('value'), 'name' => 'Hauptnavigation', 'expectedVersion' => 0, 'items' => [ ['position' => 30, 'level' => 1, 'label' => 'Extern', 'type' => 'url', 'url' => 'https://hausklar.app'], ['position' => 10, 'level' => 1, 'label' => 'Start', 'type' => 'page', 'pageId' => 'p1', 'anchor' => ''], ['position' => 20, 'level' => 2, 'label' => 'Kontakt', 'type' => 'page', 'pageId' => 'p1', 'anchor' => '#kontakt'], ['position' => 40, 'level' => 1, 'label' => ' ', 'type' => 'page', 'pageId' => 'p1'], ], ]); self::assertResponseRedirects('/admin/menus'); $items = json_decode($this->db->fetchOne('SELECT items FROM menu WHERE slot = ?', ['header']), true); self::assertSame(['Start', 'Extern'], array_column($items, 'label')); self::assertSame('Kontakt', $items[0]['children'][0]['label']); // A leading hash in the anchor field is accepted and normalised away. self::assertSame('kontakt', $items[0]['children'][0]['anchor']); // The blank row is an add slot, not an entry. self::assertCount(2, $items); $public = $client->request('GET', '/')->filter('nav[aria-label="Hauptnavigation"] > .menu-list a'); // The navigation comes from the menu now, not from the links hard-coded in the template. self::assertSame(['/', '/#kontakt', 'https://hausklar.app'], $public->extract(['href'])); } /** * ADR 110: Ein Menüeintrag wird über das Formular zum Button - mit Gestalt, Farben, Symbol, * Bewegung und Anzeige auf dem Telefon. Die Kennung für die Farbregel entsteht beim ersten * Speichern und bleibt danach gleich; leere Felder heißen Website-Standard und fehlen. */ public function testMenuFormTurnsAnEntryIntoAButtonAndKeepsItsId(): void { $client = $this->login('publisher'); $crawler = $client->request('GET', '/admin/menus'); $token = $crawler->filter('.menu-form input[name="_csrf_token"]')->first()->attr('value'); $row = ['position' => 10, 'level' => 1, 'label' => 'Angebot anfragen', 'type' => 'url', 'url' => '#kontakt', 'icon' => 'arrow-right', 'iconPosition' => 'after', 'button' => '1', 'buttonId' => '', 'buttonShape' => 'pill', 'buttonAppearance' => '', 'buttonSize' => '', 'buttonFill' => '#b71c22', 'buttonInk' => '', 'buttonHoverFill' => '@ink', 'buttonHoverInk' => '', 'buttonIconColor' => '', 'iconMotion' => 'slide', 'buttonMobile' => 'bar']; $client->request('POST', '/admin/menus/header', ['_csrf_token' => $token, 'name' => 'Hauptnavigation', 'expectedVersion' => 0, 'items' => [$row]]); self::assertResponseRedirects('/admin/menus'); $button = json_decode($this->db->fetchOne('SELECT items FROM menu WHERE slot = ?', ['header']), true)[0]['button']; self::assertMatchesRegularExpression('/^m[a-f0-9]{10}$/', $button['id']); self::assertSame(['shape' => 'pill', 'fill' => '#b71c22', 'hoverFill' => '@ink', 'mobile' => 'bar', 'iconMotion' => 'slide'], array_diff_key($button, ['id' => true])); // Die Verwaltung zeigt die Werte wieder an, und erneutes Speichern behält die Kennung. $crawler = $client->request('GET', '/admin/menus'); self::assertSame($button['id'], $crawler->filter('input[data-key="buttonId"]')->attr('value')); self::assertSame('pill', $crawler->filter('select[data-key="buttonShape"] option[selected]')->attr('value')); $client->request('POST', '/admin/menus/header', ['_csrf_token' => $token, 'name' => 'Hauptnavigation', 'expectedVersion' => 1, 'items' => [[...$row, 'buttonId' => $button['id']]]]); self::assertSame($button['id'], json_decode($this->db->fetchOne('SELECT items FROM menu WHERE slot = ?', ['header']), true)[0]['button']['id']); // Ohne Häkchen ist es wieder ein Link. $client->request('POST', '/admin/menus/header', ['_csrf_token' => $token, 'name' => 'Hauptnavigation', 'expectedVersion' => 2, 'items' => [array_diff_key($row, ['button' => true])]]); self::assertArrayNotHasKey('button', json_decode($this->db->fetchOne('SELECT items FROM menu WHERE slot = ?', ['header']), true)[0]); } public function testMenuWritesNeedAPublisherRoleACsrfTokenAndTheCurrentVersion(): void { $menus = new MenuRepository($this->db, new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media'))); $menus->save('header', 'Hauptnavigation', [['label' => 'Start', 'type' => 'page', 'pageId' => 'p1', 'anchor' => '', 'children' => []]], 0); $client = $this->login('publisher'); $crawler = $client->request('GET', '/admin/menus'); $token = $crawler->filter('.menu-form input[name="_csrf_token"]')->first()->attr('value'); $entry = ['position' => 10, 'level' => 1, 'label' => 'Geaendert', 'type' => 'page', 'pageId' => 'p1', 'anchor' => '']; $client->request('POST', '/admin/menus/header', ['_csrf_token' => 'wrong', 'name' => 'X', 'expectedVersion' => 1, 'items' => [$entry]]); self::assertResponseRedirects('/admin/menus'); self::assertSame('Hauptnavigation', $this->db->fetchOne('SELECT name FROM menu WHERE slot = ?', ['header'])); // A stale version must not overwrite a newer menu. $client->request('POST', '/admin/menus/header', ['_csrf_token' => $token, 'name' => 'Veraltet', 'expectedVersion' => 0, 'items' => [$entry]]); self::assertResponseRedirects('/admin/menus'); self::assertSame('Hauptnavigation', $this->db->fetchOne('SELECT name FROM menu WHERE slot = ?', ['header'])); // An unsafe target is refused with a message instead of being stored. $client->request('POST', '/admin/menus/header', ['_csrf_token' => $token, 'name' => 'Neu', 'expectedVersion' => 1, 'items' => [['position' => 10, 'level' => 1, 'label' => 'Boese', 'type' => 'url', 'url' => 'javascript:alert(1)']]]); $client->followRedirect(); self::assertSelectorExists('.notice.error'); self::assertStringNotContainsString('javascript:', $this->db->fetchOne('SELECT items FROM menu WHERE slot = ?', ['header'])); $client->request('POST', '/admin/menus/header', ['_csrf_token' => $token, 'name' => 'Neu', 'expectedVersion' => 1, 'items' => [$entry]]); self::assertSame('Neu', $this->db->fetchOne('SELECT name FROM menu WHERE slot = ?', ['header'])); } /** @param array $settings overrides for the mail settings */ private function contactForm(array $settings = []): FormRepository { $forms = new FormRepository($this->db); $forms->save('kontakt', 'Kontakt', [ ['name' => 'name', 'type' => 'text', 'label' => 'Name', 'required' => true], ['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true], ['name' => 'thema', 'type' => 'select', 'label' => 'Thema', 'required' => true, 'options' => ['Beratung', 'Support']], ['name' => 'nachricht', 'type' => 'textarea', 'label' => 'Nachricht', 'required' => false], ['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Ich willige ein.'], ], ['recipient' => 'info@kant-it.de', 'subject' => 'Anfrage', 'replyToField' => 'email', 'success' => 'Danke.', ...$settings], 0); return $forms; } /** Publishes a page carrying the contact form block and returns the page path. */ private function pageWithForm(): string { $document = DemoContent::starter('Kontakt'); $document['schemaVersion'] = 8; $document['blocks'][] = ['id' => 'formblock', 'type' => 'form', 'props' => ['form' => 'kontakt']]; $pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media'))); $revision = $pages->saveDraft('p1', $document, (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['p1'])); $pages->publish('p1', $revision, (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['p1'])); return 'home'; } /** @return array a body that would pass every check */ private function validBody(string $issued): array { return [ 'page' => 'home', 'block' => 'formblock', 'website' => '', 'issued' => $issued, 'issuedSignature' => hash_hmac('sha256', $issued, 'isolated-test-secret'), 'name' => 'Umut', 'email' => 'umut@example.com', 'thema' => 'Beratung', 'nachricht' => 'Bitte um Rückruf.', 'datenschutz' => 'ja', ]; } public function testFormRendersWithoutScriptAndStoresAValidSubmissionWithConsent(): void { $this->contactForm(); $this->pageWithForm(); $client = self::createClient(); $html = $client->request('GET', '/')->html(); self::assertStringContainsString('action="/formular/kontakt"', $html); self::assertStringContainsString('', $html); self::assertStringContainsString('Ich willige ein.', $html); // The honeypot exists but is kept out of the tab order and away from assistive technology. self::assertStringContainsString('name="website"', $html); self::assertStringContainsString('tabindex="-1"', $html); self::assertStringNotContainsString('getResponse()->getContent()); $issued = (string) (time() - 10); $client->request('POST', '/formular/kontakt', $this->validBody($issued)); // Post/Redirect/Get, so reloading the result page sends nothing twice. self::assertResponseStatusCodeSame(303); self::assertStringContainsString('?gesendet=kontakt#formblock', (string) $client->getResponse()->headers->get('Location')); // Ohne eigene Absenderadresse geht die Post vom Empfaenger an sich selbst. $mail = self::getMailerMessage(); self::assertNotNull($mail); self::assertEmailAddressContains($mail, 'From', 'info@kant-it.de'); self::assertEmailAddressContains($mail, 'To', 'info@kant-it.de'); self::assertEmailAddressContains($mail, 'Reply-To', 'umut@example.com'); $row = $this->db->fetchAssociative('SELECT * FROM form_submission'); self::assertNotFalse($row); $data = json_decode($row['data'], true); self::assertSame('Umut', $data['name']); self::assertSame('ja', $data['datenschutz']); // The moment of consent is stored, the sender address never is. self::assertNotNull($row['consent_at']); self::assertSame(64, strlen($row['sender_hash'])); self::assertStringNotContainsString('127.0.0.1', json_encode($row)); // The success message appears on the page the form sits on. $client->request('GET', '/?gesendet=kontakt'); self::assertStringContainsString('Danke.', $client->getResponse()->getContent()); } public function testAFormMaySendFromItsOwnAddress(): void { // Umut: die Versandadresse soll sich festlegen lassen - bisher war sie immer der // Empfaenger, und manche Hoster nehmen fremde Absender gar nicht erst an. $forms = $this->contactForm(['sender' => 'formular@kant-it.de']); $this->pageWithForm(); $client = self::createClient(); $client->request('POST', '/formular/kontakt', $this->validBody((string) (time() - 10))); self::assertResponseStatusCodeSame(303); $mail = self::getMailerMessage(); self::assertNotNull($mail); self::assertEmailAddressContains($mail, 'From', 'formular@kant-it.de'); self::assertEmailAddressContains($mail, 'To', 'info@kant-it.de'); self::assertEmailAddressContains($mail, 'Reply-To', 'umut@example.com'); // Eine kaputte Absenderadresse wird abgewiesen, eine leere bedeutet: wie der Empfaenger. try { $forms->save('kontakt', 'Kontakt', [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail']], ['recipient' => 'info@kant-it.de', 'sender' => 'kein-postfach'], 1); self::fail('Eine ungueltige Absenderadresse darf nicht gespeichert werden.'); } catch (InvalidForm $e) { self::assertStringContainsString('Absenderadresse', $e->getMessage()); } $forms->save('kontakt', 'Kontakt', [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail']], ['recipient' => 'info@kant-it.de', 'sender' => ' '], 1); self::assertSame('', $forms->findBySlug('kontakt')['settings']['sender']); } public function testInvalidSubmissionKeepsEveryEntryAndNamesTheOffendingFields(): void { $this->contactForm(); $this->pageWithForm(); $client = self::createClient(); $issued = (string) (time() - 10); $body = [...$this->validBody($issued), 'email' => 'keine-adresse', 'thema' => 'Erfunden', 'name' => '', 'datenschutz' => '']; $crawler = $client->request('POST', '/formular/kontakt', $body); self::assertResponseStatusCodeSame(422); // Nothing is stored and nothing is sent while anything is wrong. self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission')); $html = $client->getResponse()->getContent(); self::assertStringContainsString('Bitte gib eine gültige E-Mail-Adresse an.', $html); self::assertStringContainsString('Bitte wähle eine der angebotenen Optionen.', $html); self::assertStringContainsString('Dieses Feld wird benötigt.', $html); self::assertStringContainsString('Ohne diese Einwilligung', $html); // Each message belongs to its field and is announced as such. self::assertSame('formblock-email-error', $crawler->filter('#formblock-email')->attr('aria-describedby')); self::assertSame('true', $crawler->filter('#formblock-email')->attr('aria-invalid')); // Und die Seite sieht aus wie die Seite: Gestaltung, Kopf- und Fussbereich sind da. // Vorher baute der Formularcode die Seite selbst noch einmal auf und liess dabei Theme, // Blockstile und Schriften weg - wer ein Pflichtfeld vergass, sah eine nackte Seite. self::assertStringContainsString('#s', $html, $style); self::assertStringContainsString("'sha256-".base64_encode(hash('sha256', $style[1], true))."'", (string) $client->getResponse()->headers->get('Content-Security-Policy')); // Die Übersicht listet das Formular, bearbeitet wird es auf seiner eigenen Seite. $crawler = $client->request('GET', '/admin/forms'); self::assertSame(0, $crawler->filter('.form-def')->count(), 'Die Übersicht zeigt keine Editoren.'); self::assertSame('/admin/forms/kontakt', $crawler->filter('.form-list a')->first()->attr('href')); // Eine Farbe, die keine ist, wird mit Meldung abgelehnt und nichts geaendert. $crawler = $client->request('GET', '/admin/forms/kontakt'); $token = $crawler->filter('.form-def input[name="_csrf_token"]')->first()->attr('value'); self::assertCount(5, $crawler->filter('.form-def')->first()->filter('.field-card:not(.field-card-new)'), 'Jedes Feld ist eine Karte.'); self::assertSame('Anfrage senden', $crawler->filter('.form-def')->first()->filter('[data-preview-button]')->text()); $client->request('POST', '/admin/forms/kontakt', ['_csrf_token' => $token, 'name' => 'Kontakt', 'recipient' => 'info@kant-it.de', 'expectedVersion' => 2, 'buttonColor' => 'rot', 'fields' => [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => '1']]]); $client->followRedirect(); self::assertSelectorTextContains('.notice.error', 'kein Farbwert für den Button'); self::assertSame('#124b36', $forms->findBySlug('kontakt')['settings']['buttonColor']); } public function testFormAdministrationNeedsAPublisherRoleAndACsrfToken(): void { $client = $this->login('publisher'); $crawler = $client->request('GET', '/admin/forms/neu'); self::assertResponseIsSuccessful(); $token = $crawler->filter('.form-def input[name="_csrf_token"]')->first()->attr('value'); $field = ['fields' => [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => '1']]]; $client->request('POST', '/admin/forms/neu', [...$field, '_csrf_token' => 'wrong', 'slug' => 'kontakt', 'name' => 'Kontakt', 'recipient' => 'info@kant-it.de', 'expectedVersion' => 0]); self::assertResponseRedirects('/admin/forms'); self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form')); // A recipient that is not an address is refused with a message rather than stored. $client->request('POST', '/admin/forms/neu', [...$field, '_csrf_token' => $token, 'slug' => 'kontakt', 'name' => 'Kontakt', 'recipient' => 'kein-empfaenger', 'expectedVersion' => 0]); $client->followRedirect(); self::assertSelectorExists('.notice.error'); self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form')); $client->request('POST', '/admin/forms/neu', [...$field, '_csrf_token' => $token, 'slug' => 'kontakt', 'name' => 'Kontakt', 'recipient' => 'info@kant-it.de', 'expectedVersion' => 0]); self::assertSame('Kontakt', $this->db->fetchOne('SELECT name FROM form')); } public function testTheEditorPicksFormsSectionsAndTermsFromListsInsteadOfTypedAddresses(): void { // Was ein Baustein nur verknüpft, kommt als Liste Adresse => Name - der Editor macht // daraus Auswahlfelder statt Textfelder für Adressen. $media = new MediaLibrary($this->db, $this->path.'-media'); $pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, $media)); (new FormRepository($this->db))->save('anfrage', 'Anfrage', [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true, 'help' => '', 'options' => []]], ['recipient' => 'info@example.com'], 0); $band = DemoContent::starter('Werbeband'); $band['blocks'] = [['id' => 'band-text', 'type' => 'text', 'props' => ['text' => 'Aktion']]]; (new PartialRepository($this->db, new DocumentValidator()))->create('section', 'band', 'Werbeband', $band); $post = $pages->createPage('ein-beitrag', null, 'post', DemoContent::pages()['home']); (new PostRepository($this->db, $media, new SiteSettings($this->db, $media)))->saveMetadata($post, [], ['Projekte'], ['Umbau'], $pages->findForEditor($post)['version']); $client = $this->login('editor'); $client->request('GET', '/admin/api/design'); self::assertResponseIsSuccessful(); $choices = json_decode($client->getResponse()->getContent(), true)['choices']; self::assertSame(['anfrage' => 'Anfrage'], $choices['forms']); self::assertSame(['band' => 'Werbeband'], $choices['sections']); self::assertSame(['projekte' => 'Projekte'], $choices['categories']); self::assertSame(['umbau' => 'Umbau'], $choices['tags']); } public function testMenuAnchorsAndPostTermsAreOfferedForChoosing(): void { $media = new MediaLibrary($this->db, $this->path.'-media'); $pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, $media)); $document = DemoContent::starter('Leistungen'); $document['schemaVersion'] = 21; $document['blocks'][] = ['id' => 'kontakt-sektion', 'type' => 'section', 'anchor' => 'kontakt', 'children' => [ ['id' => 'kontakt-titel', 'type' => 'heading', 'props' => ['text' => 'So erreichen Sie uns', 'level' => 2]], ]]; $id = $pages->createPage('leistungen', null, 'page', $document); // Die Sprungziele einer Seite, beschriftet mit ihrer Überschrift - nur für Rollen, die // Menüs bearbeiten. $client = $this->login('editor'); $client->request('GET', '/admin/api/sprungziele/'.$id); self::assertResponseStatusCodeSame(403); $client = $this->login('admin', $client); $client->request('GET', '/admin/api/sprungziele/'.$id); self::assertResponseIsSuccessful(); self::assertSame([['anchor' => 'kontakt', 'label' => 'So erreichen Sie uns (#kontakt)']], json_decode($client->getResponse()->getContent(), true)['items']); $client->request('GET', '/admin/api/sprungziele/gibt-es-nicht'); self::assertResponseStatusCodeSame(404); // Vorhandene Kategorien und Schlagwörter stehen am Feld, damit man sie wählt statt neu tippt. $post = $pages->createPage('beitrag', null, 'post', DemoContent::pages()['home']); (new PostRepository($this->db, $media, new SiteSettings($this->db, $media)))->saveMetadata($post, [], ['Projekte'], ['Umbau'], $pages->findForEditor($post)['version']); $crawler = $client->request('GET', '/admin/posts'); self::assertSame(['Projekte'], json_decode($crawler->filter('input[name="categories"]')->attr('data-term-input'), true)); self::assertSame(['Umbau'], json_decode($crawler->filter('input[name="tags"]')->attr('data-term-input'), true)); self::assertCount(1, $crawler->filter('script[src^="/assets/term-chips.js"]')); } public function testPluginSettingsForLinksAndImagesAreChosenNotTyped(): void { // Das Feld einer Plugin-Einstellung, direkt aus dem Makro der Plugin-Seite: ein Link // bleibt ein Textfeld mit Knöpfen daneben, ein Bild wird das Bildfeld der Mediathek. self::bootKernel(); $twig = static::getContainer()->get('twig'); $render = static fn (array $schema, string $value): string => $twig->createTemplate( "{% import 'admin/plugins.html.twig' as f %}{{ f.feld('probe', 'ziel', schema, value) }}", )->render(['schema' => $schema, 'value' => $value]); $link = $render(['kind' => 'url', 'label' => 'Ziel', 'maxLength' => 200, 'help' => ''], '/kontakt'); self::assertStringContainsString('data-link-field', $link); self::assertMatchesRegularExpression('#]*data-link-input#', $link); $bild = $render(['kind' => 'media', 'label' => 'Bild', 'help' => ''], ''); self::assertStringContainsString('data-media-field', $bild); self::assertStringContainsString('', $bild); self::assertStringNotContainsString('Kennung aus der Mediathek', $bild); } public function testStylePresetsNeedAnEditorRoleACsrfTokenAndAValidStyle(): void { $client = $this->login('editor'); $client->request('GET', '/admin/api/design'); self::assertResponseIsSuccessful(); $design = json_decode($client->getResponse()->getContent(), true); // The editor receives the vocabulary, the palette and the presets from one place. self::assertArrayHasKey('fontSize', $design['properties']); self::assertSame([], $design['presets']); $token = $design['csrfToken']; $post = function (array $body, string $csrf) use ($client): void { $client->request('POST', '/admin/api/style-presets', server: ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $csrf], content: json_encode($body)); }; $post(['name' => 'Heller Abschnitt', 'style' => ['desktop' => ['fontSize' => '32px']]], 'wrong'); self::assertResponseStatusCodeSame(403); // A style the documents would refuse cannot arrive through a preset either. $post(['name' => 'Boese', 'style' => ['desktop' => ['fontSize' => '32px;position:fixed']]], $token); self::assertResponseStatusCodeSame(422); $post(['name' => ' ', 'style' => ['desktop' => ['fontSize' => '32px']]], $token); self::assertResponseStatusCodeSame(422); self::assertStringNotContainsString('position:fixed', (string) $this->db->fetchOne('SELECT theme FROM website WHERE id = ?', ['default'])); $post(['name' => 'Heller Abschnitt', 'style' => ['desktop' => ['fontSize' => '32px', 'padding' => ['top' => '40px']]]], $token); self::assertResponseIsSuccessful(); $presets = json_decode($client->getResponse()->getContent(), true)['presets']; self::assertSame('heller-abschnitt', $presets[0]['key']); self::assertSame('32px', $presets[0]['style']['desktop']['fontSize']); // Removing works by sending the same name without a style. $post(['name' => 'Heller Abschnitt', 'style' => null], $token); self::assertSame([], json_decode($client->getResponse()->getContent(), true)['presets']); } public function testMediaUploadReencodingCsrfMetadataConflictAndArchive(): void { $client = $this->login('editor'); $client->request('GET', '/admin/api/media'); self::assertResponseIsSuccessful(); $token = json_decode($client->getResponse()->getContent(), true)['csrfToken']; $client->request('POST', '/admin/api/media/upload'); self::assertResponseStatusCodeSame(403); $path = tempnam(sys_get_temp_dir(), 'kant-image-'); try { file_put_contents($path, ''); $client->request('POST', '/admin/api/media/upload', files: ['file' => new UploadedFile($path, 'fake.jpg', test: true)], server: ['HTTP_X_CSRF_TOKEN' => $token]); self::assertResponseStatusCodeSame(422); self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media')); $image = imagecreatetruecolor(1800, 1000); imagepng($image, $path); imagedestroy($image); file_put_contents($path, '', FILE_APPEND); $client->request('POST', '/admin/api/media/upload', files: ['file' => new UploadedFile($path, 'example.php', test: true)], server: ['HTTP_X_CSRF_TOKEN' => $token]); self::assertResponseStatusCodeSame(201); $item = json_decode($client->getResponse()->getContent(), true)['item']; // Four steps now; the last one is capped at the width of the source (1800 here), so a // picture is never blown up beyond what was uploaded. self::assertSame([480, 960, 1600, 1800], array_column($item['variants'], 'width')); foreach ($item['variants'] as $variant) { $file = $this->path.'-media/'.$item['id'].'-'.$variant['width'].'.webp'; self::assertSame('image/webp', getimagesize($file)['mime']); self::assertStringNotContainsString('SECRET_SOURCE_PAYLOAD', file_get_contents($file)); } $update = ['title' => 'Testbild', 'alt' => 'Dunkle Testfläche', 'version' => 1, 'archived' => true]; $client->request('POST', '/admin/api/media/'.$item['id'], server: ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $token], content: json_encode($update)); self::assertResponseIsSuccessful(); $client->request('POST', '/admin/api/media/'.$item['id'], server: ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $token], content: json_encode($update)); self::assertResponseStatusCodeSame(409); $client->request('GET', $item['variants'][0]['src']); self::assertResponseIsSuccessful(); self::assertResponseHeaderSame('Content-Type', 'image/webp'); self::assertResponseHeaderSame('X-Content-Type-Options', 'nosniff'); $client->request('GET', '/media/'.$item['id'].'/999.webp'); self::assertResponseStatusCodeSame(404); $update['version'] = 2; $update['archived'] = false; $client->request('POST', '/admin/api/media/'.$item['id'], server: ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $token], content: json_encode($update)); self::assertResponseIsSuccessful(); self::assertSame(0, (int) $this->db->fetchOne('SELECT archived FROM media')); } finally { unlink($path); } } public function testPdfsGoThroughTheMediaUploadAndTheEditorPicker(): void { $client = $this->login('editor'); $crawler = $client->request('GET', '/admin/media'); $token = $crawler->filter('form[action="/admin/media/upload"] input[name="_csrf_token"]')->attr('value'); $path = tempnam(sys_get_temp_dir(), 'kant-form-pdf-'); try { file_put_contents($path, "%PDF-1.7\n%%EOF\n"); // Dasselbe Feld wie für Bilder: der Inhalt entscheidet, wohin die Datei geht. $client->request('POST', '/admin/media/upload', ['_csrf_token' => $token], ['files' => [new UploadedFile($path, 'Flyer.pdf', test: true)]]); self::assertResponseRedirects('/admin/media'); self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media')); self::assertSame('Flyer', $this->db->fetchOne('SELECT title FROM document')); $crawler = $client->followRedirect(); self::assertStringContainsString('1 Datei(en) hochgeladen', $crawler->filter('.notice.ok')->text()); self::assertCount(1, $crawler->filter('section[aria-labelledby="documents-heading"] .row')); // Die Auswahl im Editor: Liste mit Token, Hochladen nur mit Token. $client->request('GET', '/admin/api/dokumente'); self::assertResponseIsSuccessful(); $data = json_decode($client->getResponse()->getContent(), true); self::assertSame('Flyer', $data['items'][0]['title']); $client->request('POST', '/admin/api/dokumente/upload', files: ['file' => new UploadedFile($path, 'zweites.pdf', test: true)]); self::assertResponseStatusCodeSame(403); file_put_contents($path, 'request('POST', '/admin/api/dokumente/upload', files: ['file' => new UploadedFile($path, 'getarnt.pdf', test: true)], server: ['HTTP_X_CSRF_TOKEN' => $data['csrfToken']]); self::assertResponseStatusCodeSame(422); self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM document')); // Archivieren nimmt es aus der Auswahl-Übersicht, die Datei bleibt erreichbar. $id = $data['items'][0]['id']; $client->request('POST', '/admin/api/dokumente/'.$id, server: ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $data['csrfToken']], content: json_encode(['title' => 'Flyer 2026', 'archived' => true, 'version' => 1])); self::assertResponseIsSuccessful(); self::assertCount(0, $client->request('GET', '/admin/media')->filter('section[aria-labelledby="documents-heading"] .row')); $client->request('GET', '/media/'.$id.'.pdf'); self::assertResponseIsSuccessful(); } finally { unlink($path); } } public function testMediaPreviewReadsSmallAndArchivedImagesWithoutListingTheLibrary(): void { $id = str_repeat('a', 32); $this->db->insert('media', ['id' => $id, 'title' => 'Kleines Logo', 'alt' => 'Logo', 'variants' => json_encode([['src' => '/media/'.$id.'/192', 'width' => 192, 'height' => 64, 'bytes' => 1]]), 'archived' => 1, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')]); $client = $this->login('editor'); $client->request('GET', '/admin/api/media/'.$id); self::assertResponseIsSuccessful(); $data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR); self::assertSame($id, $data['item']['id']); self::assertSame(192, $data['item']['variants'][0]['width']); self::assertTrue($data['item']['archived']); self::assertArrayNotHasKey('items', $data); $client->request('GET', '/admin/api/media/'.str_repeat('b', 32)); self::assertResponseStatusCodeSame(404); } public function testTheMediaScreenUploadsAndEditsWithoutJavaScript(): void { $client = $this->login('editor'); $crawler = $client->request('GET', '/admin/media'); self::assertResponseIsSuccessful(); // The images are on the page itself, not behind a button that opens a dialog. self::assertCount(0, $crawler->filter('.library-tile')); self::assertCount(1, $crawler->filter('form[action="/admin/media/upload"] input[type="file"]')); $token = $crawler->filter('form[action="/admin/media/upload"] input[name="_csrf_token"]')->attr('value'); $path = tempnam(sys_get_temp_dir(), 'kant-form-image-'); try { $image = imagecreatetruecolor(900, 600); imagepng($image, $path); imagedestroy($image); $client->request('POST', '/admin/media/upload', ['_csrf_token' => 'wrong'], ['files' => [new UploadedFile($path, 'ohne-token.png', test: true)]]); self::assertResponseRedirects('/admin/media'); self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media')); // Two files in one submit, the way the drop area collects them. $client->request('POST', '/admin/media/upload', ['_csrf_token' => $token], ['files' => [ new UploadedFile($path, 'erstes.png', test: true), new UploadedFile($path, 'zweites.png', test: true), ]]); self::assertResponseRedirects('/admin/media'); self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media')); $crawler = $client->followRedirect(); self::assertCount(2, $crawler->filter('.library-tile')); self::assertCount(1, $crawler->filter('.notice.ok')); self::assertStringContainsString('2 Datei(en) hochgeladen', $crawler->filter('.notice.ok')->text()); $id = (string) $this->db->fetchOne('SELECT id FROM media LIMIT 1'); $client->request('POST', '/admin/media/'.$id, ['_csrf_token' => $token, 'title' => 'Bergblick', 'alt' => 'Grüne Berglandschaft', 'archived' => '1', 'version' => 1]); self::assertResponseRedirects('/admin/media'); self::assertSame(['Bergblick', 'Grüne Berglandschaft', 1], array_values($this->db->fetchNumeric('SELECT title, alt, archived FROM media WHERE id = ?', [$id]))); // An archived image is out of the default listing but still delivered, and the archive // view shows it again. self::assertCount(1, $client->request('GET', '/admin/media')->filter('.library-tile')); self::assertCount(2, $client->request('GET', '/admin/media?archiv=1')->filter('.library-tile')); // A second save with the same version is refused instead of overwriting the newer one. $client->request('POST', '/admin/media/'.$id, ['_csrf_token' => $token, 'title' => 'Nochmal', 'alt' => '', 'version' => 1]); self::assertResponseRedirects('/admin/media'); self::assertSame('Bergblick', $this->db->fetchOne('SELECT title FROM media WHERE id = ?', [$id])); self::assertCount(1, $client->followRedirect()->filter('.notice.error:not([hidden])')); } finally { unlink($path); } } public function testTheMediaScreenPaginatesAndKeepsTheRequestedPageAfterASave(): void { $client = $this->login('editor'); for ($number = 1; $number <= 26; ++$number) { $this->db->insert('media', [ 'id' => str_pad((string) $number, 32, '0', STR_PAD_LEFT), 'title' => 'Bild '.$number, 'alt' => '', 'variants' => json_encode([['src' => '/media/x/480', 'width' => 480, 'height' => 320, 'bytes' => 1]]), 'archived' => 0, 'version' => 1, // Ascending dates, so "newest first" puts number 26 on the first page. 'created_at' => gmdate('Y-m-d H:i:s', strtotime('2026-09-01 00:00:00') + $number * 60), ]); } $first = $client->request('GET', '/admin/media'); self::assertCount(MediaLibrary::PER_PAGE, $first->filter('.library-tile')); self::assertStringContainsString('Bild 26', $first->filter('.library-tile')->first()->text()); self::assertCount(1, $first->filter('.pager a[rel="next"]')); self::assertCount(0, $first->filter('.pager a[rel="prev"]')); $second = $client->request('GET', '/admin/media?seite=2'); self::assertCount(2, $second->filter('.library-tile')); self::assertStringContainsString('Bild 2', $second->filter('.library-tile')->first()->text()); // A page beyond the end is clamped instead of showing an empty screen. self::assertCount(2, $client->request('GET', '/admin/media?seite=99')->filter('.library-tile')); // Saving from page two comes back to page two. $token = $second->filter('form[action^="/admin/media/000"] input[name="_csrf_token"]')->first()->attr('value'); $id = str_pad('2', 32, '0', STR_PAD_LEFT); $client->request('POST', '/admin/media/'.$id, ['_csrf_token' => $token, 'title' => 'Umbenannt', 'alt' => '', 'version' => 1, 'seite' => 2]); self::assertResponseRedirects('/admin/media?seite=2'); } public function testMediaLiveSearchCoversAllPagesAltTextArchiveAndLiteralWildcards(): void { $client = $this->login('editor'); for ($number = 1; $number <= 27; ++$number) { $this->db->insert('media', [ 'id' => str_pad((string) $number, 32, '0', STR_PAD_LEFT), 'title' => 1 === $number ? 'Seltenes Motiv 100%_!' : 'Bild '.$number, 'alt' => 2 === $number ? 'Einzigartiger Alternativtext Äußerer Bereich' : '', 'variants' => json_encode([['src' => '/media/x/480', 'width' => 480, 'height' => 320, 'bytes' => 1]]), 'archived' => 27 === $number ? 1 : 0, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s', 1700000000 + $number), ]); } foreach ([['SELTENES', false, 1], ['ALTERNATIVTEXT', false, 1], ['äußerer', false, 1], ['%_!', false, 1], ['Bild', false, 25], ['Bild', true, 26], ['fehlt', false, 0]] as [$query, $archived, $total]) { $parameters = ['q' => $query, 'archiv' => $archived ? 1 : 0, 'seite' => 99]; $client->request('GET', '/admin/media?'.http_build_query($parameters), server: ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest']); self::assertResponseIsSuccessful(); $data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR); self::assertSame($total, $data['total'], $query); self::assertSame($total > 24 ? 2 : 1, $data['parameters']['seite']); self::assertStringNotContainsString('request('GET', '/admin/media?q=Seltenes'); self::assertCount(1, $crawler->filter('.library-tile')); $form = $crawler->filter('.library-tile form')->form(['alt' => 'Geändert']); $client->submit($form); self::assertResponseRedirects('/admin/media?q=Seltenes'); self::assertCount(1, $client->followRedirect()->filter('.library-tile')); $crawler = $client->request('GET', '/admin/media?q=0'); $client->submit($crawler->filter('.library-tile form')->first()->form(['alt' => 'Ziffernsuche'])); self::assertResponseRedirects('/admin/media?q=0'); } public function testLegalPagesCanBeCreatedWithoutInjectingFooterLinks(): void { $client = $this->login('publisher'); $crawler = $client->request('GET', '/admin/settings'); // As long as they are missing, the settings screen says so instead of hiding it. self::assertCount(1, $crawler->filter('form[action="/admin/settings/rechtliches"]')); self::assertStringContainsString('Pflichtseiten fehlen', $crawler->filter('.card-head')->first()->text()); $token = $crawler->filter('form[action="/admin/settings/rechtliches"] input[name="_csrf_token"]')->attr('value'); $client->request('POST', '/admin/settings/rechtliches', ['_csrf_token' => 'wrong']); self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page WHERE path IN (?, ?)', ['datenschutz', 'impressum'])); $client->request('POST', '/admin/settings/rechtliches', ['_csrf_token' => $token]); self::assertResponseRedirects('/admin/settings'); // Both pages exist, are published, and the settings point at them. foreach (['datenschutz' => 'Datenschutzerklärung', 'impressum' => 'Impressum'] as $path => $title) { $row = $this->db->fetchAssociative('SELECT id, published_revision_id FROM page WHERE path = ?', [$path]); self::assertNotFalse($row, $path); self::assertNotNull($row['published_revision_id'], $path.' must be published: an unpublished imprint is no imprint.'); $client->request('GET', '/'.$path); self::assertResponseIsSuccessful(); self::assertStringContainsString($title, $client->getResponse()->getContent()); // The skeleton says plainly that it is not finished. self::assertStringContainsString('NOCH AUSZUFÜLLEN', $client->getResponse()->getContent()); } // Creating the pages does not alter the author's footer or append a link bar. $client->request('GET', '/'); $content = $client->getResponse()->getContent(); self::assertStringNotContainsString('Datenschutz', $content); self::assertStringNotContainsString('Impressum', $content); // Running it twice keeps the existing pages: they may already carry the real text. $before = $this->db->fetchOne('SELECT COUNT(*) FROM revision'); $client->request('POST', '/admin/settings/rechtliches', ['_csrf_token' => $token]); self::assertSame($before, $this->db->fetchOne('SELECT COUNT(*) FROM revision')); self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page WHERE path IN (?, ?)', ['datenschutz', 'impressum'])); } public function testTheStatisticsScreenNeedsASignInAndShowsWhatWasCounted(): void { $client = $this->login('editor'); // Two page views and one missing address, counted through real requests. $client->request('GET', '/'); $client->request('GET', '/'); $client->request('GET', '/gibt-es-nicht'); $crawler = $client->request('GET', '/admin/statistik'); self::assertResponseIsSuccessful(); self::assertStringContainsString('2', $crawler->filter('.card-head .count')->first()->text()); // All three charts - days, hours and weekdays - are drawn by the server, so they are in // the markup and need no script. self::assertCount(3, $crawler->filter('svg.chart[role="img"]')); // Jeder Balken sagt beim Ueberfahren, wofuer er steht - auch das ohne Skript. self::assertStringContainsString('2 Aufrufe', $crawler->filter('svg.chart-days rect title')->last()->text()); self::assertSame('2', trim($crawler->filter('.stat-tile-main .stat-value')->text()), 'Die Hauptkachel traegt die Summe.'); self::assertSame('1', trim($crawler->filter('.stat-tile')->last()->filter('.stat-value')->text()), 'Die letzte Kachel zaehlt die nicht gefundene Adresse.'); self::assertCount(0, $crawler->filter('svg [style]'), 'Inline styles are forbidden by the policy.'); self::assertStringContainsString('/gibt-es-nicht', $crawler->filter('.stats-list')->last()->text()); // And it says what it cannot answer. self::assertStringContainsString('nicht Besucher', $crawler->filter('.card-body')->last()->text()); $client->request('GET', '/admin/statistik.csv?tage=7'); self::assertResponseIsSuccessful(); self::assertStringContainsString('text/csv', (string) $client->getResponse()->headers->get('Content-Type')); self::assertStringContainsString('attachment; filename="kantsites-statistik-7-tage.csv"', (string) $client->getResponse()->headers->get('Content-Disposition')); // The browser has already streamed the body once; the captured copy is the internal one. $csv = (string) $client->getInternalResponse()->getContent(); self::assertStringContainsString('tag;stunde;pfad;status;verweis-domain;anzahl', $csv); self::assertStringContainsString(';/;200;;2', $csv); } public function testPasswordUpgradeRefreshesTheSessionUser(): void { $provider = new UserProvider($this->db); $user = $provider->loadUserByIdentifier('admin'); $hash = password_hash('Fixture-only-password', PASSWORD_BCRYPT, ['cost' => 5]); $provider->upgradePassword($user, $hash); self::assertSame($hash, $user->getPassword()); self::assertSame($hash, $provider->refreshUser($user)->getPassword()); } public function testChangedRolesInvalidateAnExistingSession(): void { $client = $this->login(); $this->db->executeStatement('UPDATE cms_user SET roles = ? WHERE username = ?', ['["ROLE_EDITOR"]', 'admin']); $client->request('GET', '/admin/api/pages/p1'); self::assertResponseRedirects('http://localhost/admin/login'); } public function testLoginCsrfAndPasswordAreRequired(): void { $client = self::createClient(); $client->request('POST', '/admin/login', ['_username' => 'admin', '_password' => 'Fixture-only-password']); self::assertResponseRedirects('/admin/login'); $crawler = $client->followRedirect(); self::assertSelectorExists('[role="alert"]'); $client->submit($crawler->selectButton('Anmelden')->form(['_username' => 'admin', '_password' => 'wrong'])); self::assertResponseRedirects('/admin/login'); $client->request('GET', '/admin'); self::assertResponseRedirects('http://localhost/admin/login'); } public function testCsrfProtectsEveryPostAndMalformedBodyIsRejected(): void { $client = $this->login(); $data = $this->data($client); foreach (['draft', 'publish', 'preview'] as $action) { $this->post($client, $action, ['document' => $data['document'], 'expectedVersion' => $data['version'], 'revisionId' => $data['revisionId']], 'invalid'); self::assertResponseStatusCodeSame(403); } $this->post($client, 'draft', ['document' => $data['document'], 'expectedVersion' => '2'], $data['csrfToken']); self::assertResponseStatusCodeSame(422); $client->request('POST', '/admin/api/pages/p1/draft', server: ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $data['csrfToken']], content: '{bad-json'); self::assertResponseStatusCodeSame(422); self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision')); } public function testEditorCanSaveButCannotPublish(): void { $client = $this->login('editor'); $data = $this->data($client); $data['document']['title'] = 'Private draft'; $this->post($client, 'draft', ['document' => $data['document'], 'expectedVersion' => 2], $data['csrfToken']); self::assertResponseIsSuccessful(); $saved = json_decode($client->getResponse()->getContent(), true); $this->post($client, 'publish', ['expectedVersion' => 3, 'revisionId' => $saved['revisionId']], $data['csrfToken']); self::assertResponseStatusCodeSame(403); $client->request('GET', '/'); self::assertStringNotContainsString('Private draft', $client->getResponse()->getContent()); } public function testPreviewSaveConflictAndPublishRoundTrip(): void { $client = $this->login(); $data = $this->data($client); $document = $data['document']; $document['blocks'][0]['children'][1]['props']['text'] = 'An edited heading'; $this->post($client, 'preview', ['document' => $document], $data['csrfToken']); self::assertResponseIsSuccessful(); self::assertStringContainsString('An edited heading', $client->getResponse()->getContent()); self::assertStringContainsString('no-store', $client->getResponse()->headers->get('Cache-Control')); self::assertStringContainsString('noindex', $client->getResponse()->headers->get('X-Robots-Tag')); $this->post($client, 'draft', ['document' => $document, 'expectedVersion' => 2], $data['csrfToken']); self::assertResponseIsSuccessful(); $saved = json_decode($client->getResponse()->getContent(), true); self::assertSame($document, $saved['document']); self::assertSame(3, $saved['version']); $this->post($client, 'draft', ['document' => $data['document'], 'expectedVersion' => 2], $data['csrfToken']); self::assertResponseStatusCodeSame(409); $this->post($client, 'publish', ['expectedVersion' => 3, 'revisionId' => $data['revisionId']], $data['csrfToken']); self::assertResponseStatusCodeSame(409); $this->post($client, 'publish', ['expectedVersion' => 3, 'revisionId' => $saved['revisionId']], $data['csrfToken']); self::assertResponseIsSuccessful(); $client->request('GET', '/'); self::assertStringContainsString('An edited heading', $client->getResponse()->getContent()); self::assertStringNotContainsString('editor.js', $client->getResponse()->getContent()); self::assertFalse($client->getResponse()->headers->has('Set-Cookie')); } public function testLogoutInvalidatesSessionAndNeedsCsrf(): void { $client = $this->login(); $client->request('POST', '/admin/logout', ['_csrf_token' => 'wrong']); self::assertResponseStatusCodeSame(403); $crawler = $client->request('GET', '/admin'); $client->submit($crawler->selectButton('Abmelden')->form()); self::assertResponseRedirects('/admin/login'); $client->request('GET', '/admin'); self::assertResponseRedirects('http://localhost/admin/login'); } }