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