- The installer only opens with a per-installation key that install.php creates and shows; the key is deleted with the first account. - Lock files keep .env, the database, backups and logs out of reach when a domain points at the project folder instead of public/. - Linked sections render by their own schema version, so an old section cannot carry unfiltered HTML into a newer page. - Editors no longer move, trash or change areas and post data of published pages; a scheduled publication goes live with the revision that was scheduled (migration 20261012000100). - Form stamps are bound to their form, IPv6 senders count per /64, and a flood per form or overall stops storing with a visible message. - The consent redirect rejects control characters, update targets must look like 1.2.3, and own CSS cannot reach outside its block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
309 lines
18 KiB
PHP
309 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Tests;
|
|
|
|
use App\Content\DemoContent;
|
|
use App\Content\DocumentValidator;
|
|
use App\Content\FormRepository;
|
|
use App\Content\MediaLibrary;
|
|
use App\Content\PageRepository;
|
|
use App\Content\PartialRepository;
|
|
use App\Content\PostRepository;
|
|
use App\Content\SiteSettings;
|
|
use App\Content\Theme;
|
|
use App\Domain\InvalidDocument;
|
|
use App\Infrastructure\ConnectionFactory;
|
|
use App\Kernel;
|
|
use App\Ops\ArchiveUpdate;
|
|
use App\Ops\CommandRunner;
|
|
use App\Ops\Updater;
|
|
use Doctrine\DBAL\Connection;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
/**
|
|
* Die Rückstände aus der Gesamtprüfung (ADR 100), am 16.09.2026 abgearbeitet (ADR 104): Archivseiten
|
|
* durch die Aufbaukette der Seiten, Formulare und Beitragslisten in Bereichen, Kopien mehrfach
|
|
* verknüpfter Sektionen, Canonical und Blätterverweise, Palette bis in Fehlerfarben und Farbschema,
|
|
* leere Bausteine direkt nach dem Einfügen, eine Sperre für Updates und eine .htaccess, die ihre
|
|
* eingeschalteten Zeilen behält.
|
|
*/
|
|
final class BacklogTest extends TestCase
|
|
{
|
|
private string $path;
|
|
private Connection $db;
|
|
private PageRepository $pages;
|
|
private PartialRepository $partials;
|
|
private PostRepository $posts;
|
|
private SiteSettings $site;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->path = tempnam(sys_get_temp_dir(), 'kant-backlog-');
|
|
$this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->path));
|
|
TestSchema::build($this->db);
|
|
$this->db->insert('website', ['id' => 'default', 'name' => 'KantSites', 'default_locale' => 'de', 'theme' => '{}']);
|
|
$this->db->insert('page', ['id' => 'page-1', 'slug' => 'home', 'path' => 'home', 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]);
|
|
$media = new MediaLibrary($this->db, $this->path.'-media');
|
|
$this->site = new SiteSettings($this->db, $media);
|
|
$this->pages = new PageRepository($this->db, new DocumentValidator(), $this->site);
|
|
$this->partials = new PartialRepository($this->db, new DocumentValidator());
|
|
$this->posts = new PostRepository($this->db, $media, $this->site);
|
|
foreach (DemoContent::areas() as $kind => $area) {
|
|
$this->partials->create($kind, 'default', $area['name'], $area['document']);
|
|
}
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
$this->db->close();
|
|
gc_collect_cycles();
|
|
@unlink($this->path);
|
|
}
|
|
|
|
private function publishHome(array $document): void
|
|
{
|
|
$document['schemaVersion'] = 23;
|
|
$version = (int) ($this->pages->findForEditor('page-1')['version'] ?? 0);
|
|
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, $version), $version + 1);
|
|
}
|
|
|
|
private function post(string $slug, string $title, string $date, array $categories = []): string
|
|
{
|
|
$id = $this->pages->createPage($slug, null, 'post', DemoContent::starter($title, 'post'));
|
|
$this->posts->saveMetadata($id, ['publishedAt' => $date], $categories, [], $this->pages->findForEditor($id)['version']);
|
|
$seite = $this->pages->findForEditor($id);
|
|
$this->pages->publish($id, $seite['draft_revision_id'], $seite['version']);
|
|
|
|
return $id;
|
|
}
|
|
|
|
public function testArchivesRunThroughThePageChainWithFooterSectionsAndPagination(): void
|
|
{
|
|
for ($i = 1; $i <= 12; ++$i) {
|
|
$this->post('beitrag-'.$i, 'Beitrag '.$i, sprintf('2026-01-%02d', $i), ['Projekte']);
|
|
}
|
|
// Der Fuß verknüpft eine Sektion mit eigenem CSS - das Archiv muss beides zeigen.
|
|
$sektion = DemoContent::starter('Fußsektion');
|
|
$sektion['schemaVersion'] = 23;
|
|
$sektion['blocks'] = [['id' => 'fuss-hinweis', 'type' => 'text', 'props' => ['text' => 'Aus der Fußsektion'], 'css' => '& p{letter-spacing:0.3em}']];
|
|
$this->partials->create('section', 'fusssektion', 'Fußsektion', $sektion);
|
|
$footer = $this->partials->findByKind('footer');
|
|
$doc = $footer['document'];
|
|
$doc['schemaVersion'] = 23;
|
|
$doc['blocks'][] = ['id' => 'fuss-ref', 'type' => 'partialref', 'props' => ['partial' => 'fusssektion']];
|
|
$this->partials->save($footer['id'], $doc, $footer['version']);
|
|
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'scrollTop' => '1', 'scrollTopLabel' => 'Nach oben']);
|
|
|
|
$antwort = $this->request('/beitraege');
|
|
$html = $antwort->getContent();
|
|
self::assertSame(200, $antwort->getStatusCode());
|
|
self::assertStringContainsString('<h1', $html);
|
|
self::assertStringContainsString('Beiträge', $html);
|
|
self::assertStringContainsString('Aus der Fußsektion', $html, 'Der Fuß samt verknüpfter Sektion steht unter dem Archiv.');
|
|
self::assertStringContainsString('#fuss-hinweis p{letter-spacing:0.3em}', $html, 'Eigenes CSS der Sektion kommt mit.');
|
|
self::assertStringContainsString('scroll-top', $html, 'Der Nach-oben-Knopf ist da wie auf jeder Seite.');
|
|
self::assertStringContainsString('<link rel="canonical" href="https://cms.test/beitraege">', $html);
|
|
self::assertStringContainsString('<link rel="next" href="https://cms.test/beitraege?seite=2">', $html);
|
|
self::assertStringNotContainsString('rel="prev"', $html);
|
|
self::assertStringContainsString('12 Beiträge', $html);
|
|
|
|
$zwei = $this->request('/beitraege?seite=2')->getContent();
|
|
self::assertStringContainsString('<link rel="canonical" href="https://cms.test/beitraege?seite=2">', $zwei);
|
|
self::assertStringContainsString('<link rel="prev" href="https://cms.test/beitraege">', $zwei);
|
|
self::assertStringNotContainsString('rel="next"', $zwei);
|
|
self::assertStringContainsString('Beitrag 1<', $zwei, 'Die ältesten Beiträge stehen auf der zweiten Seite.');
|
|
|
|
$kategorie = $this->request('/kategorie/projekte')->getContent();
|
|
self::assertStringContainsString('Kategorie', $kategorie);
|
|
self::assertStringContainsString('<link rel="canonical" href="https://cms.test/kategorie/projekte">', $kategorie);
|
|
self::assertStringContainsString('BreadcrumbList', $kategorie, 'Die Spur Beiträge → Kategorie steht in den strukturierten Daten.');
|
|
self::assertSame(404, $this->request('/kategorie/leer')->getStatusCode());
|
|
}
|
|
|
|
public function testAPaginatedPostListOnAPageGetsCanonicalAndPrevNext(): void
|
|
{
|
|
for ($i = 1; $i <= 5; ++$i) {
|
|
$this->post('n-'.$i, 'Notiz '.$i, sprintf('2026-02-%02d', $i));
|
|
}
|
|
$document = DemoContent::starter('Neues');
|
|
$document['blocks'][] = ['id' => 'liste', 'type' => 'postlist', 'props' => ['count' => 2, 'category' => '', 'tag' => '', 'layout' => 'liste', 'paginate' => true]];
|
|
$this->publishHome($document);
|
|
$eins = $this->request('/')->getContent();
|
|
self::assertStringContainsString('<link rel="canonical" href="https://cms.test/">', $eins);
|
|
self::assertStringContainsString('<link rel="next" href="https://cms.test/?seite=2">', $eins);
|
|
$drei = $this->request('/?seite=3')->getContent();
|
|
self::assertStringContainsString('<link rel="canonical" href="https://cms.test/?seite=3">', $drei);
|
|
self::assertStringContainsString('<link rel="prev" href="https://cms.test/?seite=2">', $drei);
|
|
self::assertStringNotContainsString('rel="next"', $drei);
|
|
}
|
|
|
|
public function testAFormInTheFooterWorksLikeOneOnThePage(): void
|
|
{
|
|
(new FormRepository($this->db))->save('kontakt', 'Kontakt', [
|
|
['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true],
|
|
['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Einverstanden'],
|
|
], ['recipient' => 'info@example.org', 'buttonColor' => '#123456'], 0);
|
|
$footer = $this->partials->findByKind('footer');
|
|
$doc = $footer['document'];
|
|
$doc['schemaVersion'] = 23;
|
|
$doc['blocks'][] = ['id' => 'fuss-formular', 'type' => 'form', 'props' => ['form' => 'kontakt']];
|
|
$this->partials->save($footer['id'], $doc, $footer['version']);
|
|
$this->publishHome(DemoContent::starter('Start'));
|
|
|
|
$html = $this->request('/')->getContent();
|
|
self::assertStringContainsString('<form class="block-form" data-form="kontakt"', $html, 'Das Formular im Fuß wird gerendert, nicht als „nicht vorhanden“ abgetan.');
|
|
self::assertStringContainsString('.block-form[data-form="kontakt"]{', $html, 'Seine Farben stehen im Stylesheet der Seite.');
|
|
|
|
// Abgeschickt ohne E-Mail: die Seite kommt mit Meldung zurück, am Formular im Fuß.
|
|
$issued = time() - 10;
|
|
$antwort = $this->request('/formular/kontakt', 'POST', ['page' => 'home', 'block' => 'fuss-formular', 'issued' => (string) $issued, 'issuedSignature' => FormRepository::stamp('kontakt', $issued, 'test-only'), 'email' => '', 'datenschutz' => 'ja']);
|
|
self::assertSame(422, $antwort->getStatusCode());
|
|
self::assertStringContainsString('Dieses Feld wird benötigt.', $antwort->getContent());
|
|
}
|
|
|
|
public function testASectionLinkedTwiceRendersWithoutDuplicateIds(): void
|
|
{
|
|
$sektion = DemoContent::starter('Band');
|
|
$sektion['schemaVersion'] = 23;
|
|
$sektion['blocks'] = [['id' => 'band-sektion', 'type' => 'section', 'anchor' => 'band', 'children' => [['id' => 'band-text', 'type' => 'text', 'props' => ['text' => 'Zweimal da'], 'style' => ['desktop' => ['color' => '#112233']]]]]];
|
|
$this->partials->create('section', 'band', 'Band', $sektion);
|
|
$document = DemoContent::starter('Doppelt');
|
|
$document['blocks'][] = ['id' => 'ref-1', 'type' => 'partialref', 'props' => ['partial' => 'band']];
|
|
$document['blocks'][] = ['id' => 'ref-2', 'type' => 'partialref', 'props' => ['partial' => 'band']];
|
|
$this->publishHome($document);
|
|
$html = $this->request('/')->getContent();
|
|
self::assertSame(1, substr_count($html, 'id="band-text"'));
|
|
self::assertSame(1, substr_count($html, 'id="band-text-2"'), 'Die zweite Verknüpfung bekommt eigene Kennungen.');
|
|
self::assertSame(2, substr_count($html, 'Zweimal da'));
|
|
self::assertSame(1, substr_count($html, 'id="band"'), 'Das Sprungziel gibt es nur einmal.');
|
|
self::assertStringContainsString('#band-text{', $html);
|
|
self::assertStringContainsString('#band-text-2{', $html, 'Auch die Kopie hat ihren Stil.');
|
|
}
|
|
|
|
public function testFreshlyInsertedBlocksAreValidAndRenderNothingPublicly(): void
|
|
{
|
|
$document = DemoContent::starter('Leer');
|
|
$document['blocks'][] = ['id' => 'k', 'type' => 'contact', 'props' => ['phone' => '', 'email' => '', 'address' => '']];
|
|
$document['blocks'][] = ['id' => 'f', 'type' => 'form', 'props' => ['form' => '']];
|
|
$document['blocks'][] = ['id' => 's', 'type' => 'partialref', 'props' => ['partial' => '']];
|
|
$document['blocks'][] = ['id' => 'e', 'type' => 'embed', 'props' => ['group' => 'external', 'title' => '', 'provider' => '', 'url' => '', 'hint' => '', 'height' => 400]];
|
|
$this->publishHome($document);
|
|
$html = $this->request('/')->getContent();
|
|
foreach (['id="k"', 'id="f"', 'id="s"', 'id="e"', 'nicht mehr vorhanden', 'block-unavailable'] as $spur) {
|
|
self::assertStringNotContainsString($spur, $html, $spur.' darf öffentlich nicht auftauchen.');
|
|
}
|
|
// Mit Inhalt gelten die alten Regeln weiter.
|
|
$document['blocks'][array_key_last($document['blocks'])]['props']['url'] = 'http://unsicher.example';
|
|
try {
|
|
$this->publishHome($document);
|
|
self::fail('Eine Adresse ohne HTTPS bleibt abgelehnt.');
|
|
} catch (InvalidDocument) {
|
|
}
|
|
$document = DemoContent::starter('Legal');
|
|
$document['blocks'][] = ['id' => 'l', 'type' => 'legal', 'props' => ['label' => str_repeat('x', 61)]];
|
|
try {
|
|
$this->publishHome($document);
|
|
self::fail('Die Beschriftung der Rechtszeile ist begrenzt.');
|
|
} catch (InvalidDocument) {
|
|
}
|
|
}
|
|
|
|
public function testADarkPaletteSwitchesTheColourSchemeAndTheErrorColours(): void
|
|
{
|
|
$theme = new Theme($this->db);
|
|
$theme->save(
|
|
[['key' => 'nacht', 'name' => 'Nacht', 'value' => '#101a2b'], ['key' => 'hell', 'name' => 'Hell', 'value' => '#f4f1ea']],
|
|
[['key' => 'body', 'name' => 'Text', 'family' => 'Arial', 'source' => 'system', 'weights' => [400, 700], 'fallback' => 'sans-serif']],
|
|
['fontFamily' => 'body', 'headingFontFamily' => 'body', 'fontSize' => '16px', 'lineHeight' => 1.6, 'color' => '@hell', 'backgroundColor' => '@nacht', 'linkColor' => '@hell', 'containerWidth' => '1180px', 'headingScale' => 1.25, 'linkUnderline' => true],
|
|
);
|
|
$this->publishHome(DemoContent::starter('Dunkel'));
|
|
$html = $this->request('/')->getContent();
|
|
self::assertStringContainsString('--k-scheme:dark', $html);
|
|
self::assertStringContainsString('--k-error:#ff9b8a', $html);
|
|
self::assertStringContainsString('--k-error-bg:color-mix(in srgb,#ff9b8a 12%,#101a2b)', $html);
|
|
self::assertGreaterThan(0.5, Theme::luminance('#f4f1ea'));
|
|
self::assertLessThan(0.1, Theme::luminance('#101a2b'));
|
|
// Die Stylesheets kennen keine festen Fehlerfarben und kein festes Weiß mehr.
|
|
$css = (string) file_get_contents(dirname(__DIR__).'/public/assets/site.css');
|
|
self::assertStringNotContainsString('color:#8b2f22', $css);
|
|
self::assertStringNotContainsString('color:white', $css);
|
|
self::assertStringContainsString('color-scheme:var(--k-scheme,light)', $css);
|
|
self::assertStringContainsString('@media print', $css);
|
|
self::assertStringNotContainsString('max-width:650px', $css, 'Eine Grenze zwischen Tablet und Telefon: 767 px.');
|
|
self::assertStringNotContainsString('max-width:650px', (string) file_get_contents(dirname(__DIR__).'/public/assets/design.css'));
|
|
}
|
|
|
|
public function testUpdatesLockEachOtherOutAndTheHtaccessKeepsItsEnabledLines(): void
|
|
{
|
|
$root = sys_get_temp_dir().'/kant-lock-'.bin2hex(random_bytes(4));
|
|
mkdir($root.'/var', 0o775, true);
|
|
mkdir($root.'/public', 0o775, true);
|
|
try {
|
|
$updater = new Updater($root, 'sqlite:///'.$root.'/var/cms.db', new CommandRunner(), $this->db);
|
|
$erste = $updater->lock();
|
|
self::assertIsResource($erste);
|
|
self::assertNull($updater->lock(), 'Solange die erste Sperre steht, bekommt niemand eine zweite.');
|
|
flock($erste, LOCK_UN);
|
|
fclose($erste);
|
|
$zweite = $updater->lock();
|
|
self::assertIsResource($zweite);
|
|
fclose($zweite);
|
|
|
|
// Die alte .htaccess hat HTTPS und HSTS eingeschaltet, das Release liefert sie auskommentiert.
|
|
$alt = "RewriteEngine On\nRewriteCond %{HTTPS} !=on\nRewriteCond %{HTTP:X-Forwarded-Proto} !=https\nRewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]\nHeader always set Strict-Transport-Security \"max-age=31536000\"\n";
|
|
$neu = "RewriteEngine On\n# neu im Release\n#RewriteCond %{HTTPS} !=on\n#RewriteCond %{HTTP:X-Forwarded-Proto} !=https\n#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]\n#Header always set Strict-Transport-Security \"max-age=31536000; includeSubDomains\"\n";
|
|
file_put_contents($root.'/public/.htaccess', $alt);
|
|
file_put_contents($root.'/var/neu.htaccess', $neu);
|
|
$merge = new \ReflectionMethod(ArchiveUpdate::class, 'mergeHtaccess');
|
|
$archiv = (new \ReflectionClass(ArchiveUpdate::class))->newInstanceWithoutConstructor();
|
|
$merge->invoke($archiv, $root.'/var/neu.htaccess', $root.'/public/.htaccess');
|
|
$ergebnis = (string) file_get_contents($root.'/public/.htaccess');
|
|
self::assertStringContainsString("# neu im Release\nRewriteCond %{HTTPS} !=on\nRewriteCond %{HTTP:X-Forwarded-Proto} !=https\nRewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]\nHeader always set Strict-Transport-Security \"max-age=31536000; includeSubDomains\"", $ergebnis, 'Die neue Datei, aber mit den eingeschalteten Zeilen.');
|
|
// Waren sie aus, bleiben sie aus.
|
|
file_put_contents($root.'/public/.htaccess', $neu);
|
|
$merge->invoke($archiv, $root.'/var/neu.htaccess', $root.'/public/.htaccess');
|
|
self::assertSame($neu, (string) file_get_contents($root.'/public/.htaccess'));
|
|
} finally {
|
|
@unlink($root.'/public/.htaccess');
|
|
@unlink($root.'/var/neu.htaccess');
|
|
@unlink($root.'/var/update.lock');
|
|
@rmdir($root.'/public');
|
|
@rmdir($root.'/var');
|
|
@rmdir($root);
|
|
}
|
|
}
|
|
|
|
private function request(string $url, string $method = 'GET', array $body = []): Response
|
|
{
|
|
$previous = [];
|
|
foreach (['APP_SECRET' => 'test-only', 'DATABASE_URL' => 'sqlite:///'.str_replace('\\', '/', $this->path), 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media', 'FONT_DIRECTORY' => $this->path.'-fonts', 'ASSET_DIRECTORY' => $this->path.'-assets'] as $key => $value) {
|
|
$previous[$key] = [$_SERVER[$key] ?? null, $_ENV[$key] ?? null];
|
|
$_SERVER[$key] = $_ENV[$key] = $value;
|
|
}
|
|
$kernel = new Kernel('test', false);
|
|
try {
|
|
$request = Request::create($url, $method, $body);
|
|
$response = $kernel->handle($request);
|
|
$kernel->terminate($request, $response);
|
|
|
|
return $response;
|
|
} 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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|