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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user