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'] = ''; $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('getContent()); self::assertStringContainsString('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('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; } } } } }