Kant CMS: Grundgerüst, visueller Editor, Mediathek
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>
This commit is contained in:
commit
82b07820bc
87 files changed
+14463
No files matched your search
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\PageRepository;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\StaleWrite;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Kernel;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use DoctrineMigrations\Version20260912000100;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class CmsTest extends TestCase
|
||||
{
|
||||
private Connection $db;
|
||||
private PageRepository $pages;
|
||||
private string $path;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->path = tempnam(sys_get_temp_dir(), 'kant-test-');
|
||||
$this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->path));
|
||||
$schema = new Schema();
|
||||
(new Version20260912000100($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' => 'Kant CMS', 'default_locale' => 'de', 'theme' => '{}']);
|
||||
foreach (['page-1' => 'home', 'page-2' => 'private'] as $id => $slug) {
|
||||
$this->db->insert('page', ['id' => $id, 'slug' => $slug, 'locale' => 'de', 'website_id' => 'default', 'version' => 0]);
|
||||
}
|
||||
$this->pages = new PageRepository($this->db, new DocumentValidator());
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->db->close();
|
||||
gc_collect_cycles();
|
||||
unlink($this->path);
|
||||
}
|
||||
|
||||
public function testDocumentRoundTripAndDraftPrivacy(): void
|
||||
{
|
||||
$document = DemoContent::pages()['home'];
|
||||
$id = $this->pages->saveDraft('page-1', $document, 0);
|
||||
self::assertNull($this->pages->findPublished('home'));
|
||||
self::assertSame($document, json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$id]), true));
|
||||
$this->pages->publish('page-1', $id, 1);
|
||||
self::assertSame($document, $this->pages->findPublished('home')['document']);
|
||||
$document['title'] = 'Private change';
|
||||
$this->pages->saveDraft('page-1', $document, 2);
|
||||
self::assertNotSame('Private change', $this->pages->findPublished('home')['document']['title']);
|
||||
}
|
||||
|
||||
public function testStaleSaveRollsBackWithoutOrphanRevision(): void
|
||||
{
|
||||
$this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0);
|
||||
try {
|
||||
$this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0);
|
||||
self::fail('Stale write was accepted.');
|
||||
} catch (StaleWrite) {
|
||||
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision'));
|
||||
self::assertSame(1, (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['page-1']));
|
||||
}
|
||||
}
|
||||
|
||||
public function testForeignRevisionCannotBePublished(): void
|
||||
{
|
||||
$id = $this->pages->saveDraft('page-2', DemoContent::pages()['home'], 0);
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->pages->publish('page-1', $id, 0);
|
||||
}
|
||||
|
||||
public function testPublicationRejectsStaleVersion(): void
|
||||
{
|
||||
$id = $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0);
|
||||
$this->expectException(StaleWrite::class);
|
||||
$this->pages->publish('page-1', $id, 0);
|
||||
}
|
||||
|
||||
public function testPublishedRevisionCanBeRestoredAndImmediatelyChangesOutput(): void
|
||||
{
|
||||
$original = DemoContent::pages()['home'];
|
||||
$first = $this->pages->saveDraft('page-1', $original, 0);
|
||||
$this->pages->publish('page-1', $first, 1);
|
||||
$changed = $original;
|
||||
$changed['title'] = 'Second revision';
|
||||
$second = $this->pages->saveDraft('page-1', $changed, 2);
|
||||
$this->pages->publish('page-1', $second, 3);
|
||||
self::assertSame('Second revision', $this->pages->findPublished('home')['document']['title']);
|
||||
$this->pages->publish('page-1', $first, 4);
|
||||
self::assertSame($original, $this->pages->findPublished('home')['document']);
|
||||
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision'));
|
||||
}
|
||||
|
||||
public static function invalidDocuments(): iterable
|
||||
{
|
||||
$base = DemoContent::pages()['home'];
|
||||
$bad = $base;
|
||||
$bad['schemaVersion'] = 999;
|
||||
yield 'future schema' => [$bad];
|
||||
$bad = $base;
|
||||
$bad['blocks'][] = $base['blocks'][0];
|
||||
yield 'duplicate IDs' => [$bad];
|
||||
foreach (['javascript:alert(1)', 'data:text/html,hi', '//evil.test', '/\\evil.test', "https://example.test/\nfoo"] as $url) {
|
||||
$bad = $base;
|
||||
$bad['blocks'][0]['children'][3]['props']['href'] = $url;
|
||||
yield $url => [$bad];
|
||||
}
|
||||
$bad = $base;
|
||||
$bad['blocks'][0]['layout']['tone'] = '" onclick="alert(1)';
|
||||
yield 'style injection' => [$bad];
|
||||
foreach ([['desktop' => ['paddingY' => 'url(evil)']], ['watch' => []], ['mobile' => ['columns' => 'two']], ['desktop' => ['unknown' => 'small']], ['mobile' => ['paddingY' => []]]] as $design) {
|
||||
$bad = $base;
|
||||
$bad['schemaVersion'] = 2;
|
||||
$bad['blocks'][0]['layout']['design'] = $design;
|
||||
yield 'invalid responsive design '.json_encode($design) => [$bad];
|
||||
}
|
||||
$bad = $base;
|
||||
$bad['blocks'][0]['layout']['design'] = ['desktop' => ['paddingY' => 'large']];
|
||||
yield 'v1 cannot silently use v2 settings' => [$bad];
|
||||
foreach ([2, 3] as $version) {
|
||||
$bad = $base;
|
||||
$bad['schemaVersion'] = $version;
|
||||
$bad['blocks'][0]['layout']['effects'] = ['shadow' => 2 === $version ? 'soft' : 'evil'];
|
||||
yield 'invalid effect version '.$version => [$bad];
|
||||
}
|
||||
$bad = $base;
|
||||
$bad['schemaVersion'] = 3;
|
||||
$bad['blocks'][] = ['id' => 'gallery', 'type' => 'gallery', 'children' => [$base['blocks'][0]]];
|
||||
yield 'gallery forbids containers' => [$bad];
|
||||
$bad = $base;
|
||||
$bad['blocks'][] = ['id' => 'gallery', 'type' => 'gallery', 'children' => []];
|
||||
yield 'gallery requires v3' => [$bad];
|
||||
foreach (['javascript:alert(1)', '/image,400w'] as $source) {
|
||||
$bad = $base;
|
||||
$bad['schemaVersion'] = 3;
|
||||
$bad['blocks'][] = ['id' => 'unsafe-image', 'type' => 'image', 'props' => ['src' => '/safe.png', 'alt' => 'Test', 'decorative' => false, 'width' => 400, 'height' => 300, 'sources' => [['src' => $source, 'width' => 400]]]];
|
||||
yield 'unsafe srcset '.$source => [$bad];
|
||||
}
|
||||
$bad = $base;
|
||||
$bad['blocks'][0]['children'][1]['props']['level'] = 9;
|
||||
yield 'invalid heading' => [$bad];
|
||||
$bad = $base;
|
||||
$bad['blocks'][0]['children'][0]['type'] = 'unknown-plugin';
|
||||
yield 'missing plugin cannot be silently resaved' => [$bad];
|
||||
$bad = $base;
|
||||
$bad['blocks'][0]['children'][] = ['id' => 'image', 'type' => 'image', 'props' => ['src' => '/image.png', 'alt' => '', 'decorative' => false, 'width' => 100, 'height' => 100]];
|
||||
yield 'missing meaningful alt' => [$bad];
|
||||
}
|
||||
|
||||
#[DataProvider('invalidDocuments')]
|
||||
public function testRejectsInvalidDocumentsBeforeWriting(array $document): void
|
||||
{
|
||||
$this->expectException(InvalidDocument::class);
|
||||
$this->pages->saveDraft('page-1', $document, 0);
|
||||
}
|
||||
|
||||
public function testInitialHtmlEscapesContentAndHasNoEditorScripts(): void
|
||||
{
|
||||
$document = DemoContent::pages()['home'];
|
||||
$document['blocks'][0]['children'][0]['props']['text'] = '<script>alert("xss")</script>';
|
||||
$id = $this->pages->saveDraft('page-1', $document, 0);
|
||||
$this->pages->publish('page-1', $id, 1);
|
||||
$response = $this->request('/');
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
self::assertStringContainsString('<script>', $response->getContent());
|
||||
self::assertStringNotContainsString('<script', $response->getContent());
|
||||
self::assertStringContainsString('<h1', $response->getContent());
|
||||
self::assertStringContainsString('rel="canonical" href="https://cms.test/"', $response->getContent());
|
||||
self::assertStringContainsString('no-store', $response->headers->get('Cache-Control'));
|
||||
}
|
||||
|
||||
public function testResponsiveRevisionPreservesOriginalAndRendersSafeClasses(): void
|
||||
{
|
||||
$original = DemoContent::pages()['home'];
|
||||
$old = $this->pages->saveDraft('page-1', $original, 0);
|
||||
$this->pages->publish('page-1', $old, 1);
|
||||
$document = $original;
|
||||
$document['schemaVersion'] = 2;
|
||||
$document['blocks'][0]['layout']['design'] = ['desktop' => ['paddingY' => 'huge'], 'mobile' => ['paddingY' => 'small']];
|
||||
$document['blocks'][0]['children'][1]['layout']['design'] = ['desktop' => ['fontSize' => 'hero'], 'mobile' => ['fontSize' => 'title']];
|
||||
$new = $this->pages->saveDraft('page-1', $document, 2);
|
||||
self::assertSame($original, $this->pages->findPublished('home')['document']);
|
||||
self::assertSame($document, $this->pages->findForEditor('page-1')['document']);
|
||||
$this->pages->publish('page-1', $new, 3);
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertStringContainsString('d-mobile-paddingY-small', $html);
|
||||
self::assertStringContainsString('d-desktop-fontSize-hero d-mobile-fontSize-title', $html);
|
||||
self::assertStringContainsString('/assets/design.css', $html);
|
||||
self::assertStringNotContainsString('style=', $html);
|
||||
self::assertStringNotContainsString('<script', $html);
|
||||
$this->pages->publish('page-1', $old, 4);
|
||||
self::assertSame($original, $this->pages->findPublished('home')['document']);
|
||||
self::assertStringNotContainsString('/assets/design.css', $this->request('/')->getContent());
|
||||
}
|
||||
|
||||
public function testDraftAndUnknownUrlsAre404AndWritesAreNotExposed(): void
|
||||
{
|
||||
$this->pages->saveDraft('page-2', DemoContent::pages()['home'], 0);
|
||||
foreach (['/private', '/missing', '/editor'] as $url) {
|
||||
self::assertSame(404, $this->request($url)->getStatusCode());
|
||||
}
|
||||
self::assertSame(405, $this->request('/', 'POST')->getStatusCode());
|
||||
}
|
||||
|
||||
public function testHomeAliasRedirectsToCanonical(): void
|
||||
{
|
||||
$response = $this->request('/home');
|
||||
self::assertSame(301, $response->getStatusCode());
|
||||
self::assertSame('/', $response->headers->get('Location'));
|
||||
}
|
||||
|
||||
private function request(string $url, string $method = 'GET'): Response
|
||||
{
|
||||
$previous = [];
|
||||
foreach (['APP_SECRET' => 'test-only', 'DATABASE_URL' => 'sqlite:///'.str_replace('\\', '/', $this->path), 'SITE_ORIGIN' => 'https://cms.test'] as $key => $value) {
|
||||
$previous[$key] = [$_SERVER[$key] ?? null, $_ENV[$key] ?? null];
|
||||
$_SERVER[$key] = $_ENV[$key] = $value;
|
||||
}
|
||||
$kernel = new Kernel('test', false);
|
||||
try {
|
||||
return $kernel->handle(Request::create($url, $method));
|
||||
} finally {
|
||||
$kernel->shutdown();
|
||||
foreach ($previous as $key => [$server, $env]) {
|
||||
if (null === $server) {
|
||||
unset($_SERVER[$key]);
|
||||
} else {
|
||||
$_SERVER[$key] = $server;
|
||||
}
|
||||
if (null === $env) {
|
||||
unset($_ENV[$key]);
|
||||
} else {
|
||||
$_ENV[$key] = $env;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
// Isolated test data only. Never touches the configured application database.
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\PageRepository;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use DoctrineMigrations\Version20260912000100;
|
||||
use DoctrineMigrations\Version20260912000200;
|
||||
use DoctrineMigrations\Version20260912000300;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
$directory = dirname(__DIR__).'/var/browser-tests';
|
||||
if (!is_dir($directory)) {
|
||||
mkdir($directory, 0775, true);
|
||||
}
|
||||
$path = $directory.'/'.bin2hex(random_bytes(12)).'.db';
|
||||
$url = 'sqlite:///'.str_replace('\\', '/', $path);
|
||||
$db = ConnectionFactory::create($url);
|
||||
$schema = new Schema();
|
||||
(new Version20260912000100($db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000200($db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000300($db, new NullLogger()))->up($schema);
|
||||
foreach ($schema->toSql($db->getDatabasePlatform()) as $sql) {
|
||||
$db->executeStatement($sql);
|
||||
}
|
||||
$db->insert('website', ['id' => 'default', 'name' => 'Browser test', 'default_locale' => 'de', 'theme' => '{}']);
|
||||
$pages = new PageRepository($db, new DocumentValidator());
|
||||
foreach (DemoContent::pages() as $slug => $document) {
|
||||
$id = 'test-'.$slug;
|
||||
$db->insert('page', ['id' => $id, 'slug' => $slug, 'locale' => 'de', 'website_id' => 'default', 'version' => 0]);
|
||||
$revision = $pages->saveDraft($id, $document, 0);
|
||||
$pages->publish($id, $revision, 1);
|
||||
}
|
||||
$password = bin2hex(random_bytes(20));
|
||||
$username = 'browser-'.bin2hex(random_bytes(6));
|
||||
$db->insert('cms_user', ['id' => $username, 'username' => $username, 'password_hash' => password_hash($password, PASSWORD_BCRYPT, ['cost' => 4]), 'roles' => '["ROLE_ADMIN"]']);
|
||||
$images = [];
|
||||
foreach (['Berge', 'Küste'] as $index => $title) {
|
||||
$image = imagecreatetruecolor(1800, 1200);
|
||||
for ($y = 0; $y < 1200; ++$y) {
|
||||
$color = imagecolorallocate($image, 110 + (int) ($y / 18), 150 + (int) ($y / 30), 155 + (int) ($y / 28));
|
||||
imageline($image, 0, $y, 1799, $y, $color);
|
||||
}
|
||||
imagefilledellipse($image, 1300, 270, 210, 210, imagecolorallocate($image, 245, 223, 169));
|
||||
imagefilledpolygon($image, [0, 1000, 420, 350 + $index * 150, 950, 980, 1400, 550, 1800, 1100, 1800, 1200, 0, 1200], imagecolorallocate($image, 42, 83 + $index * 20, 75));
|
||||
$imagePath = $path.'-'.$title.'.png';
|
||||
imagepng($image, $imagePath);
|
||||
imagedestroy($image);
|
||||
$images[] = $imagePath;
|
||||
}
|
||||
echo json_encode(['databaseUrl' => $url, 'mediaDirectory' => $path.'-media', 'images' => $images, 'username' => $username, 'password' => $password]);
|
||||
@@ -0,0 +1,123 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EditorHistory, createBlock, locate, insertBlock, duplicateBlock, moveBlock, upgradeDocument, effectiveDesign, setDesign, insertPreset, blockPath, insertMedia, setEffect } from '../public/assets/editor-model.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { designCss } from '../scripts/build-design.mjs';
|
||||
|
||||
function fixture() { return { schemaVersion: 1, title: 'Page', description: 'Description', blocks: [{ id: 's1', type: 'section', children: [{ id: 'h1', type: 'heading', props: { text: 'Hello', level: 1 } }, { id: 't1', type: 'text', props: { text: 'Body' } }] }, { id: 's2', type: 'section', children: [] }] }; }
|
||||
|
||||
test('typing coalesces into one undo and redo; saved state tracks content, not stack length', () => {
|
||||
const history = new EditorHistory(fixture());
|
||||
history.change(doc => doc.title = 'New', 'title');
|
||||
history.change(doc => doc.title = 'New title', 'title');
|
||||
assert.equal(history.past.length, 1);
|
||||
history.undo(); assert.equal(history.document.title, 'Page'); assert.equal(history.dirty, false);
|
||||
history.redo(); assert.equal(history.document.title, 'New title'); assert.equal(history.dirty, true);
|
||||
history.markSaved(history.document); assert.equal(history.dirty, false);
|
||||
history.undo(); assert.equal(history.dirty, true);
|
||||
history.redo(); assert.equal(history.dirty, false);
|
||||
});
|
||||
|
||||
test('new edit after undo drops redo and source snapshots remain immutable', () => {
|
||||
const original = fixture(); const history = new EditorHistory(original);
|
||||
history.change(doc => locate(doc.blocks, 't1').block.props.text = 'changed');
|
||||
history.undo(); history.change(doc => doc.title = 'Branch');
|
||||
assert.equal(history.future.length, 0); assert.equal(original.title, 'Page');
|
||||
assert.equal(locate(original.blocks, 't1').block.props.text, 'Body');
|
||||
});
|
||||
|
||||
test('add into selected container, add after leaf, and duplicate assigns fresh IDs recursively', () => {
|
||||
const document = fixture();
|
||||
const id = insertBlock(document, 'text', 's2'); assert.equal(locate(document.blocks, id).parent.id, 's2');
|
||||
const after = insertBlock(document, 'button', 't1'); assert.equal(document.blocks[0].children[2].id, after);
|
||||
const copy = duplicateBlock(document, 's1');
|
||||
const copied = locate(document.blocks, copy).block;
|
||||
assert.notEqual(copied.children[0].id, 'h1'); assert.equal(copied.children[0].props.level, 2);
|
||||
assert.equal(document.blocks[0].children[0].props.level, 1);
|
||||
});
|
||||
|
||||
test('moving prevents self/descendant cycles and supports cross-container moves', () => {
|
||||
const document = fixture();
|
||||
assert.equal(moveBlock(document, 's1', 'h1', true), false);
|
||||
assert.equal(moveBlock(document, 's1', 's1', true), false);
|
||||
assert.equal(moveBlock(document, 'h1', 't1', true), false);
|
||||
assert.equal(moveBlock(document, 't1', 's2', true), true);
|
||||
assert.equal(locate(document.blocks, 't1').parent.id, 's2');
|
||||
assert.equal(moveBlock(document, 's2', 's1'), true);
|
||||
assert.equal(document.blocks[0].id, 's2');
|
||||
});
|
||||
|
||||
test('history persists exact block document after add, move, delete, undo and JSON reload', () => {
|
||||
const history = new EditorHistory(fixture());
|
||||
const id = history.change(doc => insertBlock(doc, 'image', 's2'));
|
||||
history.change(doc => moveBlock(doc, id, 't1'));
|
||||
const saved = structuredClone(history.document);
|
||||
history.change(doc => { const target = locate(doc.blocks, id); target.siblings.splice(target.index, 1); });
|
||||
history.undo(); assert.deepEqual(history.document, saved);
|
||||
const loaded = new EditorHistory(JSON.parse(JSON.stringify(history.document)));
|
||||
assert.deepEqual(loaded.document, saved); assert.equal(loaded.dirty, false);
|
||||
assert.throws(() => createBlock('unknown'));
|
||||
});
|
||||
|
||||
test('v1 to v2 migration preserves all content and IDs, is idempotent and rejects future versions', () => {
|
||||
const original = fixture(); const migrated = upgradeDocument(structuredClone(original));
|
||||
assert.deepEqual(migrated, { ...original, schemaVersion: 2 });
|
||||
assert.deepEqual(upgradeDocument(structuredClone(migrated)), migrated);
|
||||
assert.throws(() => upgradeDocument({ ...original, schemaVersion: 999 }));
|
||||
assert.equal(original.schemaVersion, 1);
|
||||
});
|
||||
|
||||
test('responsive inheritance, reset, migration undo, and reload preserve distinct device values', () => {
|
||||
const history = new EditorHistory(fixture());
|
||||
history.change(doc => setDesign(doc, 'h1', 'desktop', 'fontSize', 'hero'));
|
||||
assert.equal(history.document.schemaVersion, 2);
|
||||
const heading = () => locate(history.document.blocks, 'h1').block;
|
||||
assert.deepEqual(effectiveDesign(heading(), 'mobile', 'fontSize'), { value: 'hero', source: 'desktop' });
|
||||
history.undo(); assert.deepEqual(history.document, fixture()); history.redo();
|
||||
history.change(doc => setDesign(doc, 'h1', 'tablet', 'fontSize', 'display'));
|
||||
history.change(doc => setDesign(doc, 'h1', 'mobile', 'fontSize', 'title'));
|
||||
assert.equal(effectiveDesign(heading(), 'desktop', 'fontSize').value, 'hero');
|
||||
assert.equal(effectiveDesign(heading(), 'mobile', 'fontSize').value, 'title');
|
||||
history.change(doc => setDesign(doc, 'h1', 'mobile', 'fontSize', ''));
|
||||
assert.deepEqual(effectiveDesign(heading(), 'mobile', 'fontSize'), { value: 'display', source: 'tablet' });
|
||||
history.undo(); assert.equal(effectiveDesign(heading(), 'mobile', 'fontSize').value, 'title');
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(history.document)), history.document);
|
||||
});
|
||||
|
||||
test('presets insert after containing section, keep a single H1, unique IDs, and undo atomically', () => {
|
||||
const history = new EditorHistory(fixture());
|
||||
for (const name of ['intro', 'columns', 'cta']) {
|
||||
const id = history.change(doc => insertPreset(doc, name, 'h1'));
|
||||
assert.equal(history.document.blocks[1].id, id);
|
||||
assert.deepEqual(blockPath(history.document.blocks, 'h1').map(block => block.id), ['s1', 'h1']);
|
||||
}
|
||||
const nodes = [];
|
||||
const visit = blocks => blocks.forEach(block => { nodes.push(block); visit(block.children || []); });
|
||||
visit(history.document.blocks);
|
||||
assert.equal(new Set(nodes.map(block => block.id)).size, nodes.length);
|
||||
assert.equal(nodes.filter(block => block.type === 'heading' && block.props.level === 1).length, 1);
|
||||
history.undo(); history.undo(); history.undo(); assert.deepEqual(history.document, fixture());
|
||||
});
|
||||
|
||||
test('committed CSS matches the shared validated token manifest', async () => {
|
||||
const tokens = JSON.parse(await readFile(new URL('../public/assets/design-tokens.json', import.meta.url), 'utf8'));
|
||||
assert.equal(await readFile(new URL('../public/assets/design.css', import.meta.url), 'utf8'), designCss(tokens));
|
||||
});
|
||||
|
||||
test('v3 media and effects keep originals, gallery restrictions, and atomic undo', () => {
|
||||
const history = new EditorHistory(fixture());
|
||||
const media = { id: 'a'.repeat(32), alt: 'Landscape', variants: [{src: '/media/a/480.webp', width: 480, height: 320}] };
|
||||
const gallery = history.change(doc => insertMedia(doc, 's2', [media, media]));
|
||||
assert.equal(history.document.schemaVersion, 3);
|
||||
const children = locate(history.document.blocks, gallery).block.children;
|
||||
assert.equal(children.length, 2); assert.notEqual(children[0].id, children[1].id);
|
||||
assert.equal(moveBlock(history.document, 'h1', gallery, true), false);
|
||||
assert.equal(moveBlock(history.document, 'h1', children[0].id), false);
|
||||
const text = history.change(doc => insertBlock(doc, 'text', children[0].id));
|
||||
assert.notEqual(locate(history.document.blocks, text).parent?.id, gallery);
|
||||
history.undo();
|
||||
history.change(doc => setEffect(doc, gallery, 'shadow', 'soft'));
|
||||
history.change(doc => setDesign(doc, gallery, 'mobile', 'columns', 'one'));
|
||||
assert.equal(history.document.schemaVersion, 3);
|
||||
history.undo(); history.undo(); history.undo(); assert.deepEqual(history.document, fixture());
|
||||
});
|
||||
Reference in new issue
Block a user