Jede Verwaltungsseite trug bisher ihre eigene Kopfzeile und ihre eigene, unvollständige Liste von Links auf die anderen Seiten - die Seitenübersicht verlinkte sieben Bereiche, das Menü fünf, die Fassungen drei. Wo man war, stand nirgends. Jetzt gibt es einen Rahmen für alle: eine feste Seitenleiste mit der Navigation, der aktive Eintrag über aria-current markiert, und eine Kopfzeile mit Titel, einem Satz Erklärung und Platz für die Hauptaktion. Jede Seite füllt nur noch heading, lead, actions und content. Auf schmalen Fenstern wird die Seitenleiste zu einer scrollbaren Leiste, ohne JavaScript. Die Seitenübersicht zeigt je Seite eine Zeile mit dem Alltäglichen und einem Aufklappfeld "Einstellungen", das darunter an seinem Platz öffnet - vorher waren es sechs Aufklappfelder, deren Inhalt als absolut positionierter Kasten über den Rand der Karte hinausragte. Tabellen scrollen seitlich, statt die Karte aus dem Fenster zu schieben. Das Stylesheet ist geteilt: die Editorregeln stehen in editor.css, das nur Editor und Mediathek laden; admin.css ist neu geschrieben und lesbar. Der Editor behält seinen Aufbau und übernimmt die neue Gestaltung - Systemschrift, gleiche Radien, gleiche Schaltflächen, und ein paar Panelgrößen, die zum Lesen zu klein waren. Beim Zerlegen des Stylesheets schnitt mein Skript am Ende jedes @media-Blocks alle schließenden Klammern ab statt nur einer. editor.css war damit ab dem ersten Medienblock ungültig: der Browser behielt 29 von 202 Regeln. Gefunden hat das der Browsertest, an einer Vorschaubreite von 1236 statt 1240 Pixeln - vier Pixel als einziges Anzeichen dafür, dass dreiviertel des Stylesheets fehlte. 201 PHP-Tests / 716 Assertions unter PHP 8.2.12 und 8.4.22, Playwright- Editortest mit axe ohne Verstöße auf Anmeldung, Seitenübersicht und Editor, öffentliche Browserprüfung grün. Zwei Testselektoren zeigten auf umbenannte Klassen und prüfen dieselbe Sache nun über den neuen Namen. Nicht geprüft: kein Screenreader, und die Bildschirme Menüs, Formulare, Bereiche, Design, Einstellungen und Fassungen sind nur von Hand bedient, nicht automatisiert geprüft. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
601 lines
33 KiB
PHP
601 lines
33 KiB
PHP
<?php
|
|
|
|
namespace App\Tests;
|
|
|
|
use App\Content\DemoContent;
|
|
use App\Content\DocumentValidator;
|
|
use App\Content\FormRepository;
|
|
use App\Content\MenuRepository;
|
|
use App\Content\PageRepository;
|
|
use App\Content\PartialRepository;
|
|
use App\Content\SiteSettings;
|
|
use App\Infrastructure\ConnectionFactory;
|
|
use App\Kernel;
|
|
use App\Security\UserProvider;
|
|
use Doctrine\DBAL\Connection;
|
|
use Doctrine\DBAL\Schema\Schema;
|
|
use DoctrineMigrations\Version20260912000100;
|
|
use DoctrineMigrations\Version20260912000200;
|
|
use DoctrineMigrations\Version20260912000300;
|
|
use DoctrineMigrations\Version20260912000400;
|
|
use DoctrineMigrations\Version20260912000500;
|
|
use DoctrineMigrations\Version20260912000600;
|
|
use DoctrineMigrations\Version20260912000700;
|
|
use DoctrineMigrations\Version20260912000800;
|
|
use DoctrineMigrations\Version20260912000900;
|
|
use DoctrineMigrations\Version20260912001000;
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
|
use Psr\Log\NullLogger;
|
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
|
|
|
final class EditorSecurityTest extends WebTestCase
|
|
{
|
|
private Connection $db;
|
|
private string $path;
|
|
private array $previous;
|
|
|
|
protected static function getKernelClass(): string
|
|
{
|
|
return Kernel::class;
|
|
}
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->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);
|
|
(new Version20260912000600($this->db, new NullLogger()))->up($schema);
|
|
(new Version20260912000700($this->db, new NullLogger()))->up($schema);
|
|
(new Version20260912000800($this->db, new NullLogger()))->up($schema);
|
|
(new Version20260912000900($this->db, new NullLogger()))->up($schema);
|
|
(new Version20260912001000($this->db, new NullLogger()))->up($schema);
|
|
foreach ($schema->toSql($this->db->getDatabasePlatform()) as $sql) {
|
|
$this->db->executeStatement($sql);
|
|
}
|
|
Version20260912000400::applyIndexes($this->db);
|
|
Version20260912000600::applyIndexes($this->db);
|
|
Version20260912000900::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(), new SiteSettings($this->db));
|
|
$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;
|
|
}
|
|
|
|
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'] 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 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, new SiteSettings($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']));
|
|
}
|
|
|
|
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));
|
|
$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<string, string> 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('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('<script', $client->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'));
|
|
self::assertCount(1, $crawler->filter('[role=alert]'));
|
|
// The entries survive the rejection.
|
|
self::assertSame('keine-adresse', $crawler->filter('#formblock-email')->attr('value'));
|
|
self::assertStringContainsString('Bitte um Rückruf.', $crawler->filter('#formblock-nachricht')->text());
|
|
}
|
|
|
|
/**
|
|
* @return iterable<string, array{callable(string): array}>
|
|
*/
|
|
public static function droppedSubmissions(): iterable
|
|
{
|
|
$sign = static fn (string $issued): string => hash_hmac('sha256', $issued, 'isolated-test-secret');
|
|
yield 'honeypot filled' => [static fn (array $body): array => [...$body, 'website' => 'https://spam.example']];
|
|
yield 'submitted too fast' => [static fn (array $body): array => [...$body, 'issued' => (string) time(), 'issuedSignature' => $sign((string) time())]];
|
|
yield 'timestamp forged' => [static fn (array $body): array => [...$body, 'issuedSignature' => 'nicht-signiert']];
|
|
yield 'timestamp far in the past' => [static function (array $body) use ($sign): array {
|
|
$old = (string) (time() - 200000);
|
|
|
|
return [...$body, 'issued' => $old, 'issuedSignature' => $sign($old)];
|
|
}];
|
|
}
|
|
|
|
#[DataProvider('droppedSubmissions')]
|
|
public function testSpamProtectionDropsSubmissionsQuietlyWithoutStoringThem(callable $tamper): void
|
|
{
|
|
$this->contactForm();
|
|
$this->pageWithForm();
|
|
$client = self::createClient();
|
|
|
|
$client->request('POST', '/formular/kontakt', $tamper($this->validBody((string) (time() - 10))));
|
|
// Answered like a success so an automated sender learns nothing from the response.
|
|
self::assertResponseStatusCodeSame(303);
|
|
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'));
|
|
}
|
|
|
|
public function testRateLimitStopsTheSixthSubmissionWithinAnHour(): void
|
|
{
|
|
$forms = $this->contactForm();
|
|
$this->pageWithForm();
|
|
$client = self::createClient();
|
|
|
|
for ($i = 0; $i < FormRepository::RATE_LIMIT; ++$i) {
|
|
$client->request('POST', '/formular/kontakt', $this->validBody((string) (time() - 10)));
|
|
self::assertResponseStatusCodeSame(303);
|
|
}
|
|
self::assertSame(FormRepository::RATE_LIMIT, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'));
|
|
|
|
$client->request('POST', '/formular/kontakt', $this->validBody((string) (time() - 10)));
|
|
self::assertResponseStatusCodeSame(303);
|
|
self::assertSame(FormRepository::RATE_LIMIT, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'));
|
|
self::assertTrue($forms->rateLimited(hash_hmac('sha256', '127.0.0.1', 'isolated-test-secret')));
|
|
}
|
|
|
|
public function testFormAdministrationNeedsAnEditorRoleAndACsrfToken(): void
|
|
{
|
|
$client = $this->login('editor');
|
|
$crawler = $client->request('GET', '/admin/forms');
|
|
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 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, '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>');
|
|
$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, '<?php SECRET_SOURCE_PAYLOAD ?>', 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');
|
|
}
|
|
}
|