Files
kantarciandClaude Opus 5.5 ad7c333215 Harden KantSites after the security review
- 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>
2026-10-12 14:38:25 +02:00

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;
}
}
}
}
}