Files
deskhub/DeskHub-Webseite/tests/plugin_website.php
T
2026-10-16 19:12:24 +00:00

247 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* Prüft die Plugin- und Theme-Webseite (plugin-webseite/) gegen DeskHub selbst:
* Veröffentlichen, Prüfungen, Signatur, Katalog-Vertrag (DeskHub liest und installiert, was die Webseite liefert) und
* eine echte Webserver-Sitzung mit Anmeldung, Upload und Download.
*/
if (!class_exists('ZipArchive')) { echo "Webseiten-Tests übersprungen: PHP-ZIP fehlt.\n"; exit(0); }
$tmp = sys_get_temp_dir() . '/deskhub-site-' . bin2hex(random_bytes(4));
mkdir($tmp . '/storage', 0755, true);
mkdir($tmp . '/plugins', 0755, true);
mkdir($tmp . '/themes', 0755, true);
$cnf = tempnam(sys_get_temp_dir(), 'cnf');
file_put_contents($cnf, "[req]\ndistinguished_name=req_dn\n[req_dn]\n");
$k = openssl_pkey_new(['config' => $cnf, 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
openssl_pkey_export($k, $privPem, null, ['config' => $cnf]);
$pubPem = openssl_pkey_get_details($k)['key'];
file_put_contents($tmp . '/privat.pem', $privPem);
// Umgebung der Webseite (gilt auch für den späteren Webserver-Prozess)
putenv('PLUGIN_SITE_CONFIG=' . $tmp . '/gibtesnicht.php');
putenv('PLUGIN_SITE_STORAGE=' . $tmp . '/storage');
putenv('PLUGIN_SITE_BASE_URL=https://plugins.example.test');
putenv('PLUGIN_SITE_SIGNING_KEY=' . $tmp . '/privat.pem');
putenv('PLUGIN_SITE_ADMIN_HASH=' . password_hash('Sehr-Sicheres-Passwort-1', PASSWORD_DEFAULT));
$accessKey = bin2hex(random_bytes(24));
putenv('PLUGIN_SITE_ACCESS_KEY=' . $accessKey);
// Umgebung von DeskHub
putenv('DESKHUB_PLUGINS_DIR=' . $tmp . '/plugins');
putenv('DESKHUB_THEMES_DIR=' . $tmp . '/themes');
putenv('DESKHUB_TESTING=1');
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
putenv('DESKHUB_SQLITE_PATH=:memory:');
require_once dirname(__DIR__) . '/includes/bootstrap.php';
require_once __DIR__ . '/fixtures/paths.php';
$siteDir = website_dir_or_skip('plugin_website');
require_once $siteDir . '/inc/lib.php';
$failures = [];
$total = 0;
$check = function (bool $ok, string $what) use (&$failures, &$total): void {
$total++;
if (!$ok) $failures[] = $what;
};
$makeZip = function (array $files): string {
$p = tempnam(sys_get_temp_dir(), 'pw') . '.zip';
$z = new ZipArchive();
$z->open($p, ZipArchive::CREATE | ZipArchive::OVERWRITE);
foreach ($files as $n => $c) $z->addFromString($n, $c);
$z->close();
return $p;
};
$dirZip = function (string $dir) use ($makeZip): string {
$files = [];
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)) as $f) {
$files[str_replace('\\', '/', substr($f->getPathname(), strlen($dir) + 1))] = file_get_contents($f->getPathname());
}
return $makeZip($files);
};
$themeZip = fn(string $version = '1.0.0') => $makeZip(['theme.css' => "/*\n * Author: Studio\n * Version: {$version}\n */\n:root { --primary: #0a5; }", 'img/bg.png' => 'x']);
// ── Veröffentlichen ──────────────────────────────────────────────────────────
$check(site_public_key() === $pubPem, 'öffentlicher Schlüssel wird aus dem privaten abgeleitet');
$plugin = $dirZip(plugin_source_dir_or_skip('tagesbericht', 'plugin_website'));
$r = site_publish('plugin', $plugin);
$check($r['ok'] && $r['slug'] === 'tagesbericht' && $r['signed'] === true, 'Plugin wird veröffentlicht und signiert: ' . json_encode($r));
$check(!site_publish('plugin', $plugin)['ok'], 'dieselbe Version kann nicht erneut veröffentlicht werden');
$r = site_publish('theme', $themeZip(), ['slug' => 'nordlicht', 'name' => 'Nordlicht', 'description' => 'Hell und ruhig']);
$check($r['ok'] && $r['version'] === '1.0.0', 'Theme wird veröffentlicht, Version stammt aus dem CSS-Kopf: ' . json_encode($r));
$check(site_publish('theme', $themeZip('1.1.0'), ['slug' => 'nordlicht', 'name' => 'Nordlicht'])['ok'], 'zweite Theme-Version wird angenommen');
$bad = site_publish('plugin', $makeZip(['plugin.json' => '{"slug":"x1","name":"X","version":"1.0.0","permissions":["alles"]}', 'plugin.php' => '<?php']));
$check(!$bad['ok'] && str_contains($bad['error'], 'unbekannte Berechtigung'), 'unbekannte Berechtigung wird abgelehnt');
$check(!site_publish('plugin', $makeZip(['plugin.json' => '{"slug":"core","name":"X","version":"1.0.0"}', 'plugin.php' => '<?php']))['ok'], 'reservierter Name wird abgelehnt');
$check(!site_publish('plugin', $makeZip(['plugin.json' => '{"slug":"evil","name":"X","version":"1.0.0"}', '../evil.php' => '<?php']))['ok'], 'Zip-Slip wird abgelehnt');
$check(!site_publish('plugin', $makeZip(['plugin.json' => '{"slug":"exe1","name":"X","version":"1.0.0"}', 'a.exe' => 'MZ']))['ok'], 'unzulässiger Dateityp im Plugin wird abgelehnt');
$check(!site_publish('plugin', $makeZip(['a.php' => '<?php']))['ok'], 'Plugin ohne plugin.json wird abgelehnt');
$check(!site_publish('theme', $makeZip(['theme.css' => 'a{}', 'x.php' => '<?php echo 1;']), ['slug' => 'mitphp', 'name' => 'X', 'version' => '1.0.0'])['ok'], 'PHP-Datei in einem Theme wird abgelehnt');
$check(!site_publish('theme', $themeZip(), ['slug' => 'Ungültig!', 'name' => 'X'])['ok'], 'ungültige Theme-Kennung wird abgelehnt');
$check(!site_publish('theme', $makeZip(['theme.css' => 'a{}']), ['slug' => 'ohnever', 'name' => 'X'])['ok'], 'Theme ohne Versionsangabe wird abgelehnt');
$check(!site_publish('sonstiges', $plugin)['ok'], 'unbekannter Typ wird abgelehnt');
// ── Katalog-Vertrag: DeskHub liest und installiert, was die Webseite liefert ─────
$pluginCatalog = site_catalog('plugin');
$themeCatalog = site_catalog('theme');
$check(count($pluginCatalog['plugins']) === 1 && str_starts_with($pluginCatalog['plugins'][0]['downloadUrl'], 'https://plugins.example.test/download.php'), 'Plugin-Katalog enthält https-Download-Adresse');
$check(count($themeCatalog['themes']) === 1 && $themeCatalog['themes'][0]['version'] === '1.1.0', 'Theme-Katalog enthält nur die neueste Version');
$files = [];
foreach (['plugin' => $pluginCatalog['plugins'], 'theme' => $themeCatalog['themes']] as $t => $list) {
foreach ($list as $e) {
$v = site_item($t, $e['slug'])['versions'][0];
$files[$e['downloadUrl']] = file_get_contents(site_storage() . '/packages/' . $v['file']);
}
}
$GLOBALS['deskhub_http_get'] = function (string $url, int $max) use ($pluginCatalog, $themeCatalog, $files): array {
if (str_contains($url, 'type=plugins')) return [200, json_encode($pluginCatalog), null];
if (str_contains($url, 'type=themes')) return [200, json_encode($themeCatalog), null];
return isset($files[$url]) ? [200, $files[$url], null] : [404, null, 'HTTP 404'];
};
$pdo = get_db();
set_setting('plugin_catalog_url', 'https://plugins.example.test/catalog.php?type=plugins');
set_setting('theme_catalog_url', 'https://plugins.example.test/catalog.php?type=themes');
set_setting('plugin_publisher_key', $pubPem);
set_setting('plugin_require_signature', '1');
$cat = plugin_catalog_fetch();
$check($cat['ok'] && count($cat['plugins']) === 1 && $cat['plugins'][0]['permissions'] === ['hooks.cron', 'ui.menu'], 'DeskHub liest den Plugin-Katalog der Webseite');
$r = plugin_catalog_install($pdo, 'tagesbericht');
$check($r['ok'] && $r['signed'] === true, 'DeskHub installiert das Plugin mit geprüfter Signatur („Signatur erforderlich“ an): ' . json_encode($r));
$check(is_file($tmp . '/plugins/tagesbericht/plugin.php'), 'Plugin-Dateien liegen im Plugin-Ordner');
$r = plugin_set_active($pdo, 'tagesbericht', true, ['hooks.cron', 'ui.menu']);
$check($r['ok'], 'Plugin aus dem Katalog lässt sich mit bestätigten Berechtigungen aktivieren');
$tc = theme_catalog_fetch();
$check($tc['ok'] && count($tc['themes']) === 1, 'DeskHub liest den Theme-Katalog der Webseite');
$r = theme_catalog_install('nordlicht');
$check($r['ok'] && $r['signed'] === true && count(list_custom_themes()) === 1, 'DeskHub installiert das Theme mit geprüfter Signatur: ' . json_encode($r));
unset($GLOBALS['deskhub_http_get']);
// ── Webserver: öffentliche Seiten, Anmeldung, Upload, Download ───────────────
$port = random_int(20000, 20999);
$root = $siteDir;
$logFile = tempnam(sys_get_temp_dir(), 'pwlog');
$proc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$port", '-t', $root], [['pipe', 'r'], ['file', $logFile, 'w'], ['file', $logFile, 'a']], $pipes, $root);
$http = function (string $method, string $path, array $opts = []) use ($port): array {
$ch = curl_init("http://127.0.0.1:$port$path");
// DeskHub schickt den Zugangsschlüssel immer mit; $opts['key'] = false lässt ihn weg, eine Zeichenkette setzt einen anderen
$keyHeader = array_key_exists('key', $opts) ? $opts['key'] : $GLOBALS['accessKey'];
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 20,
CURLOPT_HTTPHEADER => $keyHeader === false ? [] : ['X-DeskHub-Key: ' . $keyHeader],
CURLOPT_COOKIEJAR => $opts['jar'] ?? '', CURLOPT_COOKIEFILE => $opts['jar'] ?? '']);
if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $opts['data'] ?? []); }
$raw = (string) curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$hs = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
return [$status, substr($raw, $hs), substr($raw, 0, $hs)];
};
try {
for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $port)) break; usleep(100000); }
// Keine öffentlichen Seiten: Startseite führt zur Verwaltung, Übersichts-, Detail- und Hilfeseite gibt es nicht
[$s, , $h] = $http('GET', '/index.php?type=plugin');
$check($s === 302 && stripos($h, 'Location: admin.php') !== false, 'die Startseite leitet zur Verwaltung weiter');
[$s] = $http('GET', '/item.php?type=plugin&slug=tagesbericht');
$check($s === 404, 'es gibt keine öffentliche Detailseite mehr');
[$s] = $http('GET', '/hilfe.php');
$check($s === 404, 'die Hilfeseite „So geht’s“ gibt es nicht mehr');
// Zugangsschutz: ohne (oder mit falschem) Schlüssel liefert die Webseite nichts aus
foreach (['/catalog.php?type=plugins', '/catalog.php?type=themes', '/publisher-key.php', '/preview.php?file=x.png', '/download.php?type=plugin&slug=tagesbericht'] as $guarded) {
[$s1, $b1] = $http('GET', $guarded, ['key' => false]);
[$s2] = $http('GET', $guarded, ['key' => 'falscher-schluessel']);
$check($s1 === 401 && $s2 === 401 && !str_contains($b1, 'tagesbericht') && !str_contains($b1, 'BEGIN PUBLIC KEY'), "ohne gültigen Zugangsschlüssel gibt {$guarded} nichts heraus (401)");
}
[$s, $body] = $http('GET', '/health.php', ['key' => false]);
$check($s === 200 && $body === 'ok', 'der Healthcheck antwortet ohne Schlüssel und gibt nichts preis');
[$s] = $http('GET', '/admin.php', ['key' => false]);
$check($s === 200, 'die Verwaltung bleibt über ihre Anmeldung erreichbar (Passwortschutz statt Zugangsschlüssel)');
[$s, $body] = $http('GET', '/publisher-key.php');
$check($s === 200 && trim($body) === trim($pubPem), 'öffentlicher Schlüssel ist abrufbar');
[$s, $body, $h] = $http('GET', '/catalog.php?type=plugins');
$json = json_decode($body, true);
$check($s === 200 && stripos($h, 'application/json') !== false && ($json['plugins'][0]['slug'] ?? '') === 'tagesbericht', 'catalog.php liefert JSON');
$entry = $json['plugins'][0];
$path = substr($entry['downloadUrl'], strlen('https://plugins.example.test'));
[$s, $body] = $http('GET', $path);
$check($s === 200 && hash('sha256', $body) === $entry['sha256'], 'Download liefert exakt das Paket mit der angegebenen Prüfsumme');
[$s] = $http('GET', '/download.php?type=plugin&slug=tagesbericht&version=9.9.9');
$check($s === 404, 'unbekannte Version liefert 404');
[$s] = $http('GET', '/download.php?type=plugin&slug=../../etc/passwd');
$check($s === 404, 'Pfadtricks im Download werden abgewiesen');
[$s] = $http('GET', '/preview.php?file=../katalog.sqlite');
$check($s === 404, 'Vorschau liefert nur Bilder');
$dl = (int) site_db()->query("SELECT downloads FROM versions v JOIN items i ON i.id = v.item_id WHERE i.slug = 'tagesbericht'")->fetchColumn();
$check($dl >= 1, 'Downloads werden gezählt');
// Verwaltung
$jar = tempnam(sys_get_temp_dir(), 'jar');
[$s, $body] = $http('GET', '/admin.php', ['jar' => $jar]);
$check($s === 200 && str_contains($body, 'name="password"') && !str_contains($body, 'Neues Paket veröffentlichen'), 'Verwaltung zeigt ohne Anmeldung nur das Login');
[$s, $body] = $http('POST', '/admin.php', ['jar' => $jar, 'data' => ['action' => 'login', 'user' => 'admin', 'password' => 'falsch']]);
$check(str_contains($body, 'falsch') && !str_contains($body, 'Neues Paket veröffentlichen'), 'falsches Passwort wird abgewiesen');
[$s] = $http('POST', '/admin.php', ['jar' => $jar, 'data' => ['action' => 'publish', 'type' => 'plugin']]);
$check($s === 403, 'Veröffentlichen ohne Anmeldung ist gesperrt');
[$s] = $http('POST', '/admin.php', ['jar' => $jar, 'data' => ['action' => 'login', 'user' => 'admin', 'password' => 'Sehr-Sicheres-Passwort-1']]);
$check($s === 302, 'richtiges Passwort meldet an');
[$s, $body] = $http('GET', '/admin.php', ['jar' => $jar]);
$check($s === 200 && str_contains($body, 'Neues Paket veröffentlichen') && str_contains($body, 'Signierschlüssel eingerichtet'), 'Verwaltung nach der Anmeldung');
preg_match('/name="csrf" value="([a-f0-9]+)"/', $body, $m);
$csrf = $m[1] ?? '';
[$s, $body] = $http('POST', '/admin.php', ['jar' => $jar, 'data' => ['action' => 'publish', 'type' => 'theme', 'csrf' => 'falsch', 'package' => new CURLFile($themeZip('2.0.0'), 'application/zip', 'x.zip')]]);
$check($s === 403, 'falsches CSRF-Token wird abgewiesen');
$up = $themeZip('2.0.0');
[$s, $body] = $http('POST', '/admin.php', ['jar' => $jar, 'data' => ['action' => 'publish', 'type' => 'theme', 'csrf' => $csrf, 'slug' => 'abendrot', 'name' => 'Abendrot', 'description' => 'Warm', 'package' => new CURLFile($up, 'application/zip', 'abendrot.zip')]]);
$check($s === 200 && str_contains($body, 'Veröffentlicht: abendrot 2.0.0'), 'Upload über die Verwaltung veröffentlicht ein Theme');
[$s, $body] = $http('GET', '/catalog.php?type=themes');
$slugs = array_column(json_decode($body, true)['themes'] ?? [], 'slug');
$check(in_array('abendrot', $slugs, true) && in_array('nordlicht', $slugs, true), 'neues Theme erscheint im Katalog');
[$s, $body] = $http('POST', '/admin.php', ['jar' => $jar, 'data' => ['action' => 'publish', 'type' => 'plugin', 'csrf' => $csrf, 'package' => new CURLFile($makeZip(['nur.txt' => 'x']), 'application/zip', 'k.zip')]]);
$check(str_contains($body, 'plugin.json wurde im Paket nicht gefunden'), 'ungültiges Paket zeigt eine verständliche Meldung');
// Löschen über die Verwaltung: Version entfernen, dann verschwindet auch der Eintrag samt Paketdatei und Katalogzeile
[$s, $body] = $http('GET', '/admin.php', ['jar' => $jar]);
$check(preg_match_all('/<form method="post" data-confirm="[^"]*"><input type="hidden" name="action" value="delete"><input type="hidden" name="csrf" value="[a-f0-9]+"><input type="hidden" name="id" value="(\d+)">/', $body, $dm) >= 1, 'die Verwaltung zeigt Entfernen-Schaltflächen');
$abId = (int) site_db()->query("SELECT v.id FROM versions v JOIN items i ON i.id = v.item_id WHERE i.slug = 'abendrot'")->fetchColumn();
$abFile = (string) site_db()->query("SELECT v.file FROM versions v JOIN items i ON i.id = v.item_id WHERE i.slug = 'abendrot'")->fetchColumn();
$check($abId > 0 && is_file($tmp . '/storage/packages/' . $abFile), 'Paketdatei liegt vor dem Löschen im Speicher');
[$s, $body] = $http('POST', '/admin.php', ['jar' => $jar, 'data' => ['action' => 'delete', 'csrf' => 'falsch', 'id' => $abId]]);
$check($s === 403 && $abId === (int) site_db()->query("SELECT v.id FROM versions v JOIN items i ON i.id = v.item_id WHERE i.slug = 'abendrot'")->fetchColumn(), 'Löschen mit falschem CSRF-Token ändert nichts');
[$s, $body] = $http('POST', '/admin.php', ['jar' => $jar, 'data' => ['action' => 'delete', 'csrf' => $csrf, 'id' => $abId]]);
$check($s === 200 && str_contains($body, 'Version entfernt.'), 'Löschen meldet Erfolg: HTTP ' . $s . ' ' . substr(strip_tags($body), 0, 200));
$check((int) site_db()->query("SELECT COUNT(*) FROM items WHERE slug = 'abendrot'")->fetchColumn() === 0, 'der Eintrag ist nach dem Löschen aus der Datenbank entfernt');
$deletedPackage = $tmp . '/storage/packages/' . $abFile; // die Datei selbst wird erst nach dem Beenden des Testservers geprüft (Windows hält sie bis dahin offen)
[$s, $body] = $http('GET', '/catalog.php?type=themes');
$check(!in_array('abendrot', array_column(json_decode($body, true)['themes'] ?? [], 'slug'), true) && in_array('nordlicht', array_column(json_decode($body, true)['themes'] ?? [], 'slug'), true), 'gelöschtes Theme ist aus dem Katalog, andere bleiben');
[$s, $body] = $http('POST', '/admin.php', ['jar' => $jar, 'data' => ['action' => 'delete', 'csrf' => $csrf, 'id' => 999999]]);
$check(str_contains($body, 'Version nicht gefunden'), 'unbekannte Version liefert eine verständliche Meldung');
@unlink($jar);
} finally {
proc_terminate($proc);
proc_close($proc);
}
usleep(500000);
clearstatcache();
$check(isset($deletedPackage) && !is_file($deletedPackage), 'die Paketdatei ist nach dem Löschen entfernt');
// Aufräumen
$rm = function (string $d) use (&$rm) {
foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); }
@rmdir($d);
};
$rm($tmp);
@unlink($cnf);
@unlink($logFile);
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Plugin website checks passed ({$total} Prüfungen).
";