open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE); foreach ($files as $name => $content) $zip->addFromString($name, $content); $zip->close(); return $path; }; $dirZip = function (string $dir) use ($makeZip): string { $files = []; foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)) as $f) { $files['beispiel-paket/' . str_replace('\\', '/', substr($f->getPathname(), strlen($dir) + 1))] = file_get_contents($f->getPathname()); } return $makeZip($files); }; $manifest = fn(array $o = []) => json_encode($o + ['slug' => 'kaputt', 'name' => 'Kaputt', 'version' => '1.0.0']); $activate = fn(string $slug) => plugin_set_active($pdo, $slug, true, plugin_read_manifest(plugins_dir() . '/' . $slug)[0]['permissions'] ?? []); // RSA-Schlüssel für die Signaturtests (Windows-PHP braucht eine minimale openssl.cnf) $cnf = tempnam(sys_get_temp_dir(), 'cnf'); file_put_contents($cnf, "[req]\ndistinguished_name=req_dn\n[req_dn]\n"); $keyOpts = ['config' => $cnf]; $newKey = function () use ($keyOpts) { $k = openssl_pkey_new($keyOpts + ['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); openssl_pkey_export($k, $priv, null, $keyOpts); return [$priv, openssl_pkey_get_details($k)['key']]; }; [$privKey, $pubKey] = $newKey(); [$otherPriv, $otherPub] = $newKey(); $sign = function (string $zipPath, string $slug, string $version, string $priv): string { openssl_sign(plugin_signature_message($slug, $version, hash_file('sha256', $zipPath)), $sig, $priv, OPENSSL_ALGO_SHA256); return base64_encode($sig); }; // ── Installation des Beispiel-Plugins ──────────────────────────────────────── require_once __DIR__ . '/fixtures/paths.php'; $zip = $dirZip(plugin_source_dir_or_skip('plugin-beispiel', 'plugins')); $hash = hash_file('sha256', $zip); $r = plugin_install_zip($pdo, $zip, str_repeat('0', 64)); $check(!$r['ok'] && str_contains($r['error'], 'Prüfsumme'), 'falsche Prüfsumme wird abgelehnt'); $r = plugin_install_zip($pdo, $zip, $hash); $check($r['ok'] && $r['slug'] === 'beispiel' && $r['version'] === '1.0.0' && $r['signed'] === false, 'Beispiel-Plugin wird installiert: ' . json_encode($r)); $check(is_file($pluginDir . '/beispiel/plugin.php'), 'Dateien liegen im Plugin-Ordner'); $check((int) $pdo->query("SELECT is_active FROM plugins WHERE slug='beispiel'")->fetchColumn() === 0, 'Plugin ist nach der Installation inaktiv'); $reboot(); $check(!isset(plugin_state()['loaded']['beispiel']), 'inaktives Plugin wird nicht geladen'); // ── Aktivierung: Berechtigungen bestätigen, Migration, Hooks, Filter, Menü, API ── $r = plugin_set_active($pdo, 'beispiel', true); $check(!$r['ok'] && !empty($r['needs_confirmation']), 'Aktivierung ohne Bestätigung der Berechtigungen wird abgelehnt'); $r = plugin_set_active($pdo, 'beispiel', true, ['hooks.tickets']); $check(!$r['ok'], 'unvollständige Bestätigung genügt nicht'); $check((int) $pdo->query("SELECT COUNT(*) FROM schema_migrations WHERE migration LIKE 'plugin_beispiel%'")->fetchColumn() === 0, 'ohne Bestätigung laufen keine Migrationen'); $r = $activate('beispiel'); $check($r['ok'], 'Aktivierung mit Bestätigung gelingt: ' . json_encode($r)); $check((int) $pdo->query("SELECT COUNT(*) FROM schema_migrations WHERE migration = 'plugin_beispiel_001_log." . $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) . ".sql'")->fetchColumn() === 1, 'Plugin-Migration wird protokolliert'); $other = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? 'mysql' : 'sqlite'; $check((int) $pdo->query("SELECT COUNT(*) FROM schema_migrations WHERE migration LIKE 'plugin_beispiel%.{$other}.sql'")->fetchColumn() === 0, 'Migrationen der anderen Datenbankart laufen nicht'); $check(plugin_run_migrations($pdo, 'beispiel')['applied'] === 0, 'Migrationen laufen nur einmal'); $check(str_contains((string) $pdo->query("SELECT approved_permissions FROM plugins WHERE slug='beispiel'")->fetchColumn(), 'hooks.tickets'), 'bestätigte Berechtigungen werden gespeichert'); $reboot(); $check(isset(plugin_state()['loaded']['beispiel']), 'aktives Plugin wird geladen'); webhook_dispatch($pdo, 'ticket.created', 42); webhook_dispatch($pdo, 'ticket.status_changed', 42, ['previousStatus' => 'open']); $check((int) $pdo->query('SELECT COUNT(*) FROM plugin_beispiel_log WHERE ticket_id = 42')->fetchColumn() === 2, 'Ticket-Ereignisse erreichen das Plugin'); $html = plugin_filter('ticket.view.sidebar', '', ['ticketId' => 42]); $check(str_contains($html, '2 Ereignis'), 'Filter liefert Seitenleisten-Baustein'); $menu = plugin_menu_items(['id' => 1, 'role' => 'admin']); $check(count($menu) === 1 && $menu[0]['label'] === 'Ticket-Protokoll' && str_contains($menu[0]['url'], 'p=beispiel'), 'Menüpunkt wird registriert'); $api = plugin_state()['api']['beispiel']['count']['fn'] ?? null; $check($api !== null && $api([], [])['count'] === 2, 'API-Endpunkt liefert Daten'); plugin_hook('cron.run'); $check(plugin_setting('beispiel', 'last_cron') !== null, 'cron.run-Hook läuft'); // ── Berechtigungen werden bei der Registrierung durchgesetzt ───────────────── $limited = $makeZip([ 'plugin.json' => $manifest(['slug' => 'begrenzt', 'name' => 'Begrenzt', 'permissions' => ['ui.menu']]), 'plugin.php' => "exec(\"UPDATE settings SET setting_value = 'x' WHERE setting_key = 'nie'\"); });\n" . "plugin_register_menu('Erlaubt', 'seite');\nplugin_register_api('verboten', fn() => ['x' => 1]);\nplugin_add_filter('ticket.view.sidebar', fn(\$h) => \$h . 'NICHT');\nplugin_add_hook('cron.run', fn() => null);", ]); $check(plugin_install_zip($pdo, $limited)['ok'], 'Plugin mit begrenzten Rechten installiert'); $activate('begrenzt'); $reboot(); $check(in_array('begrenzt', array_column(plugin_state()['menus'], 'plugin'), true), 'erlaubter Erweiterungspunkt (Menü) wird registriert'); $check(!isset(plugin_state()['api']['begrenzt']), 'nicht erlaubte API-Registrierung wird abgelehnt'); $check(!str_contains(plugin_filter('ticket.view.sidebar', '', ['ticketId' => 1]), 'NICHT'), 'nicht erlaubter Seitenleisten-Filter wird abgelehnt'); $check(!isset(plugin_state()['hooks']['ticket.created']) || !array_filter(plugin_state()['hooks']['ticket.created'][10] ?? [], fn($h) => $h['plugin'] === 'begrenzt'), 'nicht erlaubter Ticket-Hook wird abgelehnt'); $note = (string) $pdo->query("SELECT last_error FROM plugins WHERE slug='begrenzt'")->fetchColumn(); $check(str_contains($note, 'Nicht deklarierte Berechtigung'), 'abgelehnte Registrierung ist im Plugin vermerkt'); $check((int) $pdo->query("SELECT fail_count FROM plugins WHERE slug='begrenzt'")->fetchColumn() === 0 && (int) $pdo->query("SELECT is_active FROM plugins WHERE slug='begrenzt'")->fetchColumn() === 1, 'abgelehnte Registrierung deaktiviert das Plugin nicht'); $bad = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'unbekannt', 'permissions' => ['alles.lesen']]), 'plugin.php' => ' $manifest(['slug' => 'ohnedb', 'permissions' => []]), 'plugin.php' => ' 'CREATE TABLE x (id INT);']); plugin_install_zip($pdo, $nomig); $r = plugin_set_active($pdo, 'ohnedb', true, []); $check(!$r['ok'] && str_contains($r['error'], 'db.migrations'), 'Migrationen ohne Berechtigung db.migrations werden abgelehnt'); // Update mit zusätzlichen Berechtigungen: aktives Plugin wird angehalten, bis der Admin bestätigt $upd = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'begrenzt', 'name' => 'Begrenzt', 'version' => '1.1.0', 'permissions' => ['ui.menu', 'hooks.tickets']]), 'plugin.php' => "query("SELECT is_active FROM plugins WHERE slug='begrenzt'")->fetchColumn() === 0, 'Plugin mit neuen Berechtigungen wird bis zur Bestätigung angehalten'); $r = plugin_set_active($pdo, 'begrenzt', true, ['ui.menu']); $check(!$r['ok'], 'erneute Aktivierung verlangt die neuen Berechtigungen'); $check($activate('begrenzt')['ok'], 'nach Bestätigung läuft das Plugin wieder'); // Dateien von Hand ausgetauscht (FTP): Boot verweigert das Laden, solange die Rechte nicht bestätigt sind file_put_contents($pluginDir . '/begrenzt/plugin.json', $manifest(['slug' => 'begrenzt', 'name' => 'Begrenzt', 'version' => '1.2.0', 'permissions' => ['ui.menu', 'hooks.tickets', 'hooks.cron']])); $reboot(); $check(!isset(plugin_state()['loaded']['begrenzt']) && (int) $pdo->query("SELECT is_active FROM plugins WHERE slug='begrenzt'")->fetchColumn() === 0, 'per FTP ausgetauschte Dateien mit neuen Rechten werden nicht geladen'); // ── Fehler-Isolation ───────────────────────────────────────────────────────── $bad = $makeZip(['plugin.json' => $manifest(['permissions' => ['hooks.tickets']]), 'plugin.php' => "query('SELECT COUNT(*) FROM plugin_beispiel_log')->fetchColumn(); for ($i = 0; $i < 3; $i++) webhook_dispatch($pdo, 'ticket.created', 7); $check((int) $pdo->query('SELECT COUNT(*) FROM plugin_beispiel_log')->fetchColumn() === $logBefore + 3, 'ein fehlerhaftes Plugin stört andere Plugins nicht'); $row = $pdo->query("SELECT is_active, fail_count, last_error FROM plugins WHERE slug='kaputt'")->fetch(); $check((int) $row['is_active'] === 0 && (int) $row['fail_count'] >= PLUGIN_FAIL_LIMIT, 'fehlerhaftes Plugin wird nach ' . PLUGIN_FAIL_LIMIT . ' Fehlern deaktiviert'); $check(str_contains((string) $row['last_error'], 'Absichtlicher Testfehler'), 'Fehlertext wird gespeichert'); plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'syntax']), 'plugin.php' => 'query("SELECT fail_count FROM plugins WHERE slug='syntax'")->fetchColumn() >= 1, 'Syntaxfehler beim Laden wird abgefangen'); // ── Sicherheitsprüfungen beim Installieren ─────────────────────────────────── $evil = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'evil']), 'plugin.php' => ' ' $manifest(['slug' => 'exe']), 'plugin.php' => ' 'MZ'])); $check(!$r['ok'] && str_contains($r['error'], 'Unzulässige Datei'), 'unzulässige Dateitypen werden abgelehnt'); $r = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'hid']), 'plugin.php' => ' 'x'])); $check(!$r['ok'], 'versteckte Dateien (.htaccess) werden abgelehnt'); $r = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'core']), 'plugin.php' => ' $manifest(['slug' => '../x']), 'plugin.php' => ' ' $manifest(['slug' => 'neu', 'requires' => '99.0.0']), 'plugin.php' => ' $manifest(['slug' => 'signiert', 'name' => 'Signiert', 'version' => '2.0.0']), 'plugin.php' => 'query("SELECT signature_ok FROM plugins WHERE slug='signiert'")->fetchColumn() === 1, 'signiertes Plugin wird installiert und als signiert vermerkt'); $r = plugin_install_zip($pdo, $pkg, null, $sign($pkg, 'signiert', '2.0.0', $otherPriv)); $check(!$r['ok'] && str_contains($r['error'], 'Signatur ist ungültig'), 'Signatur mit fremdem Schlüssel wird abgelehnt'); $tampered = $makeZip(['plugin.json' => $manifest(['slug' => 'signiert', 'name' => 'Signiert', 'version' => '2.0.0']), 'plugin.php' => ' $manifest(['slug' => 'signiert', 'name' => 'Signiert', 'version' => '9.9.9']), 'plugin.php' => ' $manifest(['slug' => 'unsigniert']), 'plugin.php' => ' $manifest(['slug' => 'katalogplugin', 'name' => 'Aus dem Katalog', 'version' => '1.0.0', 'permissions' => ['ui.menu']]), 'plugin.php' => " 'katalogplugin', 'name' => 'Aus dem Katalog', 'version' => '1.0.0', 'description' => 'Test', 'author' => 'Tester', 'permissions' => ['ui.menu'], 'downloadUrl' => 'https://plugins.example.de/dl/katalogplugin-1.0.0.zip', 'sha256' => hash_file('sha256', $catalogPkg), 'signature' => $catalogSig]; $served = ['plugins' => [$entry, ['slug' => 'ungueltig!', 'version' => '1', 'sha256' => 'x'], array_merge($entry, ['slug' => 'httpdl', 'downloadUrl' => 'http://x.de/a.zip']), array_merge($entry, ['slug' => 'fremdrecht', 'permissions' => ['alles.lesen']])]]; $requests = []; $GLOBALS['deskhub_http_get'] = function (string $url, int $max) use (&$served, &$requests, $catalogPkg): array { $requests[] = $url; if (str_contains($url, 'catalog.php')) return [200, json_encode($served), null]; if (str_contains($url, 'katalogplugin-1.0.0.zip')) return [200, file_get_contents($catalogPkg), null]; return [404, null, 'HTTP 404']; }; $check(plugin_catalog_fetch()['ok'] === false, 'ohne Katalog-Adresse gibt es einen Hinweis'); set_setting('plugin_catalog_url', 'http://plugins.example.de/catalog.php'); $check(!plugin_catalog_fetch()['ok'], 'Katalog muss über https erreichbar sein'); set_setting('plugin_catalog_url', 'https://127.0.0.1/catalog.php'); $check(!plugin_catalog_fetch()['ok'], 'Katalog auf interner Adresse wird abgelehnt'); set_setting('plugin_catalog_url', 'https://plugins.example.de/catalog.php?type=plugins'); $cat = plugin_catalog_fetch(); $check($cat['ok'] && count($cat['plugins']) === 1 && $cat['plugins'][0]['slug'] === 'katalogplugin', 'Katalog wird gelesen, ungültige Einträge (Slug, http-Download, unbekannte Rechte) werden verworfen'); $r = plugin_catalog_install($pdo, 'katalogplugin'); $check($r['ok'] && $r['signed'] && (int) $pdo->query("SELECT COUNT(*) FROM plugins WHERE slug='katalogplugin' AND source='catalog' AND signature_ok=1")->fetchColumn() === 1, 'Installation aus dem Katalog gelingt (Prüfsumme + Signatur)'); $again = plugin_catalog_install($pdo, 'katalogplugin'); $check(!$again['ok'] && str_contains($again['error'], 'bereits'), 'ein bereits installiertes Katalog-Plugin lässt sich nicht noch einmal installieren'); $check(!plugin_catalog_install($pdo, 'gibtesnicht')['ok'], 'Plugin außerhalb des Katalogs kann nicht installiert werden'); // Update aus dem Katalog mit einem Klick (ohne das Plugin erneut zu suchen): „Alle aktualisieren“ und das Update eines einzelnen Plugins $check(plugin_catalog_update_all($pdo) === ['updated' => [], 'failed' => []], 'Ohne neuere Version im Katalog gibt es nichts zu aktualisieren'); $pkg11 = $makeZip(['plugin.json' => $manifest(['slug' => 'katalogplugin', 'name' => 'Aus dem Katalog', 'version' => '1.1.0', 'permissions' => ['ui.menu']]), 'plugin.php' => " '1.1.0', 'downloadUrl' => 'https://plugins.example.de/dl/katalogplugin-1.1.0.zip', 'sha256' => hash_file('sha256', $pkg11), 'signature' => $sign($pkg11, 'katalogplugin', '1.1.0', $privKey)]); $GLOBALS['deskhub_http_get'] = function (string $url, int $max) use (&$served11, $pkg11): array { if (str_contains($url, 'catalog.php')) return [200, json_encode($served11), null]; if (str_contains($url, 'katalogplugin-1.1.0.zip')) return [200, file_get_contents($pkg11), null]; return [404, null, 'HTTP 404']; }; plugin_catalog_cached(true); $ups = plugin_updates_available($pdo); $check(count($ups) === 1 && $ups[0]['slug'] === 'katalogplugin' && $ups[0]['installed'] === '1.0.0' && $ups[0]['available'] === '1.1.0', 'Update 1.0.0 → 1.1.0 wird erkannt'); $res = plugin_catalog_update_all($pdo); $check($res['updated'] === ['Aus dem Katalog 1.1.0'] && $res['failed'] === [], 'Alle aktualisieren: Plugin wird mit einem Aufruf auf 1.1.0 gebracht'); $check($pdo->query("SELECT version FROM plugins WHERE slug='katalogplugin'")->fetchColumn() === '1.1.0' && plugin_updates_available($pdo) === [], 'Die Version ist 1.1.0 und der Hinweis „Update verfügbar“ verschwindet'); // Fehler bei einem Plugin stoppt nichts und wird gemeldet (manipulierte Prüfsumme) $served11['plugins'][0]['version'] = '1.2.0'; $served11['plugins'][0]['sha256'] = str_repeat('b', 64); $served11['plugins'][0]['downloadUrl'] = 'https://plugins.example.de/dl/katalogplugin-1.1.0.zip'; plugin_catalog_cached(true); $res = plugin_catalog_update_all($pdo); $check($res['updated'] === [] && isset($res['failed']['katalogplugin']) && $pdo->query("SELECT version FROM plugins WHERE slug='katalogplugin'")->fetchColumn() === '1.1.0', 'Fehlerhaftes Update wird gemeldet und lässt die installierte Version unverändert'); unset($GLOBALS['deskhub_http_get']); $GLOBALS['deskhub_http_get'] = function (string $url, int $max) use (&$served, &$requests, $catalogPkg): array { $requests[] = $url; if (str_contains($url, 'catalog.php')) return [200, json_encode($served), null]; if (str_contains($url, 'katalogplugin-1.0.0.zip')) return [200, file_get_contents($catalogPkg), null]; return [404, null, 'HTTP 404']; }; plugin_catalog_cached(true); $pdo->exec("UPDATE plugins SET version = '1.0.0' WHERE slug = 'katalogplugin'"); plugin_uninstall($pdo, 'katalogplugin'); // für die folgenden Fehlerfall-Tests wieder frei machen $served['plugins'][0]['sha256'] = str_repeat('a', 64); $r = plugin_catalog_install($pdo, 'katalogplugin'); $check(!$r['ok'] && str_contains($r['error'], 'Prüfsumme'), 'abweichende Prüfsumme (manipulierter Download) wird abgelehnt'); $served['plugins'][0]['sha256'] = hash_file('sha256', $catalogPkg); $served['plugins'][0]['version'] = '1.0.1'; // Katalog verspricht eine andere Version als im Paket steckt $GLOBALS['deskhub_http_get'] = function (string $url, int $max) use (&$served, $catalogPkg): array { if (str_contains($url, 'catalog.php')) return [200, json_encode($served), null]; return [200, file_get_contents($catalogPkg), null]; }; $r = plugin_catalog_install($pdo, 'katalogplugin'); $check(!$r['ok'] && str_contains($r['error'], 'passt nicht zur Katalog-Angabe'), 'Paket, das nicht zur Katalog-Angabe passt, wird abgelehnt'); $served['plugins'][0]['version'] = '1.0.0'; $served['plugins'][0]['downloadUrl'] = 'https://10.0.0.5/x.zip'; $check(!plugin_catalog_install($pdo, 'katalogplugin')['ok'], 'Download von interner Adresse wird abgelehnt'); unset($GLOBALS['deskhub_http_get']); // ── Offizieller Katalog und geprüfter Modus ────────────────────────────────── $check(!plugin_official_configured() && !plugin_strict_mode(), 'ohne Vorgaben gilt kein strenger Modus'); $GLOBALS['deskhub_catalog_defaults'] = ['plugins_url' => 'https://official.example.de/catalog.php?type=plugins', 'themes_url' => 'https://official.example.de/catalog.php?type=themes', 'publisher_key' => $pubKey]; set_setting('plugin_catalog_url', 'https://eigener.example.de/catalog.php'); set_setting('plugin_publisher_key', $otherPub); set_setting('plugin_require_signature', '0'); $check(plugin_official_configured() && plugin_strict_mode(), 'mit Vorgaben gilt der geprüfte Modus'); $check(plugin_catalog_url() === 'https://official.example.de/catalog.php?type=plugins', 'im geprüften Modus gilt die offizielle Katalog-Adresse, nicht die eigene'); $check(plugin_publisher_key() === trim($pubKey) && plugin_signatures_required('catalog') && !plugin_signatures_required('upload'), 'bei festem Katalog gilt der offizielle Schlüssel; aus dem Katalog ist Signatur Pflicht, bei eigenen Uploads nicht'); $r = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'handgemacht']), 'plugin.php' => 'query("SELECT signature_ok FROM plugins WHERE slug='handgemacht'")->fetchColumn() === 0 && $pdo->query("SELECT source FROM plugins WHERE slug='handgemacht'")->fetchColumn() === 'upload', 'ein eigenes, unsigniertes Plugin lässt sich auch bei festem Katalog hochladen und ist als „nicht geprüft“ vermerkt'); $r = plugin_install_zip($pdo, $pkg, null, $sign($pkg, 'signiert', '2.0.0', $privKey)); $check($r['ok'] && $r['signed'] === true, 'ein signierter Upload wird als signiert erkannt'); $r = plugin_install_zip($pdo, $pkg, null, $sign($pkg, 'signiert', '2.0.0', $otherPriv)); $check(!$r['ok'] && str_contains($r['error'], 'Signatur ist ungültig'), 'eine ungültige Signatur beim Upload wird auch bei festem Katalog abgelehnt'); $check(plugin_set_active($pdo, 'handgemacht', true)['ok'], 'ein eigenes Plugin ohne Berechtigungen lässt sich aktivieren'); $offPkg = $makeZip(['plugin.json' => $manifest(['slug' => 'offiziell', 'name' => 'Offiziell', 'version' => '1.0.0', 'permissions' => ['ui.menu']]), 'plugin.php' => " 'offiziell', 'name' => 'Offiziell', 'version' => '1.0.0', 'description' => '', 'author' => '', 'permissions' => ['ui.menu'], 'downloadUrl' => 'https://official.example.de/dl/offiziell.zip', 'sha256' => hash_file('sha256', $offPkg), 'signature' => $sign($offPkg, 'offiziell', '1.0.0', $privKey)]; $offServed = ['plugins' => [$offEntry, array_merge($offEntry, ['slug' => 'unsigniert', 'signature' => ''])]]; $fetches = 0; $GLOBALS['deskhub_http_get'] = function (string $url, int $max) use (&$offServed, $offPkg, &$fetches): array { if (str_contains($url, 'catalog.php')) { $fetches++; return [200, json_encode($offServed), null]; } return [200, file_get_contents($offPkg), null]; }; $c1 = plugin_catalog_cached(); $c2 = plugin_catalog_cached(); $check($c1['ok'] && $c2['ok'] && $fetches === 1, 'Katalog wird eine Stunde zwischengespeichert (ein Abruf für zwei Aufrufe)'); plugin_catalog_cached(true); $check($fetches === 2, 'Aktualisieren erzwingt einen neuen Abruf'); // Zwischenspeicher ist kurz (5 Minuten), die Plugin-Seite verlangt höchstens eine Minute Alter, Serverausfall zeigt den letzten Stand $setAge = function (int $seconds): void { $c = json_decode((string) get_setting('plugin_catalog_cache', ''), true); $c['at'] = time() - $seconds; set_setting('plugin_catalog_cache', json_encode($c)); }; $fetches0 = $fetches; $setAge(200); plugin_catalog_cached(); $check($fetches === $fetches0, 'Katalog ist nach 3 Minuten noch frisch genug (kein Abruf)'); plugin_catalog_cached(false, 60); $check($fetches === $fetches0 + 1, 'die Plugin-Seite verlangt höchstens eine Minute Alter: neuer Abruf beim Öffnen'); $setAge(400); plugin_catalog_cached(); $check($fetches === $fetches0 + 2, 'nach über 5 Minuten wird der Katalog von selbst neu geladen (kein Klick auf „Katalog aktualisieren“ nötig)'); $setAge(400); $GLOBALS['deskhub_http_get'] = function (string $url, int $max) use (&$fetches): array { $fetches++; return [0, null, 'Verbindung fehlgeschlagen']; }; $stale = plugin_catalog_cached(); $check($stale['ok'] && !empty($stale['stale']) && count($stale['plugins']) === 2 && $fetches === $fetches0 + 3, 'Server nicht erreichbar: die Seite zeigt den letzten Stand (stale)'); plugin_catalog_cached(); plugin_catalog_cached(false, 60); $check($fetches === $fetches0 + 3, 'danach wird zwei Minuten lang nicht erneut gefragt (die Seite wartet nicht bei jedem Aufruf)'); $btn = plugin_catalog_cached(true); $check(!$btn['ok'] && $fetches === $fetches0 + 4, 'der Knopf „Katalog aktualisieren“ fragt trotzdem und meldet den Fehler'); $GLOBALS['deskhub_http_get'] = function (string $url, int $max) use (&$offServed, $offPkg, &$fetches): array { if (str_contains($url, 'catalog.php')) { $fetches++; return [200, json_encode($offServed), null]; } return [200, file_get_contents($offPkg), null]; }; $setAge(400); set_setting('plugin_catalog_fail_at', '0'); $ok = plugin_catalog_cached(); $check($ok['ok'] && empty($ok['stale']), 'nach der Erholung des Servers wieder ein frischer Katalog'); $r = plugin_catalog_install($pdo, 'offiziell'); $check($r['ok'] && $r['signed'], 'Installation aus dem offiziellen Katalog gelingt: ' . json_encode($r)); plugin_uninstall($pdo, 'offiziell'); $offServed['plugins'][0]['signature'] = ''; $r = plugin_catalog_install($pdo, 'offiziell'); $check(!$r['ok'] && str_contains($r['error'], 'müssen signiert sein'), 'unsigniertes Katalog-Plugin wird im geprüften Modus abgelehnt'); $offServed['plugins'][0]['signature'] = $sign($offPkg, 'offiziell', '1.0.0', $otherPriv); // Signatur eines Fremden $r = plugin_catalog_install($pdo, 'offiziell'); $check(!$r['ok'] && str_contains($r['error'], 'Signatur ist ungültig'), 'Signatur eines fremden Herausgebers wird abgelehnt'); // Neuere Katalogversion eines installierten Plugins darf installiert werden $offPkg2 = $makeZip(['plugin.json' => $manifest(['slug' => 'offiziell', 'name' => 'Offiziell', 'version' => '1.1.0', 'permissions' => ['ui.menu']]), 'plugin.php' => " '1.1.0', 'sha256' => hash_file('sha256', $offPkg2), 'signature' => $sign($offPkg2, 'offiziell', '1.1.0', $privKey)] + $offEntry; $GLOBALS['deskhub_http_get'] = function (string $url, int $max) use (&$offServed, $offPkg2): array { if (str_contains($url, 'catalog.php')) return [200, json_encode($offServed), null]; return [200, file_get_contents($offPkg2), null]; }; plugin_catalog_cached(true); $r = plugin_catalog_install($pdo, 'offiziell'); $check($r['ok'] && $r['updated'] && $r['version'] === '1.1.0', 'eine neuere Katalogversion wird als Update installiert: ' . json_encode($r)); $r = plugin_catalog_install($pdo, 'offiziell'); $check(!$r['ok'] && str_contains($r['error'], 'bereits'), 'dieselbe Version danach nicht noch einmal'); unset($GLOBALS['deskhub_http_get']); // Eigene (nicht signierte) Plugins laufen weiter, auch bei festem Katalog $pdo->exec("UPDATE plugins SET is_active = 1, approved_permissions = 'ui.menu' WHERE slug = 'offiziell'"); $pdo->exec("UPDATE plugins SET is_active = 1, approved_permissions = 'hooks.cron,hooks.tickets,ui.menu' WHERE slug = 'begrenzt'"); $pdo->exec("UPDATE plugins SET signature_ok = 0 WHERE slug = 'begrenzt'"); $reboot(); $check(isset(plugin_state()['loaded']['begrenzt']) && (int) $pdo->query("SELECT is_active FROM plugins WHERE slug='begrenzt'")->fetchColumn() === 1, 'ein eigenes, nicht signiertes Plugin wird auch bei festem Katalog geladen'); $check(isset(plugin_state()['loaded']['offiziell']), 'signiertes Plugin aus dem Katalog wird geladen'); // Der Katalog ist fest: Einstellungen schalten den strengen Modus nicht ab set_setting('plugin_allow_custom_sources', '1'); $check(plugin_strict_mode(), 'die frühere Einstellung "Eigene Quellen erlauben" hat keine Wirkung mehr'); $check(plugin_catalog_url() === 'https://official.example.de/catalog.php?type=plugins' && plugin_publisher_key() === trim($pubKey), 'eigene Katalog-Adresse und eigener Schlüssel aus den Einstellungen werden ignoriert'); set_setting('plugin_allow_custom_sources', '0'); unset($GLOBALS['deskhub_catalog_defaults']); set_setting('plugin_catalog_url', ''); set_setting('plugin_publisher_key', ''); set_setting('plugin_catalog_cache', ''); // ── Aktualisieren, Notausschalter, Entfernen ───────────────────────────────── $upd = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'beispiel', 'name' => 'Beispiel', 'version' => '1.1.0']), 'plugin.php' => 'query("SELECT version FROM plugins WHERE slug='beispiel'")->fetchColumn() === '1.1.0', 'Plugin lässt sich aktualisieren'); touch($pluginDir . '/.disabled'); $reboot(); $check(plugin_state()['loaded'] === [], 'Notausschalter (.disabled) lädt keine Plugins'); unlink($pluginDir . '/.disabled'); $un = plugin_uninstall($pdo, 'beispiel', false); $check($un['ok'] && !is_dir($pluginDir . '/beispiel'), 'Plugin wird entfernt'); $check((int) $pdo->query("SELECT COUNT(*) FROM plugins WHERE slug='beispiel'")->fetchColumn() === 0, 'Datenbankeintrag verschwindet'); $check(plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'beispiel']), 'plugin.php' => ' 'https://official.example.de/catalog.php?type=plugins', 'themes_url' => 'https://official.example.de/catalog.php?type=themes', 'publisher_key' => $pubKey, 'access_key' => $KEY]; $seenHeaders = []; $GLOBALS['deskhub_http_get'] = function (string $url, int $max, array $headers = []) use (&$seenHeaders): array { $seenHeaders[$url] = $headers; return [200, '{}', null]; }; foreach (['https://official.example.de/catalog.php?type=plugins', 'https://official.example.de/download.php?type=plugin&slug=x', 'https://OFFICIAL.example.de/preview.php?file=a.png', 'https://fremd.example.org/irgendwas', 'https://official.example.de.boese.example.org/x', 'http://official.example.de/catalog.php'] as $u) plugin_http_get($u, 1000); $expect = ['X-DeskHub-Key: ' . $KEY]; $check($seenHeaders['https://official.example.de/catalog.php?type=plugins'] === $expect && $seenHeaders['https://official.example.de/download.php?type=plugin&slug=x'] === $expect && $seenHeaders['https://OFFICIAL.example.de/preview.php?file=a.png'] === $expect, 'Katalog, Download und Vorschau beim offiziellen Server bekommen den Zugangsschlüssel'); $check($seenHeaders['https://fremd.example.org/irgendwas'] === [] && $seenHeaders['https://official.example.de.boese.example.org/x'] === [] && $seenHeaders['http://official.example.de/catalog.php'] === [], 'an fremde Adressen, ähnlich klingende Domains und unverschlüsselte Verbindungen wird der Schlüssel nie geschickt'); $GLOBALS['deskhub_catalog_defaults']['access_key'] = ''; plugin_http_get('https://official.example.de/catalog.php?type=plugins', 1000); $check($seenHeaders['https://official.example.de/catalog.php?type=plugins'] === [], 'ohne eingetragenen Zugangsschlüssel wird kein Header gesendet'); unset($GLOBALS['deskhub_http_get'], $GLOBALS['deskhub_catalog_defaults']); // ── Ohne Testmodus lässt sich der Katalog nicht überschreiben (echter Prozess mit den echten Vorgaben) ── $probe = tempnam(sys_get_temp_dir(), 'probe') . '.php'; file_put_contents($probe, " 'https://boese.example/c', 'publisher_key' => 'x']; require " . var_export(dirname(__DIR__) . '/includes/bootstrap.php', true) . "; " . "set_setting('plugin_catalog_url', 'https://boese.example/c'); set_setting('plugin_publisher_key', 'x'); set_setting('plugin_allow_custom_sources', '1'); " . "echo json_encode(['strict' => plugin_strict_mode(), 'url' => plugin_catalog_url(), 'key' => plugin_publisher_key() === catalog_defaults()['publisher_key'], 'official' => plugin_official_configured()]);"); $envClean = array_diff_key(getenv(), ['DESKHUB_TESTING' => 1, 'DESKHUB_CATALOG_DEFAULTS' => 1]); $envClean['DESKHUB_CATALOG_DEFAULTS'] = $pluginDir . '/gibtesnicht.php'; // ohne DESKHUB_TESTING muss auch das wirkungslos sein $ph = proc_open([PHP_BINARY, $probe], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, $envClean); $probeOut = json_decode((string) stream_get_contents($pipes[1]), true) ?: []; proc_close($ph); @unlink($probe); $real = (array) require dirname(__DIR__) . '/includes/catalog_defaults.php'; $check(($probeOut['strict'] ?? null) === true && ($probeOut['official'] ?? null) === true && ($probeOut['url'] ?? '') === $real['plugins_url'] && ($probeOut['key'] ?? false) === true, 'ohne Testmodus ignorieren Überschreibungsversuche (Globals, Umgebungsvariable, Einstellungen) die fest eingebauten Vorgaben: ' . json_encode($probeOut)); $check($real['plugins_url'] !== '' && str_contains($real['publisher_key'], 'BEGIN PUBLIC KEY'), 'die ausgelieferte includes/catalog_defaults.php enthält Katalog und Schlüssel'); // 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($pluginDir); @unlink($cnf); if ($failures) { fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n"); exit(1); } echo "Plugin system checks passed.\n";