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', '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; } private function login(string $username = 'admin'): KernelBrowser { $client = self::createClient(); $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 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 { $client = $this->login('editor'); $client->request('GET', '/admin/settings'); self::assertSelectorNotExists('#not-found-form'); $client->request('POST', '/admin/settings/not-found', ['action' => 'create']); self::assertResponseStatusCodeSame(403); self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page')); } 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 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('editor'); $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'])); } public function testMenuWritesNeedAnEditorRoleACsrfTokenAndTheCurrentVersion(): 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('editor'); $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'])); } private function contactForm(): 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.'], 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')); $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 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('