Symfony 7.4 / Twig / Doctrine DBAL auf herkömmlichem PHP-Webhosting. Versionierte Blockdokumente (v1-v3) mit unveränderlichen Revisionen, getrennten Entwürfen/Veröffentlichungen und optimistischer Konflikterkennung. Visueller Editor mit Strukturbaum, Live-Vorschau, responsiven Gerätewerten, Abschnittsvorlagen, Bildmediathek, Galerien und Effekten. Öffentliche Ausgabe ohne Editor-JavaScript. Geprüft: 41 PHP-Tests / 155 Assertions, 10 JavaScript-Modelltests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
260 lines
13 KiB
PHP
260 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Tests;
|
|
|
|
use App\Content\DemoContent;
|
|
use App\Content\DocumentValidator;
|
|
use App\Content\PageRepository;
|
|
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 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);
|
|
foreach ($schema->toSql($this->db->getDatabasePlatform()) as $sql) {
|
|
$this->db->executeStatement($sql);
|
|
}
|
|
$this->db->insert('website', ['id' => 'default', 'name' => 'Test', 'default_locale' => 'de', 'theme' => '{}']);
|
|
$this->db->insert('page', ['id' => 'p1', 'slug' => 'home', 'locale' => 'de', 'website_id' => 'default', 'version' => 0]);
|
|
$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'] 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');
|
|
}
|
|
}
|
|
|
|
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');
|
|
}
|
|
}
|