projectDir = dirname(__DIR__); $this->path = tempnam(sys_get_temp_dir(), 'kant-plugin-'); $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')]); $settings = new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')); $this->plugins = new PluginRegistry($this->db, $this->projectDir); $this->pages = new PageRepository($this->db, new DocumentValidator(plugins: $this->plugins), $settings); $partials = new PartialRepository($this->db, new DocumentValidator()); foreach (DemoContent::areas() as $kind => $area) { $partials->create($kind, 'default', $area['name'], $area['document']); } } protected function tearDown(): void { $this->db->close(); gc_collect_cycles(); @unlink($this->path); } private function uploadImage(): array { $media = new MediaLibrary($this->db, $this->path.'-media'); $file = tempnam(sys_get_temp_dir(), 'kant-plugin-img-'); $image = imagecreatetruecolor(800, 600); imagepng($image, $file); imagedestroy($image); $item = $media->upload(new UploadedFile($file, 'foto.png', test: true)); @unlink($file); return $item; } private function vergleich(array $props, string $id = 'v1'): array { $document = DemoContent::starter('Vergleich'); $document['schemaVersion'] = 23; $document['blocks'][] = ['id' => $id, 'type' => 'vergleich.bildvergleich', 'props' => $props]; return $document; } public function testTheReferencePluginIsFoundReadAndSwitchable(): void { $all = $this->plugins->all(); self::assertArrayHasKey('vergleich', $all); $plugin = $all['vergleich']; self::assertNull($plugin['error']); self::assertSame('Bildvergleich', $plugin['title']); self::assertFalse($plugin['active'], 'Ein neues Plugin ist aus, bis jemand es einschaltet.'); self::assertSame([], $this->plugins->blocks()); self::assertNull($this->plugins->block('vergleich.bildvergleich')); $this->plugins->activate('vergleich'); $block = $this->plugins->block('vergleich.bildvergleich'); self::assertNotNull($block); self::assertSame('@plugins/vergleich/blocks/bildvergleich.html.twig', $block['template']); self::assertSame('/plugin-assets/vergleich/assets/bildvergleich.css?v=1.1.0', $block['css']); self::assertSame('/plugin-assets/vergleich/assets/bildvergleich.js?v=1.1.0', $block['script']); self::assertSame(['before', 'after', 'labelBefore', 'labelAfter', 'alt', 'position', 'rounded'], array_keys($block['props'])); self::assertSame(['kind' => 'number', 'label' => 'Startposition des Reglers (%)', 'help' => '', 'min' => 5, 'max' => 95, 'default' => 50], $block['props']['position']); self::assertSame('1.1.0', $this->plugins->all()['vergleich']['activatedVersion']); // Der Schalter liegt in der Datenbank: eine neue Registry sieht ihn auch. self::assertTrue((new PluginRegistry($this->db, $this->projectDir))->all()['vergleich']['active']); $this->plugins->deactivate('vergleich'); self::assertFalse($this->plugins->all()['vergleich']['active']); self::assertNull($this->plugins->block('vergleich.bildvergleich')); try { $this->plugins->activate('gibtesnicht'); self::fail('Ein unbekanntes Plugin darf sich nicht einschalten lassen.'); } catch (\InvalidArgumentException $e) { self::assertSame('Dieses Plugin gibt es nicht.', $e->getMessage()); } } public function testABrokenManifestIsReportedAndNeverActive(): void { $dir = sys_get_temp_dir().'/kant-plugins-'.bin2hex(random_bytes(4)); mkdir($dir.'/plugins/kaputt', 0o775, true); mkdir($dir.'/plugins/leer', 0o775, true); mkdir($dir.'/plugins/gut/blocks', 0o775, true); file_put_contents($dir.'/VERSION', "1.17.0\n"); file_put_contents($dir.'/plugins/kaputt/plugin.json', '{"name": "kaputt", "title": "Kaputt", "version": "1.0.0", "blocks": {"karte": {"label": "Karte", "template": "blocks/fehlt.html.twig"}}}'); file_put_contents($dir.'/plugins/leer/plugin.json', '{nicht json'); file_put_contents($dir.'/plugins/gut/plugin.json', json_encode(['name' => 'gut', 'title' => 'Gut', 'version' => '2.0.0', 'requires' => '9.0.0', 'blocks' => ['karte' => ['label' => 'Karte', 'template' => 'blocks/karte.html.twig', 'props' => ['titel' => ['kind' => 'text', 'default' => 'Hallo']]]]])); file_put_contents($dir.'/plugins/gut/blocks/karte.html.twig', '
{{ props.titel }}
'); try { $registry = new PluginRegistry($this->db, $dir); $all = $registry->all(); self::assertStringContainsString('fehlt im Pluginordner', (string) $all['kaputt']['error']); self::assertStringContainsString('kein gültiges JSON', (string) $all['leer']['error']); self::assertStringContainsString('Braucht KantSites 9.0.0', (string) $all['gut']['error']); foreach (['kaputt', 'leer', 'gut'] as $name) { try { $registry->activate($name); self::fail($name.' darf sich nicht einschalten lassen.'); } catch (\InvalidArgumentException) { } } self::assertSame([], $registry->blocks()); // Mit passender Fassung ist „gut“ in Ordnung und seine Vorgaben stehen fest. file_put_contents($dir.'/VERSION', "9.1.0\n"); $registry = new PluginRegistry($this->db, $dir); self::assertNull($registry->all()['gut']['error']); $registry->activate('gut'); self::assertSame(['titel' => 'Hallo'], $registry->validateProps('gut.karte', [])); self::assertSame(['titel' => 'Servus'], $registry->validateProps('gut.karte', ['titel' => ' Servus ', 'fremd' => 'weg']), 'Unbekannte Eigenschaften fallen weg, bekannte werden bereinigt.'); } finally { foreach (['kaputt/plugin.json', 'leer/plugin.json', 'gut/plugin.json', 'gut/blocks/karte.html.twig'] as $file) { @unlink($dir.'/plugins/'.$file); } foreach (['gut/blocks', 'gut', 'kaputt', 'leer'] as $sub) { @rmdir($dir.'/plugins/'.$sub); } @unlink($dir.'/VERSION'); @rmdir($dir.'/plugins'); @rmdir($dir); } } public function testPropsAreCheckedAgainstTheSchemaOnceThePluginIsActive(): void { $bild = $this->uploadImage(); // Aus: Erhaltungsvertrag - alles wird gespeichert, nichts geprüft. $wild = $this->vergleich(['position' => 'sehr weit', 'before' => '', $html); self::assertStringContainsString("script-src 'self'", (string) $an->headers->get('Content-Security-Policy')); self::assertSame(1, substr_count($html, 'bildvergleich.css'), 'Jede Datei einmal, auch wenn der Baustein mehrfach vorkäme.'); // Die Dateien kommen über die eigene Route, mit Typ und langer Haltbarkeit; anderes nicht. $css = $this->request('/plugin-assets/vergleich/assets/bildvergleich.css'); self::assertSame(200, $css->getStatusCode()); self::assertSame('text/css; charset=UTF-8', $css->headers->get('Content-Type')); self::assertStringContainsString('immutable', (string) $css->headers->get('Cache-Control')); self::assertSame(404, $this->request('/plugin-assets/vergleich/plugin.json')->getStatusCode(), 'Das Manifest ist keine öffentliche Datei.'); self::assertSame(404, $this->request('/plugin-assets/vergleich/assets/../plugin.json')->getStatusCode()); self::assertSame(404, $this->request('/plugin-assets/vergleich/blocks/bildvergleich.html.twig')->getStatusCode()); // Ohne Bilder rendert die Vorlage einen leeren Platzhalter und keine Bilder von anderswo. $this->plugins->deactivate('vergleich'); self::assertSame(404, $this->request('/plugin-assets/vergleich/assets/bildvergleich.css')->getStatusCode(), 'Die Dateien eines ausgeschalteten Plugins gibt es nicht.'); } public function testDesignTokensAndOwnStylesApplyToPluginBlocks(): void { $this->plugins->activate('vergleich'); $document = $this->vergleich(['position' => 50]); $letzter = array_key_last($document['blocks']); $document['blocks'][$letzter]['layout'] = ['design' => ['desktop' => ['marginTop' => 'large']], 'effects' => ['entrance' => 'fade', 'delay' => 'none']]; $document['blocks'][$letzter]['style'] = ['desktop' => ['maxWidth' => '600px']]; $this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1); $html = $this->request('/')->getContent(); self::assertStringContainsString('class="block-bildvergleich bildvergleich-rund bildvergleich-griff-pfeile d-desktop-marginTop-large fx-entrance-fade fx-delay-none"', $html); self::assertStringContainsString('#v1{', $html); } public function testSettingsAreValidatedStoredAndHandedToTemplatesAndRoutes(): void { $this->plugins->activate('vergleich'); self::assertSame(['labelBefore' => 'Vorher', 'labelAfter' => 'Nachher', 'handle' => 'pfeile'], $this->plugins->settingsOf('vergleich'), 'Ohne gespeicherte Werte gelten die Vorgaben des Manifests.'); // Aus der Verwaltung kommen Zeichenketten; ein leeres Kästchen fehlt ganz. $this->plugins->saveSettings('vergleich', ['labelBefore' => ' Alt ', 'labelAfter' => 'Neu', 'handle' => 'punkt', 'fremd' => 'weg']); self::assertSame(['labelBefore' => 'Alt', 'labelAfter' => 'Neu', 'handle' => 'punkt'], (new PluginRegistry($this->db, $this->projectDir))->settingsOf('vergleich')); try { $this->plugins->saveSettings('vergleich', ['handle' => 'kugel']); self::fail('Ein Wert außerhalb der Auswahl muss abgewiesen werden.'); } catch (\InvalidArgumentException $e) { self::assertStringContainsString('Griff des Reglers', $e->getMessage()); } // Die Vorlage nimmt die Einstellung als Standard, wenn der Baustein keine eigene hat. $bild = $this->uploadImage(); $this->pages->publish('page-1', $this->pages->saveDraft('page-1', $this->vergleich(['before' => $bild['id'], 'after' => $bild['id']]), 0), 1); $html = $this->request('/')->getContent(); self::assertStringContainsString('bildvergleich-griff-punkt', $html); self::assertStringContainsString('>Alt', $html); self::assertStringContainsString('>Neu', $html); // Die eigene Adresse antwortet mit den Einstellungen als JSON - und nur, solange das Plugin an ist. $daten = $this->request('/vergleich/daten?stadt=Freudenstadt&=1'); self::assertSame(200, $daten->getStatusCode()); self::assertSame('application/json', $daten->headers->get('Content-Type')); self::assertSame('noindex', $daten->headers->get('X-Robots-Tag')); self::assertStringContainsString("script-src 'none'", (string) $daten->headers->get('Content-Security-Policy')); $json = json_decode((string) $daten->getContent(), true); self::assertSame(['before' => 'Alt', 'after' => 'Neu'], $json['labels']); self::assertSame(['stadt' => 'Freudenstadt'], $json['echo'], 'Nur saubere Parameternamen erreichen die Vorlage.'); $this->plugins->deactivate('vergleich'); self::assertSame(404, $this->request('/vergleich/daten')->getStatusCode()); } public function testAContainerBlockRendersItsChildrenAndOnlyTheAllowedOnes(): void { $this->plugins->activate('vergleich'); $bild = $this->uploadImage(); $kind = static fn (string $id) => ['id' => $id, 'type' => 'vergleich.bildvergleich', 'props' => ['before' => $bild['id'], 'after' => $bild['id'], 'position' => 40]]; $document = DemoContent::starter('Galerie'); $document['schemaVersion'] = 23; $document['blocks'][] = ['id' => 'g', 'type' => 'vergleich.galerie', 'props' => ['columns' => '3', 'title' => 'Unsere Arbeiten'], 'children' => [$kind('k1'), $kind('k2')]]; $this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1); $html = $this->request('/')->getContent(); self::assertStringContainsString('
', $html); self::assertStringContainsString('

Unsere Arbeiten

', $html); self::assertSame(2, substr_count($html, 'class="block-bildvergleich'), 'Die Kinder werden vom Kern gerendert und der Vorlage übergeben.'); self::assertSame(1, substr_count($html, 'bildvergleich.css'), 'Stylesheet einmal, obwohl zwei Bausteine es nennen.'); // Ein fremdes Kind wird beim Speichern abgewiesen, ebenso Kinder in einem Nicht-Container. $version = $this->pages->findForEditor('page-1')['version']; $fremd = $document; $fremd['blocks'][count($fremd['blocks']) - 1]['children'][] = ['id' => 't', 'type' => 'text', 'props' => ['text' => 'Hallo']]; try { $this->pages->saveDraft('page-1', $fremd, $version); self::fail('Ein Text gehört nicht in die Vergleichsgalerie.'); } catch (InvalidDocument $e) { self::assertStringContainsString('passt nur: vergleich.bildvergleich', $e->getMessage()); } $keinContainer = $this->vergleich(['position' => 50]); $keinContainer['blocks'][count($keinContainer['blocks']) - 1]['children'] = [['id' => 't', 'type' => 'text', 'props' => ['text' => 'Hallo']]]; try { $this->pages->saveDraft('page-1', $keinContainer, $version); self::fail('Ein Bildvergleich nimmt keine Kinder auf.'); } catch (InvalidDocument $e) { self::assertStringContainsString('nimmt keine Bausteine auf', $e->getMessage()); } // Der Editor erfährt Container und erlaubte Kinder, und die Vorlage mit ihren Bausteinen. $editor = array_column($this->plugins->forEditor(), null, 'type'); self::assertTrue($editor['vergleich.galerie']['container']); self::assertSame(['vergleich.bildvergleich'], $editor['vergleich.galerie']['children']); self::assertFalse($editor['vergleich.bildvergleich']['container']); $presets = $this->plugins->presetsForEditor(); self::assertSame('plugin:vergleich:abschnitt', $presets[0]['id']); self::assertSame('section', $presets[0]['blocks'][0]['type']); } public function testSwitchingAPluginOnAndOffLeavesTheDesignAlone(): void { // Ein Plugin bringt kein Designpaket mehr mit: Farben, Schriften und Buttons gehören der // Website und ändern sich nur unter Design. Ein Schalter darf daran nichts drehen. $theme = new \App\Content\Theme($this->db); $vorher = $theme->load(); $document = $this->vergleich(['position' => 50]); $this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1); $this->plugins->activate('vergleich'); self::assertSame($vorher, (new \App\Content\Theme($this->db))->load(), 'Einschalten färbt die Website nicht um.'); $this->plugins->deactivate('vergleich'); self::assertSame($vorher, (new \App\Content\Theme($this->db))->load(), 'Ausschalten lässt das Design ebenso in Ruhe.'); // Und die Inhalte bleiben in beiden Richtungen stehen. self::assertSame(end($document['blocks']), end($this->pages->findPublished('home')['document']['blocks'])); } 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; } } } } }