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('', $html); self::assertStringContainsString('', $html); self::assertStringNotContainsString('rel="prev"', $html); self::assertStringContainsString('12 Beiträge', $html); $zwei = $this->request('/beitraege?seite=2')->getContent(); self::assertStringContainsString('', $zwei); self::assertStringContainsString('', $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('', $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('', $eins); self::assertStringContainsString('', $eins); $drei = $this->request('/?seite=3')->getContent(); self::assertStringContainsString('', $drei); self::assertStringContainsString('', $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('
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; } } } } }