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); $schema = new Schema(); (new Version20260912000100($this->db, new NullLogger()))->up($schema); (new Version20260912000200($this->db, new NullLogger()))->up($schema); (new Version20260912000300($this->db, new NullLogger()))->up($schema); (new Version20260912000400($this->db, new NullLogger()))->up($schema); (new Version20260912000500($this->db, new NullLogger()))->up($schema); foreach ($schema->toSql($this->db->getDatabasePlatform()) as $sql) { $this->db->executeStatement($sql); } Version20260912000400::applyIndexes($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()); $revision = $pages->saveDraft('p1', DemoContent::pages()['home'], 0); $pages->publish('p1', $revision, 1); 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; } 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'] 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]]] 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 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"] 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); $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'])); } 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']; self::assertSame([480, 960, 1600], 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 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'); } }