Upload via GUI (335 Dateien)
This commit is contained in:
@@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft die Mehrsprachigkeit: Sprachpakete als Plugin, Übersetzung der fertigen Seite (Text, Attribute, Knöpfe, JavaScript-Texte,
|
||||||
|
* Muster mit {} und {#}), Sammelmodus, Sprachwechsler mit Flaggen und den Wechsel über lang.php.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$pluginDir = sys_get_temp_dir() . '/deskhub-i18n-' . bin2hex(random_bytes(4));
|
||||||
|
mkdir($pluginDir, 0755, true);
|
||||||
|
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
|
||||||
|
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';
|
||||||
|
|
||||||
|
$failures = [];
|
||||||
|
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||||
|
if (!$ok) $failures[] = $what;
|
||||||
|
};
|
||||||
|
$pdo = get_db();
|
||||||
|
|
||||||
|
// ── Ohne Sprachpaket: nur Deutsch, kein Wechsler ────────────────────────────
|
||||||
|
$check(array_keys(i18n_languages()) === ['de'] && i18n_switcher_html() === '' && i18n_current() === 'de', 'ohne Sprachpaket gibt es nur Deutsch und keinen Wechsler');
|
||||||
|
|
||||||
|
// ── Sprachpaket installieren: eigenes kleines Paket und das mitgelieferte English ──
|
||||||
|
$mkZip = function (array $files) {
|
||||||
|
$path = sys_get_temp_dir() . '/lang-' . bin2hex(random_bytes(3)) . '.zip';
|
||||||
|
$z = new ZipArchive();
|
||||||
|
$z->open($path, ZipArchive::CREATE);
|
||||||
|
foreach ($files as $name => $content) $z->addFromString($name, $content);
|
||||||
|
$z->close();
|
||||||
|
return $path;
|
||||||
|
};
|
||||||
|
$flag = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2"><rect width="3" height="2" fill="#009246"/></svg>';
|
||||||
|
$strings = [
|
||||||
|
'_info' => 'Test',
|
||||||
|
'Neues Ticket' => 'New ticket',
|
||||||
|
'Ticket erstellen' => 'Create ticket',
|
||||||
|
'Betreff' => 'Subject',
|
||||||
|
'Kurze Zusammenfassung' => 'Short summary',
|
||||||
|
'Willkommen, {}' => 'Welcome, {}',
|
||||||
|
'{} · erstellt am {}' => '{1} · created {2}',
|
||||||
|
'{#} Treffer' => '{} hits',
|
||||||
|
'Wirklich löschen?' => 'Really delete?',
|
||||||
|
'Ein <b>Test</b>' => 'A test',
|
||||||
|
'Mit & Zeichen' => 'With & signs',
|
||||||
|
'Leer' => '',
|
||||||
|
];
|
||||||
|
$pkg = $mkZip([
|
||||||
|
'plugin.json' => json_encode(['slug' => 'testsprache', 'name' => 'Testsprache', 'version' => '1.0.0', 'entry' => 'plugin.php', 'permissions' => [], 'language' => ['code' => 'xx', 'name' => 'Testsprache', 'flag' => 'flag.svg', 'strings' => 'strings.json', 'htmlLang' => 'xx-TS']]),
|
||||||
|
'plugin.php' => "<?php\n",
|
||||||
|
'flag.svg' => $flag,
|
||||||
|
'strings.json' => json_encode($strings, JSON_UNESCAPED_UNICODE),
|
||||||
|
]);
|
||||||
|
$r = plugin_install_zip($pdo, $pkg);
|
||||||
|
$check($r['ok'] && $r['slug'] === 'testsprache', 'Sprachpaket lässt sich installieren: ' . json_encode($r));
|
||||||
|
$check(count(i18n_languages(true)) === 1, 'ein nicht aktiviertes Sprachpaket zählt nicht');
|
||||||
|
$act = plugin_set_active($pdo, 'testsprache', true, []);
|
||||||
|
$check($act['ok'], 'Sprachpaket braucht keine Berechtigungen und lässt sich aktivieren: ' . json_encode($act));
|
||||||
|
$langs = i18n_languages(true);
|
||||||
|
$check(array_keys($langs) === ['de', 'xx'] && $langs['xx']['name'] === 'Testsprache' && $langs['xx']['htmlLang'] === 'xx-TS' && str_starts_with($langs['xx']['flag'], 'data:image/svg+xml;base64,'), 'Sprachen: Deutsch (eingebaut) und das Paket mit Flagge als Datenadresse');
|
||||||
|
$check(i18n_dictionary('xx')['count'] === 10 && !isset(i18n_dictionary('xx')['exact']['Leer']), 'Wörterbuch: leere Werte und _info werden ignoriert');
|
||||||
|
|
||||||
|
// ── Auswahl der Sprache ─────────────────────────────────────────────────────
|
||||||
|
$check(i18n_current() === 'de', 'ohne Cookie gilt die Standardsprache');
|
||||||
|
$_COOKIE[I18N_COOKIE] = 'xx';
|
||||||
|
$check(i18n_current() === 'xx', 'Cookie wählt die Sprache');
|
||||||
|
$_COOKIE[I18N_COOKIE] = 'zz';
|
||||||
|
$check(i18n_current() === 'de', 'unbekannter Sprachcode im Cookie wird ignoriert');
|
||||||
|
unset($_COOKIE[I18N_COOKIE]);
|
||||||
|
set_setting('default_language', 'xx');
|
||||||
|
$check(i18n_default() === 'xx' && i18n_current() === 'xx', 'Standardsprache aus den Einstellungen');
|
||||||
|
set_setting('default_language', 'qq');
|
||||||
|
$check(i18n_default() === 'de', 'unbekannte Standardsprache fällt auf Deutsch zurück');
|
||||||
|
set_setting('default_language', 'de');
|
||||||
|
|
||||||
|
// ── Übersetzung ─────────────────────────────────────────────────────────────
|
||||||
|
$t = fn(string $html) => i18n_translate_html($html, 'xx');
|
||||||
|
$check($t('<h1>Neues Ticket</h1>') === '<h1>New ticket</h1>', 'Text zwischen Tags');
|
||||||
|
$check($t("<p>\n Neues Ticket \n</p>") === "<p>\n New ticket \n</p>", 'Leerraum um den Text bleibt, Leerraum im Text zählt nicht');
|
||||||
|
$check($t('<input placeholder="Kurze Zusammenfassung" title="Betreff">') === '<input placeholder="Short summary" title="Subject">', 'Attribute placeholder und title');
|
||||||
|
$check($t('<input type="submit" value="Ticket erstellen">') === '<input type="submit" value="Create ticket">', 'Beschriftung von Knöpfen');
|
||||||
|
$check($t('<input type="text" value="Ticket erstellen">') === '<input type="text" value="Ticket erstellen">', 'Eingaben (value) bleiben unberührt');
|
||||||
|
$check($t('<option value="Betreff">Betreff</option>') === '<option value="Betreff">Subject</option>', 'Optionstext ja, Wert nein');
|
||||||
|
$check($t('<textarea>Neues Ticket</textarea>') === '<textarea>Neues Ticket</textarea>', 'Inhalt von Textfeldern bleibt');
|
||||||
|
$check($t('<p>Unbekannter Text</p>') === '<p>Unbekannter Text</p>', 'ohne Übersetzung bleibt es deutsch');
|
||||||
|
$check($t('<p>Willkommen, Erika</p>') === '<p>Welcome, Erika</p>', 'Muster mit {}');
|
||||||
|
$check($t('<p>Technik · erstellt am 24.10.2026</p>') === '<p>Technik · created 24.10.2026</p>', 'Muster mit {1} und {2} (Reihenfolge)');
|
||||||
|
$check($t('<p>12 Treffer</p>') === '<p>12 hits</p>' && $t('<p>viele Treffer</p>') === '<p>viele Treffer</p>', 'Muster mit {#} nur für Zahlen');
|
||||||
|
$check($t('<p>Mit & Zeichen</p>') === '<p>With & signs</p>', 'Entities im Schlüssel und im Ergebnis');
|
||||||
|
$check($t('<p>Wirklich löschen?</p>') === '<p>Really delete?</p>', 'Fragezeichen');
|
||||||
|
$check($t('<button onclick="return confirm(\'Wirklich löschen?\')">x</button>') === '<button onclick="return confirm(\'Really delete?\')">x</button>', 'confirm() in Ereignis-Attributen');
|
||||||
|
$check($t('<script>if (confirm("Wirklich löschen?")) go(); var a = "Neues Ticket"; var b = "Anderes";</script>') === '<script>if (confirm("Really delete?")) go(); var a = "New ticket"; var b = "Anderes";</script>', 'Textliterale in Skripten (nur genaue Treffer)');
|
||||||
|
$check($t('<script src="/x.js"></script>') === '<script src="/x.js"></script>', 'Skript-Dateien bleiben');
|
||||||
|
$check($t('<style>.a::after{content:"Neues Ticket"}</style>') === '<style>.a::after{content:"Neues Ticket"}</style>', 'Stile bleiben');
|
||||||
|
$check($t('<html lang="de"><body>x</body></html>') === '<html lang="xx-TS"><body>x</body></html>', 'lang-Attribut passt zur Sprache');
|
||||||
|
$check($t('<img src="data:image/png;base64,AAAA" alt="Betreff">') === '<img src="data:image/png;base64,AAAA" alt="Subject">', 'Bilder: alt ja, src nein');
|
||||||
|
$check($t('<!-- Neues Ticket --><p>Neues Ticket</p>') === '<!-- Neues Ticket --><p>New ticket</p>', 'Kommentare bleiben');
|
||||||
|
$check($t('<p>Neues <b>Ticket</b></p>') === '<p>Neues <b>Ticket</b></p>', 'Text mit Markup mittendrin wird nicht zerhackt (nur ganze Textstücke)');
|
||||||
|
$check(i18n_translate_html('<p>Neues Ticket</p>', 'de') === '<p>Neues Ticket</p>', 'Deutsch verändert nichts');
|
||||||
|
|
||||||
|
// ── Sammelmodus ─────────────────────────────────────────────────────────────
|
||||||
|
$seen = [];
|
||||||
|
i18n_translate_html('<h1>Neues Ticket</h1><p>Völlig unbekannter Satz hier</p><p>42</p><a href="x">https://beispiel.de</a><span title="Ein Tooltip hier">x</span><script>if (confirm("Soll das wirklich weg?")) {}</script>', 'xx', $seen);
|
||||||
|
$check(isset($seen['Völlig unbekannter Satz hier']) && isset($seen['Ein Tooltip hier']) && isset($seen['Soll das wirklich weg?']) && !isset($seen['Neues Ticket']) && !isset($seen['42']) && !isset($seen['https://beispiel.de']), 'gesammelt wird nur Unübersetztes, keine Zahlen und Adressen: ' . json_encode(array_keys($seen)));
|
||||||
|
i18n_remember(array_keys($seen));
|
||||||
|
i18n_remember(['Völlig unbekannter Satz hier']);
|
||||||
|
$row = $pdo->query("SELECT hits FROM i18n_missing WHERE text = 'Völlig unbekannter Satz hier'")->fetchColumn();
|
||||||
|
$check((int) $pdo->query('SELECT COUNT(*) FROM i18n_missing')->fetchColumn() === 3 && (int) $row === 2, 'unübersetzte Texte werden gespeichert und gezählt');
|
||||||
|
|
||||||
|
// ── Ausgabe-Filter ──────────────────────────────────────────────────────────
|
||||||
|
$GLOBALS['__i18n_code'] = 'xx';
|
||||||
|
$check(i18n_output_filter('<!doctype html><html lang="de"><body><h1>Neues Ticket</h1></body></html>') === '<!doctype html><html lang="xx-TS"><body><h1>New ticket</h1></body></html>', 'Ausgabe-Filter übersetzt die Seite');
|
||||||
|
$check(i18n_output_filter('{"a":"Neues Ticket"}') === false, 'JSON bleibt unverändert (keine HTML-Tags)');
|
||||||
|
|
||||||
|
// ── Sprachwechsler ──────────────────────────────────────────────────────────
|
||||||
|
$_SERVER['REQUEST_URI'] = '/ticket_new.php?x=1';
|
||||||
|
$sw = i18n_switcher_html();
|
||||||
|
$check(substr_count($sw, '<img') === 2 && str_contains($sw, '/lang.php?l=xx&back=%2Fticket_new.php%3Fx%3D1') && str_contains($sw, '/lang.php?l=de') && str_contains($sw, 'aria-current'), 'Wechsler: zwei Flaggen, Links mit Rücksprung, aktive Sprache markiert');
|
||||||
|
$check(str_contains(i18n_switcher_html('floating'), 'position:fixed') && !str_contains($sw, 'position:fixed'), 'Wechsler: feste Position nur für Anmeldeseiten');
|
||||||
|
$check(i18n_safe_back('/staff/tickets.php?a=1') === '/staff/tickets.php?a=1' && i18n_safe_back('//evil.example') === '/' && i18n_safe_back('https://evil.example') === '/' && i18n_safe_back("/a\r\nSet-Cookie: x") === '/' && i18n_safe_back('/\\evil') === '/' && i18n_safe_back('') === '/', 'Rücksprung nur auf Adressen dieser Seite');
|
||||||
|
|
||||||
|
// ── Das mitgelieferte English-Paket ─────────────────────────────────────────
|
||||||
|
$enSrc = plugin_source_dir_or_skip('lang-en', 'i18n');
|
||||||
|
$deSrc = plugin_source_dir_or_skip('lang-de', 'i18n');
|
||||||
|
foreach ([$enSrc, $deSrc] as $src) {
|
||||||
|
$zp = sys_get_temp_dir() . '/lp-' . bin2hex(random_bytes(3)) . '.zip';
|
||||||
|
$z = new ZipArchive();
|
||||||
|
$z->open($zp, ZipArchive::CREATE);
|
||||||
|
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) $z->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1)));
|
||||||
|
$z->close();
|
||||||
|
$ri = plugin_install_zip($pdo, $zp);
|
||||||
|
$check($ri['ok'], 'mitgeliefertes Paket installierbar: ' . ($ri['error'] ?? $ri['slug']));
|
||||||
|
$slug = (string) ($ri['slug'] ?? '');
|
||||||
|
$check(plugin_set_active($pdo, $slug, true, [])['ok'], "Paket {$slug} aktivierbar");
|
||||||
|
@unlink($zp);
|
||||||
|
}
|
||||||
|
$langs = i18n_languages(true);
|
||||||
|
$check(isset($langs['en'], $langs['de'], $langs['xx']) && $langs['de']['slug'] === 'lang-de' && $langs['en']['name'] === 'English' && $langs['en']['flag'] !== '', 'English und Deutsch (als Plugin) sind da');
|
||||||
|
$en = i18n_dictionary('en');
|
||||||
|
$check($en['count'] > 900, 'English hat viele Übersetzungen: ' . $en['count']);
|
||||||
|
$check(i18n_translate_html('<h1>Neues Ticket erstellen</h1><label>Betreff</label><p>Willkommen, Erika</p><a>Meine Tickets – Test</a>', 'en') === '<h1>Create new ticket</h1><label>Subject</label><p>Welcome, Erika</p><a>My tickets – Test</a>', 'English übersetzt Kundenportal-Texte');
|
||||||
|
$check(i18n_translate_html('<th>Abteilungen</th><button>Speichern</button><span>Abmelden</span>', 'en') === '<th>Departments</th><button>Save</button><span>Sign out</span>', 'English übersetzt Texte des Mitarbeiterbereichs');
|
||||||
|
|
||||||
|
// ── Aufräumen ───────────────────────────────────────────────────────────────
|
||||||
|
@unlink($pkg);
|
||||||
|
foreach (scandir($pluginDir) ?: [] as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
|
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
|
||||||
|
}
|
||||||
|
@rmdir($pluginDir);
|
||||||
|
|
||||||
|
if ($failures) {
|
||||||
|
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
echo 'Mehrsprachigkeit checks passed.' . "\n";
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft die Mehrsprachigkeit auf echten Seiten: Sprachwechsel über lang.php, übersetzte Kundenportal- und Mitarbeiterseiten, Sammelmodus.
|
||||||
|
*/
|
||||||
|
|
||||||
|
date_default_timezone_set('Europe/Berlin'); // wie in der Test-Installation
|
||||||
|
$root = dirname(__DIR__);
|
||||||
|
$work = sys_get_temp_dir() . '/deskhub-i18np-' . bin2hex(random_bytes(4));
|
||||||
|
$tmp = $work . '/inst';
|
||||||
|
mkdir($tmp, 0755, true);
|
||||||
|
$webPort = random_int(37500, 37999);
|
||||||
|
$skip = '#^(config/config\.php|database/[^/]+\.sqlite[^/]*|backups|uploads|tests|plugins/[^/]+|\.git)(/|$)#';
|
||||||
|
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST) as $f) {
|
||||||
|
$rel = str_replace(chr(92), '/', substr($f->getPathname(), strlen($root) + 1));
|
||||||
|
if (preg_match($skip, $rel) && !preg_match('#^(backups|uploads|plugins)/(\.htaccess|README\.md)$#', $rel)) continue;
|
||||||
|
if ($f->isDir()) { @mkdir($tmp . '/' . $rel, 0755, true); continue; }
|
||||||
|
@mkdir(dirname($tmp . '/' . $rel), 0755, true);
|
||||||
|
copy($f->getPathname(), $tmp . '/' . $rel);
|
||||||
|
}
|
||||||
|
@mkdir($tmp . '/uploads');
|
||||||
|
@mkdir($tmp . '/config');
|
||||||
|
file_put_contents($tmp . '/config/config.php', '<?php return ' . var_export([
|
||||||
|
'app_key' => bin2hex(random_bytes(32)),
|
||||||
|
'db' => ['driver' => 'sqlite', 'sqlite_path' => $tmp . '/database/deskhub.sqlite'],
|
||||||
|
'site' => ['name' => 'Test', 'url' => 'http://127.0.0.1', 'admin_email' => 'a@test.de', 'timezone' => 'Europe/Berlin'],
|
||||||
|
'uploads' => ['max_size_bytes' => 10485760, 'allowed_extensions' => ['png'], 'path' => $tmp . '/uploads'],
|
||||||
|
], true) . ';');
|
||||||
|
|
||||||
|
putenv('DESKHUB_CONFIG_JSON');
|
||||||
|
putenv('DESKHUB_SQLITE_PATH');
|
||||||
|
putenv('DESKHUB_TESTING');
|
||||||
|
putenv('DESKHUB_API_ALLOW_INSECURE=1');
|
||||||
|
require_once $root . '/database/sqlite_schema.php';
|
||||||
|
require_once $root . '/database/migrate.php';
|
||||||
|
$db = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
|
||||||
|
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||||
|
$db->exec('PRAGMA foreign_keys = ON');
|
||||||
|
$db->exec(convert_schema_to_sqlite((string) file_get_contents($root . '/database/schema.sql')));
|
||||||
|
$mig = run_migrations($db, true);
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$dept = (int) $db->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
|
||||||
|
$db->exec('DELETE FROM staff');
|
||||||
|
$ins = $db->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)');
|
||||||
|
$ids = [];
|
||||||
|
foreach ([['Chef', 'chef@test.de', 'admin'], ['Agent', 'agent@test.de', 'agent']] as [$n, $e, $r]) {
|
||||||
|
$ins->execute([$n, $e, password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT), $r, $dept, $now]);
|
||||||
|
$ids[$n] = (int) $db->lastInsertId();
|
||||||
|
}
|
||||||
|
$db = null;
|
||||||
|
|
||||||
|
$failures = [];
|
||||||
|
$total = 0;
|
||||||
|
$check = function (bool $ok, string $what) use (&$failures, &$total): void { $total++; if (!$ok) $failures[] = $what; };
|
||||||
|
$webProc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$webPort", '-t', $tmp], [['pipe', 'r'], ['file', $work . '/web.out', 'w'], ['file', $work . '/web.out', 'a']], $wp, $tmp);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $webPort)) break; usleep(100000); }
|
||||||
|
$pdo = function () use ($tmp): PDO {
|
||||||
|
$p = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
|
||||||
|
$p->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
$p->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||||
|
return $p;
|
||||||
|
};
|
||||||
|
// Sprachpakete in die Test-Installation einspielen (wie über Plugins → Hochladen)
|
||||||
|
putenv('DESKHUB_SQLITE_PATH=' . $tmp . '/database/deskhub.sqlite');
|
||||||
|
$zips = [];
|
||||||
|
foreach (['lang-en', 'lang-de'] as $slug) {
|
||||||
|
$src = $root . '/../Plugins/plugins/' . $slug;
|
||||||
|
$zp = $work . '/' . $slug . '.zip';
|
||||||
|
$z = new ZipArchive();
|
||||||
|
$z->open($zp, ZipArchive::CREATE);
|
||||||
|
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) $z->addFile($f->getPathname(), str_replace(chr(92), '/', substr($f->getPathname(), strlen($src) + 1)));
|
||||||
|
$z->close();
|
||||||
|
$zips[] = $zp;
|
||||||
|
}
|
||||||
|
$script = $work . '/install.php';
|
||||||
|
file_put_contents($script, "<?php\nrequire " . var_export($tmp . '/includes/bootstrap.php', true) . ";\n\$pdo = get_db();\nforeach (" . var_export($zips, true) . " as \$z) { \$r = plugin_install_zip(\$pdo, \$z); if (!\$r['ok']) { echo 'FEHLER ' . \$r['error']; exit(1); } plugin_set_active(\$pdo, \$r['slug'], true, []); }\necho 'ok';");
|
||||||
|
$out = (string) shell_exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($script) . ' 2>&1');
|
||||||
|
$check(trim($out) === 'ok', 'Sprachpakete in der Test-Installation installiert: ' . $out);
|
||||||
|
|
||||||
|
$call = function (string $method, string $path, string $jar, array $form = [], array $headers = []) use ($webPort): array {
|
||||||
|
$ch = curl_init("http://127.0.0.1:$webPort$path");
|
||||||
|
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_HEADER => true, CURLOPT_TIMEOUT => 60, CURLOPT_COOKIEJAR => $jar, CURLOPT_COOKIEFILE => $jar, CURLOPT_HTTPHEADER => array_merge(['Sec-Fetch-Mode: navigate'], $headers)]);
|
||||||
|
if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($form)); }
|
||||||
|
$raw = (string) curl_exec($ch);
|
||||||
|
$s = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||||
|
$hs = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||||
|
curl_close($ch);
|
||||||
|
return [$s, substr($raw, $hs), substr($raw, 0, $hs)];
|
||||||
|
};
|
||||||
|
$jar = fn() => tempnam(sys_get_temp_dir(), 'i18n');
|
||||||
|
|
||||||
|
// Gast: erst deutsch, mit Flaggen
|
||||||
|
$g = $jar();
|
||||||
|
[$s, $b] = $call('GET', '/login.php', $g);
|
||||||
|
$check($s === 200 && str_contains($b, 'Passwort vergessen?') && str_contains($b, '<html lang="de">') && substr_count($b, 'class="lang-switch"') === 1 && substr_count($b, 'data:image/svg+xml;base64') >= 2, 'Anmeldeseite: deutsch, mit Sprachwechsler (zwei Flaggen)');
|
||||||
|
// Wechsel zu English
|
||||||
|
[$s, , $h] = $call('GET', '/lang.php?l=en&back=%2Flogin.php', $g);
|
||||||
|
$check($s === 302 && preg_match('#^Location: /login\.php#mi', $h) === 1 && stripos($h, 'Set-Cookie: deskhub_lang=en') !== false && stripos($h, 'SameSite=Lax') !== false, 'lang.php setzt das Cookie und springt zurück');
|
||||||
|
[$s, $b] = $call('GET', '/login.php', $g);
|
||||||
|
$check(str_contains($b, 'Forgot your password?') && str_contains($b, '<html lang="en">') && !str_contains($b, 'Passwort vergessen?'), 'Anmeldeseite ist englisch');
|
||||||
|
[, $b] = $call('GET', '/ticket_new.php', $g);
|
||||||
|
$check(str_contains($b, 'Create new ticket') && str_contains($b, 'Subject') && str_contains($b, 'Open a new ticket') | str_contains($b, 'Support center home'), 'Neues Ticket ist englisch');
|
||||||
|
[, $b] = $call('GET', '/ticket_lookup.php', $g);
|
||||||
|
$check(str_contains($b, 'Check ticket status') || str_contains($b, 'Ticket number'), 'Ticketstatus-Seite ist englisch');
|
||||||
|
[, $b] = $call('GET', '/staff/login.php', $g);
|
||||||
|
$check(str_contains($b, '<html lang="en">') && str_contains($b, 'class="lang-switch"'), 'Mitarbeiter-Anmeldung: englisch, mit Wechsler');
|
||||||
|
// Ungültiges wird ignoriert
|
||||||
|
$g2 = $jar();
|
||||||
|
[$s, , $h] = $call('GET', '/lang.php?l=zz&back=https%3A%2F%2Fevil.example', $g2);
|
||||||
|
$check($s === 302 && stripos($h, 'deskhub_lang') === false && preg_match('#^Location: /\s*$#mi', $h) === 1, 'unbekannte Sprache und fremde Rücksprung-Adresse werden ignoriert');
|
||||||
|
[$s, , $h] = $call('GET', '/lang.php?l=en&back=%2F%2Fevil.example', $g2);
|
||||||
|
$check(preg_match('#^Location: /\s*$#mi', $h) === 1, 'Rücksprung-Adresse mit // wird verworfen');
|
||||||
|
|
||||||
|
// Mitarbeiterbereich auf Englisch
|
||||||
|
$adm = $jar();
|
||||||
|
[, $lp] = $call('GET', '/staff/login.php', $adm);
|
||||||
|
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $lp, $m);
|
||||||
|
$call('POST', '/staff/login.php', $adm, ['csrf_token' => $m[1] ?? '', 'email' => 'chef@test.de', 'password' => 'Sehr-Sicher-1234']);
|
||||||
|
$call('GET', '/lang.php?l=en&back=%2F', $adm);
|
||||||
|
[$s, $b] = $call('GET', '/staff/departments.php', $adm);
|
||||||
|
$check($s === 200 && str_contains($b, '<html lang="en">') && str_contains($b, 'Departments') && str_contains($b, 'Sign out') && str_contains($b, 'class="lang-switch"'), 'Mitarbeiterseite ist englisch, Wechsler in der Seitenleiste');
|
||||||
|
$call('GET', '/lang.php?l=de&back=%2F', $adm);
|
||||||
|
[, $b] = $call('GET', '/staff/departments.php', $adm);
|
||||||
|
$check(str_contains($b, 'Abteilungen') && str_contains($b, 'Abmelden'), 'zurück auf Deutsch');
|
||||||
|
|
||||||
|
// Seite „Sprachen“, Standardsprache, Sammelmodus
|
||||||
|
[$s, $b] = $call('GET', '/staff/languages.php', $adm);
|
||||||
|
$check($s === 200 && str_contains($b, 'English') && str_contains($b, 'lang-en') && str_contains($b, 'Vorlage laden'), 'Seite „Sprachen“ zeigt die installierten Sprachen');
|
||||||
|
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $b, $m);
|
||||||
|
$call('POST', '/staff/languages.php', $adm, ['csrf_token' => $m[1] ?? '', 'action' => 'save', 'default_language' => 'en', 'i18n_collect' => '1']);
|
||||||
|
$fresh = $jar();
|
||||||
|
[, $b] = $call('GET', '/login.php', $fresh);
|
||||||
|
$check(str_contains($b, 'Forgot your password?') && str_contains($b, '<html lang="en">'), 'Standardsprache gilt für Besucher ohne Cookie');
|
||||||
|
$call('GET', '/lang.php?l=de&back=%2F', $fresh);
|
||||||
|
$call('GET', '/login.php', $fresh);
|
||||||
|
$call('GET', '/ticket_lookup.php', $fresh);
|
||||||
|
$check((int) $pdo()->query('SELECT COUNT(*) FROM i18n_missing')->fetchColumn() > 3, 'Sammelmodus merkt sich Texte, auch im deutschen Modus');
|
||||||
|
[$s, $b, $h] = $call('GET', '/staff/languages.php?download=en', $adm);
|
||||||
|
$tpl = json_decode($b, true);
|
||||||
|
$check($s === 200 && is_array($tpl) && stripos($h, 'application/json') !== false && stripos($h, 'attachment') !== false && ($tpl['Anmelden'] ?? null) === 'Sign in', 'Vorlage zum Herunterladen: vorhandene Übersetzungen eingetragen');
|
||||||
|
// Schnittstellen bleiben unberührt (JSON)
|
||||||
|
[$s, $b] = $call('POST', '/api/staff/v1/login.php', $fresh, [], ['Content-Type: application/json']);
|
||||||
|
$check(json_decode($b, true) !== null, 'Schnittstelle liefert weiter reines JSON');
|
||||||
|
[$s, $b] = $call('POST', '/staff/languages.php', $adm, ['csrf_token' => 'falsch', 'action' => 'clear']);
|
||||||
|
$check($s === 403, 'Löschen der gesammelten Texte braucht das CSRF-Token');
|
||||||
|
// Nicht-Admins kommen nicht auf die Seite
|
||||||
|
$ag = $jar();
|
||||||
|
[, $lp] = $call('GET', '/staff/login.php', $ag);
|
||||||
|
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $lp, $m);
|
||||||
|
$call('POST', '/staff/login.php', $ag, ['csrf_token' => $m[1] ?? '', 'email' => 'agent@test.de', 'password' => 'Sehr-Sicher-1234']);
|
||||||
|
[$s, , $h] = $call('GET', '/staff/languages.php', $ag);
|
||||||
|
$check($s === 302, 'Mitarbeiter ohne Adminrechte werden von der Seite „Sprachen“ umgeleitet');
|
||||||
|
} finally {
|
||||||
|
if (isset($webProc) && is_resource($webProc)) { proc_terminate($webProc); proc_close($webProc); }
|
||||||
|
}
|
||||||
|
|
||||||
|
usleep(300000);
|
||||||
|
$rm = function (string $d) use (&$rm) {
|
||||||
|
foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") && !is_link("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); }
|
||||||
|
@rmdir($d);
|
||||||
|
};
|
||||||
|
if (str_starts_with($work, sys_get_temp_dir() . '/deskhub-i18np-')) $rm($work);
|
||||||
|
|
||||||
|
if ($failures) {
|
||||||
|
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
echo "i18n pages checks passed ({$total} Prüfungen).\n";
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft die Live-Anzeige für neue Tickets: Ausgangsstand ohne Hinweise, neue Tickets seit der letzten ID, Ausschluss von Spam, Live-Chats und
|
||||||
|
* zusammengeführten Tickets, Abteilungsrechte, Kennzahlen, Begrenzung auf 6 Hinweise und die Einbindung ins Layout.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$db = sys_get_temp_dir() . '/deskhub-lt-' . bin2hex(random_bytes(4)) . '.sqlite';
|
||||||
|
putenv('DESKHUB_TESTING=1');
|
||||||
|
putenv('DESKHUB_SQLITE_PATH=' . $db);
|
||||||
|
require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
||||||
|
|
||||||
|
$failures = [];
|
||||||
|
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||||
|
if (!$ok) $failures[] = $what;
|
||||||
|
};
|
||||||
|
$pdo = get_db();
|
||||||
|
$d1 = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
|
||||||
|
$pdo->prepare("INSERT INTO departments (name, email, is_active, created_at) VALUES ('Technik', 'technik@team.example', 1, ?)")->execute([db_now()]);
|
||||||
|
$d2 = (int) $pdo->lastInsertId();
|
||||||
|
$prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn();
|
||||||
|
$mkStaff = function (string $name, string $role, ?int $dept) use ($pdo): int {
|
||||||
|
$pdo->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)')->execute([$name, strtolower($name) . '@team.example', 'x', $role, $dept, db_now()]);
|
||||||
|
return (int) $pdo->lastInsertId();
|
||||||
|
};
|
||||||
|
$anna = $mkStaff('Anna', 'agent', $d1);
|
||||||
|
$chef = $mkStaff('Chef', 'admin', null);
|
||||||
|
$n = 0;
|
||||||
|
$mk = function (string $subject, int $dept, array $o = []) use ($pdo, $prio, &$n): int {
|
||||||
|
$n++;
|
||||||
|
$now = db_now();
|
||||||
|
$pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, is_spam, merged_into, assigned_staff_id, resolution_due_at, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)')
|
||||||
|
->execute(['LT-' . $n, $subject, $o['name'] ?? 'Kunde', 'k@x.example', $dept, $prio, $o['status'] ?? 'open', $o['source'] ?? 'web', $o['spam'] ?? 0, $o['merged'] ?? null, $o['assigned'] ?? null, $o['due'] ?? null, $now, $now]);
|
||||||
|
return (int) $pdo->lastInsertId();
|
||||||
|
};
|
||||||
|
$call = function (int $staffId, int $since) {
|
||||||
|
$_SESSION['staff_id'] = $staffId;
|
||||||
|
$_GET = ['since' => (string) $since];
|
||||||
|
// current_staff() merkt sich den Mitarbeiter je Prozess; für den Test über Reflection zurücksetzen
|
||||||
|
$rf = new ReflectionFunction('current_staff');
|
||||||
|
foreach ($rf->getStaticVariables() as $k => $v) { /* nur lesen */ }
|
||||||
|
ob_start();
|
||||||
|
include dirname(__DIR__) . '/staff/live_tickets.php';
|
||||||
|
return json_decode((string) ob_get_clean(), true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// current_staff() cached den ersten Mitarbeiter im Prozess: deshalb jeden Fall in einem eigenen Prozess prüfen
|
||||||
|
$php = escapeshellarg(PHP_BINARY);
|
||||||
|
$quiet = PHP_OS_FAMILY === 'Windows' ? '2>NUL' : '2>/dev/null';
|
||||||
|
$script = sys_get_temp_dir() . '/lt-call-' . bin2hex(random_bytes(3)) . '.php';
|
||||||
|
file_put_contents($script, "<?php\nputenv('DESKHUB_TESTING=1');\nputenv('DESKHUB_SQLITE_PATH=' . " . var_export($db, true) . ");\nrequire " . var_export(dirname(__DIR__) . '/includes/bootstrap.php', true) . ";\nif ((int) (\$argv[1] ?? 0) > 0) \$_SESSION['staff_id'] = (int) \$argv[1];\n\$_GET = ['since' => \$argv[2] ?? '0'];\ninclude " . var_export(dirname(__DIR__) . '/staff/live_tickets.php', true) . ";\n");
|
||||||
|
$run = function (int $staffId, int $since) use ($php, $script, $quiet): ?array {
|
||||||
|
$out = [];
|
||||||
|
exec("{$php} " . escapeshellarg($script) . " {$staffId} {$since} " . $quiet, $out);
|
||||||
|
return json_decode(implode("\n", $out), true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ausgangsstand
|
||||||
|
$base = $mk('Altes Ticket', $d1);
|
||||||
|
$r0 = $run($chef, 0);
|
||||||
|
$check(is_array($r0) && $r0['latest'] === $base && $r0['tickets'] === [] && $r0['more'] === 0, 'Ausgangsstand liefert die letzte ID und keine Hinweise: ' . json_encode($r0));
|
||||||
|
$check(is_array($r0) && $r0['counts']['open'] === 1 && $r0['counts']['unassigned'] === 1 && $r0['counts']['mine'] === 0 && $r0['counts']['unanswered'] === 1 && $r0['counts']['overdue'] === 0, 'Kennzahlen stimmen');
|
||||||
|
|
||||||
|
// Neue Tickets
|
||||||
|
$t1 = $mk('Login geht nicht', $d1, ['name' => 'Erika Muster']);
|
||||||
|
$t2 = $mk('Technik-Frage', $d2);
|
||||||
|
$mk('Spam', $d1, ['spam' => 1]);
|
||||||
|
$mk('Live-Chat', $d1, ['source' => 'chat']);
|
||||||
|
$mk('Zusammengeführt', $d1, ['merged' => $t1]);
|
||||||
|
$r1 = $run($chef, $base);
|
||||||
|
$ids = array_column((array) ($r1['tickets'] ?? []), 'id');
|
||||||
|
$check($ids === [$t1, $t2], 'Administrator sieht die beiden echten neuen Tickets (kein Spam, kein Chat, kein zusammengeführtes): ' . json_encode($ids));
|
||||||
|
$check(($r1['tickets'][0]['subject'] ?? '') === 'Login geht nicht' && ($r1['tickets'][0]['name'] ?? '') === 'Erika Muster' && ($r1['tickets'][0]['number'] ?? '') === 'LT-2' && ($r1['tickets'][1]['department'] ?? '') === 'Technik', 'Hinweis enthält Nummer, Betreff, Name und Abteilung');
|
||||||
|
$check(($r1['latest'] ?? 0) === $t2, 'latest ist die ID des neuesten sichtbaren Tickets (Spam, Chat und zusammengeführte zählen nicht)');
|
||||||
|
|
||||||
|
// Abteilungsrechte
|
||||||
|
$r2 = $run($anna, $base);
|
||||||
|
$check(array_column((array) ($r2['tickets'] ?? []), 'id') === [$t1], 'Mitarbeiter sehen nur Tickets ihrer Abteilung');
|
||||||
|
$check(($r2['counts']['open'] ?? -1) === 5 && ($r1['counts']['open'] ?? -1) === 6, 'Zahlen werden je Abteilung gezählt wie auf der Übersicht (Mitarbeiter 5, Administrator 6): ' . ($r2['counts']['open'] ?? '?') . '/' . ($r1['counts']['open'] ?? '?'));
|
||||||
|
$ra = $run($anna, 0);
|
||||||
|
$check(($ra['counts']['open'] ?? -1) === $r2['counts']['open'] && ($ra['counts']['open'] ?? -1) < ($run($chef, 0)['counts']['open'] ?? 0), 'Mitarbeiter zählen weniger als Administratoren');
|
||||||
|
|
||||||
|
// Nach dem Abruf nichts Neues
|
||||||
|
$r3 = $run($chef, (int) $r1['latest']);
|
||||||
|
$check(($r3['tickets'] ?? null) === [] && ($r3['more'] ?? -1) === 0, 'kein Hinweis, wenn nichts Neues kam');
|
||||||
|
|
||||||
|
// Mir zugewiesen und überfällig
|
||||||
|
$mk('Zugewiesen', $d1, ['assigned' => $anna, 'due' => date('Y-m-d H:i:s', time() - 3600)]);
|
||||||
|
$r4 = $run($anna, 0);
|
||||||
|
$check(($r4['counts']['mine'] ?? 0) === 1 && ($r4['counts']['overdue'] ?? 0) === 1, 'zugewiesene und überfällige Tickets werden gezählt');
|
||||||
|
|
||||||
|
// Begrenzung auf 6 Hinweise
|
||||||
|
$before = (int) $run($chef, 0)['latest'];
|
||||||
|
for ($i = 0; $i < 9; $i++) $mk('Massenticket ' . $i, $d1);
|
||||||
|
$r5 = $run($chef, $before);
|
||||||
|
$check(count($r5['tickets'] ?? []) === 6 && ($r5['more'] ?? 0) === 3, 'höchstens 6 Hinweise, der Rest wird als „weitere“ gezählt: ' . count($r5['tickets'] ?? []) . ' / ' . ($r5['more'] ?? '?'));
|
||||||
|
$check(($r5['tickets'][0]['subject'] ?? '') === 'Massenticket 0', 'älteste zuerst');
|
||||||
|
|
||||||
|
// Ohne Anmeldung
|
||||||
|
$out = [];
|
||||||
|
exec("{$php} " . escapeshellarg($script) . " 0 0 " . $quiet, $out);
|
||||||
|
$check(json_decode(implode('', $out), true) === ['error' => 'login'], 'ohne Anmeldung: Fehlermeldung statt Daten');
|
||||||
|
|
||||||
|
// Einbindung
|
||||||
|
$footer = (string) file_get_contents(dirname(__DIR__) . '/staff/_footer.php');
|
||||||
|
$check(str_contains($footer, '/assets/js/live-tickets.js'), 'Skript ist im Layout eingebunden');
|
||||||
|
$reports = (string) file_get_contents(dirname(__DIR__) . '/staff/reports.php');
|
||||||
|
$check(substr_count($reports, 'data-live-count=') === 5, 'fünf Kennzahlen der Übersicht aktualisieren sich live');
|
||||||
|
$js = (string) file_get_contents(dirname(__DIR__) . '/assets/js/live-tickets.js');
|
||||||
|
$check(str_contains($js, '/staff/live_tickets.php') && str_contains($js, 'textContent') && !str_contains($js, 'innerHTML'), 'Skript fragt den Abruf ab und setzt Inhalte nur als Text (kein innerHTML)');
|
||||||
|
$check(preg_match('/setTimeout\(poll, document\.hidden \? 30000 : 10000\)/', $js) === 1, 'Abruf alle 10 Sekunden (im Hintergrund-Tab alle 30)');
|
||||||
|
|
||||||
|
@unlink($script);
|
||||||
|
@unlink($db);
|
||||||
|
if ($failures) {
|
||||||
|
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
echo 'Live tickets checks passed.' . "\n";
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft das Plugin "Discord- und Telegram-Bot": Signaturprüfung (Ed25519), Befehle /ticket /status /antwort, Ratenbegrenzung,
|
||||||
|
* Benachrichtigung bei Antworten und Lösung (mit ersetztem HTTP-Aufruf), Telegram-Geheimtoken und Schutz fremder Tickets.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!function_exists('sodium_crypto_sign_keypair')) { echo "sodium fehlt, Test übersprungen\n"; exit(0); }
|
||||||
|
|
||||||
|
$pluginDir = sys_get_temp_dir() . '/deskhub-cb-' . bin2hex(random_bytes(4));
|
||||||
|
mkdir($pluginDir, 0755, true);
|
||||||
|
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
|
||||||
|
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';
|
||||||
|
|
||||||
|
$failures = [];
|
||||||
|
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||||
|
if (!$ok) $failures[] = $what;
|
||||||
|
};
|
||||||
|
$pdo = get_db();
|
||||||
|
|
||||||
|
require_once __DIR__ . '/fixtures/paths.php';
|
||||||
|
$src = plugin_source_dir_or_skip('community-bot', 'plugin_community_bot');
|
||||||
|
$zipPath = sys_get_temp_dir() . '/cb-' . bin2hex(random_bytes(3)) . '.zip';
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
$zip->open($zipPath, ZipArchive::CREATE);
|
||||||
|
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) {
|
||||||
|
$zip->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1)));
|
||||||
|
}
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
$r = plugin_install_zip($pdo, $zipPath);
|
||||||
|
$check($r['ok'] && $r['slug'] === 'community-bot', 'Plugin lässt sich installieren: ' . json_encode($r));
|
||||||
|
$act = plugin_set_active($pdo, 'community-bot', true, ['api.public', 'hooks.tickets', 'ui.menu', 'db.migrations']);
|
||||||
|
$check($act['ok'], 'Plugin lässt sich aktivieren: ' . json_encode($act));
|
||||||
|
$state = &plugin_state();
|
||||||
|
$state['booted'] = false;
|
||||||
|
plugins_boot();
|
||||||
|
$wh = plugin_state()['webhooks']['community-bot'] ?? [];
|
||||||
|
$check(isset($wh['discord'], $wh['telegram']), 'beide Webhook-Adressen sind registriert');
|
||||||
|
|
||||||
|
// ── Discord ────────────────────────────────────────────────────────────────
|
||||||
|
$kp = sodium_crypto_sign_keypair();
|
||||||
|
$sk = sodium_crypto_sign_secretkey($kp);
|
||||||
|
plugin_set_setting('community-bot', 'discord_key', bin2hex(sodium_crypto_sign_publickey($kp)));
|
||||||
|
communitybot_set_secret('discord_token', 'bot-token-xyz');
|
||||||
|
plugin_set_setting('community-bot', 'discord_app', '123456789012');
|
||||||
|
$sent = [];
|
||||||
|
$GLOBALS['communitybot_http'] = function (string $method, string $url, array $headers, ?string $body) use (&$sent): array {
|
||||||
|
$sent[] = compact('method', 'url', 'headers', 'body');
|
||||||
|
if (str_contains($url, '/users/@me/channels')) return ['status' => 200, 'body' => '{"id":"dm-1"}'];
|
||||||
|
return ['status' => 200, 'body' => '{"ok":true,"id":"m1"}'];
|
||||||
|
};
|
||||||
|
$dc = function (array $payload, ?string $ts = null, bool $tamper = false) use ($wh, $sk): array {
|
||||||
|
$body = json_encode($payload);
|
||||||
|
$ts ??= (string) time();
|
||||||
|
$sig = bin2hex(sodium_crypto_sign_detached($ts . $body, $sk));
|
||||||
|
return $wh['discord'](['method' => 'POST', 'headers' => ['x-signature-ed25519' => $sig, 'x-signature-timestamp' => $ts], 'body' => $tamper ? $body . ' ' : $body, 'query' => [], 'ip' => '1.2.3.4']);
|
||||||
|
};
|
||||||
|
$check($dc(['type' => 1])['json'] === ['type' => 1], 'PING wird mit PONG beantwortet');
|
||||||
|
$check($dc(['type' => 1], null, true)['status'] === 401, 'veränderter Inhalt wird abgelehnt (401)');
|
||||||
|
$check($dc(['type' => 1], (string) (time() - 3600))['status'] === 401, 'alter Zeitstempel wird abgelehnt');
|
||||||
|
$bad = $wh['discord'](['method' => 'POST', 'headers' => [], 'body' => '{"type":1}', 'query' => [], 'ip' => '1.2.3.4']);
|
||||||
|
$check($bad['status'] === 401, 'ohne Signatur: 401');
|
||||||
|
$other = sodium_crypto_sign_secretkey(sodium_crypto_sign_keypair());
|
||||||
|
$ts = (string) time();
|
||||||
|
$forged = $wh['discord'](['method' => 'POST', 'headers' => ['x-signature-ed25519' => bin2hex(sodium_crypto_sign_detached($ts . '{"type":1}', $other)), 'x-signature-timestamp' => $ts], 'body' => '{"type":1}', 'query' => [], 'ip' => '1.2.3.4']);
|
||||||
|
$check($forged['status'] === 401, 'Signatur mit fremdem Schlüssel: 401');
|
||||||
|
|
||||||
|
$cmd = fn(string $name, array $opts, string $uid = '111111111111') => ['type' => 2, 'channel_id' => '999', 'member' => ['user' => ['id' => $uid, 'username' => 'kim', 'global_name' => 'Kim Test']], 'data' => ['name' => $name, 'options' => array_map(fn($k, $v) => ['name' => $k, 'value' => $v], array_keys($opts), $opts)]];
|
||||||
|
$res = $dc($cmd('ticket', ['betreff' => 'Login geht nicht', 'nachricht' => 'Ich komme nicht mehr in mein Konto']));
|
||||||
|
$content = $res['json']['data']['content'] ?? '';
|
||||||
|
$check($res['status'] === 200 && ($res['json']['data']['flags'] ?? 0) === 64 && preg_match('/#(\S+) wurde angelegt/', $content, $m) === 1, '/ticket legt ein Ticket an und nennt die Nummer: ' . $content);
|
||||||
|
$number = $m[1] ?? '';
|
||||||
|
$t = $pdo->prepare('SELECT * FROM tickets WHERE ticket_number = ?');
|
||||||
|
$t->execute([$number]);
|
||||||
|
$ticket = $t->fetch();
|
||||||
|
$check($ticket && $ticket['subject'] === 'Login geht nicht' && $ticket['requester_email'] === 'discord-111111111111@discord.invalid' && $ticket['source'] === 'api', 'Ticket hat Betreff, Platzhalter-Adresse und Quelle');
|
||||||
|
$check($ticket && $ticket['requester_name'] === 'Kim Test', 'Name aus Discord übernommen');
|
||||||
|
$tid = (int) ($ticket['id'] ?? 0);
|
||||||
|
$tagged = (int) $pdo->query("SELECT COUNT(*) FROM ticket_tags tt JOIN tags g ON g.id = tt.tag_id WHERE tt.ticket_id = {$tid} AND g.name = 'Discord'")->fetchColumn();
|
||||||
|
$check($tagged === 1, 'Ticket trägt den Tag „Discord“');
|
||||||
|
$check((int) $pdo->query("SELECT COUNT(*) FROM plugin_communitybot_links WHERE ticket_id = {$tid} AND platform = 'discord' AND ext_user = '111111111111'")->fetchColumn() === 1, 'Verknüpfung gespeichert');
|
||||||
|
$check(($res['json']['data']['allowed_mentions']['parse'] ?? null) === [], 'keine Erwähnungen in der Antwort erlaubt');
|
||||||
|
|
||||||
|
$st = $dc($cmd('status', ['nummer' => $number]))['json']['data']['content'] ?? '';
|
||||||
|
$check(str_contains($st, $number) && str_contains($st, 'Offen'), '/status zeigt den Stand: ' . $st);
|
||||||
|
$foreign = $dc($cmd('status', ['nummer' => $number], '222222222222'))['json']['data']['content'] ?? '';
|
||||||
|
$check(str_contains($foreign, 'gehört nicht zu Ihnen') && !str_contains($foreign, 'Offen'), 'fremdes Ticket ist nicht abfragbar');
|
||||||
|
$foreignReply = $dc($cmd('antwort', ['nummer' => $number, 'text' => 'Ich mische mich ein'], '222222222222'))['json']['data']['content'] ?? '';
|
||||||
|
$check(str_contains($foreignReply, 'gehört nicht zu Ihnen') && (int) $pdo->query("SELECT COUNT(*) FROM ticket_threads WHERE ticket_id = {$tid}")->fetchColumn() === 1, 'fremde Antwort wird nicht gespeichert');
|
||||||
|
$list = $dc($cmd('status', []))['json']['data']['content'] ?? '';
|
||||||
|
$check(str_contains($list, $number), '/status ohne Nummer listet die eigenen Tickets');
|
||||||
|
|
||||||
|
$rep = $dc($cmd('antwort', ['nummer' => $number, 'text' => 'Noch eine Info']))['json']['data']['content'] ?? '';
|
||||||
|
$check(str_contains($rep, 'hinzugefügt') && (int) $pdo->query("SELECT COUNT(*) FROM ticket_threads WHERE ticket_id = {$tid} AND author_type = 'customer'")->fetchColumn() === 2, '/antwort ergänzt das Ticket');
|
||||||
|
|
||||||
|
// Ratenbegrenzung (Standard 3 pro Stunde): zwei weitere gehen, das vierte nicht
|
||||||
|
$ok2 = $dc($cmd('ticket', ['betreff' => 'Zwei', 'nachricht' => 'Zweites Anliegen']))['json']['data']['content'] ?? '';
|
||||||
|
$ok3 = $dc($cmd('ticket', ['betreff' => 'Drei', 'nachricht' => 'Drittes Anliegen']))['json']['data']['content'] ?? '';
|
||||||
|
$no4 = $dc($cmd('ticket', ['betreff' => 'Vier', 'nachricht' => 'Viertes Anliegen']))['json']['data']['content'] ?? '';
|
||||||
|
$check(str_contains($ok2, 'angelegt') && str_contains($ok3, 'angelegt') && str_contains($no4, 'schon 3 Tickets'), 'Ratenbegrenzung greift beim vierten Ticket: ' . $no4);
|
||||||
|
$other1 = $dc($cmd('ticket', ['betreff' => 'Ich', 'nachricht' => 'Anderer Nutzer'], '333333333333'))['json']['data']['content'] ?? '';
|
||||||
|
$check(str_contains($other1, 'angelegt'), 'andere Nutzer sind nicht betroffen');
|
||||||
|
|
||||||
|
// Benachrichtigung bei Mitarbeiter-Antwort
|
||||||
|
$staffId = (int) $pdo->query('SELECT id FROM staff ORDER BY id LIMIT 1')->fetchColumn();
|
||||||
|
$pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?, 'staff', ?, ?, 0, ?)")->execute([$tid, $staffId, '<p>Bitte setzen Sie das <b>Passwort</b> zurück.</p>', db_now()]);
|
||||||
|
$sent = [];
|
||||||
|
webhook_dispatch($pdo, 'ticket.replied', $tid, ['author' => 'staff', 'internal' => false]);
|
||||||
|
$dm = array_values(array_filter($sent, fn($s) => str_contains($s['url'], '/channels/dm-1/messages')));
|
||||||
|
$check(count($dm) === 1 && str_contains((string) $dm[0]['body'], 'Passwort zurück') && !str_contains((string) $dm[0]['body'], '<b>') && in_array('Authorization: Bot bot-token-xyz', $dm[0]['headers'], true), 'Antwort kommt als Direktnachricht (ohne HTML, mit Bot-Token)');
|
||||||
|
$sent = [];
|
||||||
|
webhook_dispatch($pdo, 'ticket.replied', $tid, ['author' => 'staff', 'internal' => true]);
|
||||||
|
webhook_dispatch($pdo, 'ticket.replied', $tid, ['author' => 'customer']);
|
||||||
|
$check(!$sent, 'interne Notizen und Kunden-Antworten lösen nichts aus');
|
||||||
|
$pdo->prepare("UPDATE tickets SET status = 'resolved' WHERE id = ?")->execute([$tid]);
|
||||||
|
webhook_dispatch($pdo, 'ticket.status_changed', $tid, ['previousStatus' => 'open']);
|
||||||
|
$check(count(array_filter($sent, fn($s) => str_contains((string) $s['body'], 'Gelöst'))) === 1, 'bei „Gelöst“ kommt eine Mitteilung');
|
||||||
|
$sent = [];
|
||||||
|
plugin_set_setting('community-bot', 'notify', '0');
|
||||||
|
webhook_dispatch($pdo, 'ticket.status_changed', $tid, ['previousStatus' => 'open']);
|
||||||
|
$check(!$sent, 'ohne Benachrichtigung wird nichts gesendet');
|
||||||
|
plugin_set_setting('community-bot', 'notify', '1');
|
||||||
|
|
||||||
|
// Discord-Befehle registrieren
|
||||||
|
$sent = [];
|
||||||
|
$reg = communitybot_discord_register();
|
||||||
|
$check($reg['ok'] && $sent[0]['method'] === 'PUT' && str_ends_with($sent[0]['url'], '/applications/123456789012/commands') && count(json_decode((string) $sent[0]['body'], true)) === 3, 'Befehle werden bei Discord eingetragen');
|
||||||
|
|
||||||
|
// ── Telegram ───────────────────────────────────────────────────────────────
|
||||||
|
communitybot_set_secret('telegram_token', '123:ABC');
|
||||||
|
communitybot_set_secret('telegram_secret', 'geheim-1234');
|
||||||
|
$good = ['x-telegram-bot-api-secret-token' => 'geheim-1234'];
|
||||||
|
$tg = fn(string $text, array $headers = ['x-telegram-bot-api-secret-token' => 'geheim-1234'], int $uid = 777) => $wh['telegram'](['method' => 'POST', 'headers' => $headers, 'body' => json_encode(['update_id' => 1, 'message' => ['message_id' => 5, 'text' => $text, 'chat' => ['id' => $uid, 'type' => 'private'], 'from' => ['id' => $uid, 'first_name' => 'Tom', 'last_name' => 'Tester']]]), 'query' => [], 'ip' => '1.2.3.4']);
|
||||||
|
$check($tg('/ticket x', [])['status'] === 401, 'Telegram ohne Token: 401');
|
||||||
|
$check($tg('/ticket x', ['x-telegram-bot-api-secret-token' => 'falsch'])['status'] === 401, 'Telegram mit falschem Token: 401');
|
||||||
|
$res = $tg('/ticket Drucker kaputt | Der Drucker im Büro druckt nicht mehr');
|
||||||
|
$check(($res['json']['method'] ?? '') === 'sendMessage' && (int) $res['json']['chat_id'] === 777 && preg_match('/#(\S+) wurde angelegt/', (string) ($res['json']['text'] ?? ''), $m2) === 1, 'Telegram: /ticket antwortet mit der Nummer: ' . json_encode($res));
|
||||||
|
$num2 = $m2[1] ?? '';
|
||||||
|
$s = $pdo->prepare('SELECT * FROM tickets WHERE ticket_number = ?');
|
||||||
|
$s->execute([$num2]);
|
||||||
|
$t2 = $s->fetch();
|
||||||
|
$check($t2 && $t2['subject'] === 'Drucker kaputt' && $t2['requester_name'] === 'Tom Tester' && $t2['requester_email'] === 'telegram-777@telegram.invalid', 'Telegram-Ticket: Betreff vor dem Strich, Name, Adresse');
|
||||||
|
$noSub = $tg('/ticket Mein Monitor flackert seit heute', $good, 778);
|
||||||
|
$check(str_contains((string) $noSub['json']['text'], 'angelegt'), 'Telegram: ohne Strich wird der Betreff aus dem Text gebildet');
|
||||||
|
$check(str_contains((string) $tg('/status ' . $num2)['json']['text'], 'Offen'), 'Telegram: /status');
|
||||||
|
$check(str_contains((string) $tg('/status ' . $num2, $good, 999)['json']['text'], 'gehört nicht zu Ihnen'), 'Telegram: fremdes Ticket nicht abfragbar');
|
||||||
|
$check(str_contains((string) $tg('/antwort ' . $num2 . ' Danke, jetzt geht es')['json']['text'], 'hinzugefügt'), 'Telegram: /antwort');
|
||||||
|
$check(str_contains((string) $tg('/hilfe@MeinBot')['json']['text'], '/ticket'), 'Telegram: /hilfe mit Bot-Namen');
|
||||||
|
$check($tg('Hallo ohne Befehl')['json'] instanceof stdClass, 'Telegram: normaler Text wird ignoriert');
|
||||||
|
$id2 = (int) $t2['id'];
|
||||||
|
$pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?, 'staff', ?, 'Wir kümmern uns darum.', 0, ?)")->execute([$id2, $staffId, db_now()]);
|
||||||
|
$sent = [];
|
||||||
|
webhook_dispatch($pdo, 'ticket.replied', $id2, ['author' => 'staff', 'internal' => false]);
|
||||||
|
$send = array_values(array_filter($sent, fn($x) => str_contains($x['url'], 'api.telegram.org/bot123:ABC/sendMessage')));
|
||||||
|
$check(count($send) === 1 && (string) json_decode((string) $send[0]['body'], true)['chat_id'] === '777', 'Telegram: Antwort wird an den Chat geschickt');
|
||||||
|
$sent = [];
|
||||||
|
$treg = communitybot_telegram_register();
|
||||||
|
$check($treg['ok'] && str_contains((string) $sent[0]['body'], 'secret_token') && str_contains((string) $sent[0]['body'], 'plugin_webhook.php?p=community-bot'), 'Telegram-Webhook wird mit Token und Adresse gesetzt');
|
||||||
|
|
||||||
|
// Gesperrte Absenderadresse
|
||||||
|
$pdo->prepare('INSERT INTO banlist (match_type, value, created_at) VALUES (?,?,?)')->execute(['email', 'telegram-555@telegram.invalid', db_now()]);
|
||||||
|
$ban = $tg('/ticket Sperre | Test', $good, 555);
|
||||||
|
$check(str_contains((string) $ban['json']['text'], 'nicht angelegt'), 'gesperrte Absender bekommen kein Ticket: ' . ($ban['json']['text'] ?? ''));
|
||||||
|
|
||||||
|
unset($GLOBALS['communitybot_http']);
|
||||||
|
foreach (scandir($pluginDir) ?: [] as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
|
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
|
||||||
|
}
|
||||||
|
@rmdir($pluginDir);
|
||||||
|
@unlink($zipPath);
|
||||||
|
|
||||||
|
if ($failures) {
|
||||||
|
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
echo 'Community-Bot plugin checks passed.' . "\n";
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, dass der echte Server-Cronjob (cron/run_plugins.php) die Hintergrundaufgaben der Plugins auslöst (Hook "cron.run"),
|
||||||
|
* nicht doppelt läuft, im Crontab-Block steht und im eingebauten Hintergrund-Betrieb als Job geführt wird.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$pluginDir = sys_get_temp_dir() . '/deskhub-cj-' . bin2hex(random_bytes(4));
|
||||||
|
$db = sys_get_temp_dir() . '/deskhub-cj-' . bin2hex(random_bytes(4)) . '.sqlite';
|
||||||
|
$marker = sys_get_temp_dir() . '/deskhub-cj-marker-' . bin2hex(random_bytes(4)) . '.txt';
|
||||||
|
mkdir($pluginDir, 0755, true);
|
||||||
|
foreach (['DESKHUB_PLUGINS_DIR' => $pluginDir, 'DESKHUB_TESTING' => '1', 'DESKHUB_CATALOG_DEFAULTS' => __DIR__ . '/fixtures/no-catalog.php', 'DESKHUB_SQLITE_PATH' => $db, 'DESKHUB_CJ_MARKER' => $marker] as $k => $v) putenv("{$k}={$v}");
|
||||||
|
require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
||||||
|
|
||||||
|
$failures = [];
|
||||||
|
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||||
|
if (!$ok) $failures[] = $what;
|
||||||
|
};
|
||||||
|
$pdo = get_db();
|
||||||
|
|
||||||
|
// Kleines Plugin, das bei jedem Lauf eine Zeile in eine Datei schreibt
|
||||||
|
$zipPath = sys_get_temp_dir() . '/cj-' . bin2hex(random_bytes(3)) . '.zip';
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
$zip->open($zipPath, ZipArchive::CREATE);
|
||||||
|
$zip->addFromString('cronprobe/plugin.json', json_encode(['slug' => 'cronprobe', 'name' => 'Cron-Probe', 'version' => '1.0.0', 'permissions' => ['hooks.cron']]));
|
||||||
|
$zip->addFromString('cronprobe/plugin.php', "<?php\nplugin_add_hook('cron.run', function () { file_put_contents(getenv('DESKHUB_CJ_MARKER'), 'lauf' . PHP_EOL, FILE_APPEND); });\nplugin_add_hook('cron.minute', function () { file_put_contents(getenv('DESKHUB_CJ_MARKER'), 'minute' . PHP_EOL, FILE_APPEND); });\n");
|
||||||
|
$zip->close();
|
||||||
|
$check(plugin_install_zip($pdo, $zipPath)['ok'], 'Test-Plugin lässt sich installieren');
|
||||||
|
$check(plugin_set_active($pdo, 'cronprobe', true, ['hooks.cron'])['ok'], 'Test-Plugin lässt sich aktivieren');
|
||||||
|
|
||||||
|
$php = escapeshellarg(PHP_BINARY);
|
||||||
|
$script = escapeshellarg(dirname(__DIR__) . '/cron/run_plugins.php');
|
||||||
|
exec("{$php} {$script} 2>&1", $out, $code);
|
||||||
|
$check($code === 0 && str_contains(implode("\n", $out), '1 aktive'), 'Cronjob löst die Plugin-Aufgaben aus: ' . implode(' | ', $out));
|
||||||
|
$n = fn(string $w) => substr_count((string) @file_get_contents($marker), $w);
|
||||||
|
$check(is_file($marker) && $n('lauf') === 1 && $n('minute') === 1, 'beide Hooks des Plugins wurden genau einmal ausgeführt (Minute und 5-Minuten-Lauf)');
|
||||||
|
unset($out);
|
||||||
|
exec("{$php} {$script} 2>&1", $out, $code);
|
||||||
|
$check($code === 0 && str_contains(implode("\n", $out), 'Übersprungen') && $n('lauf') === 1 && $n('minute') === 1, 'direkt danach läuft er nicht doppelt');
|
||||||
|
$last = (string) $pdo->query("SELECT setting_value FROM settings WHERE setting_key = 'cron_run_plugins_last_run'")->fetchColumn();
|
||||||
|
$check($last !== '' && strtotime($last) > time() - 60, 'letzter Lauf ist vermerkt (Anzeige unter Einstellungen → Cron-Jobs)');
|
||||||
|
$srcRow = $pdo->query("SELECT setting_key, setting_value FROM settings WHERE setting_key IN ('cron_run_plugins_source','cron_run_plugins_last_cli')")->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||||
|
$check(($srcRow['cron_run_plugins_source'] ?? '') === 'cron' && strtotime((string) ($srcRow['cron_run_plugins_last_cli'] ?? '')) > time() - 60, 'der Lauf wird als „echter Cronjob“ vermerkt (Quelle und Zeit)');
|
||||||
|
|
||||||
|
// Eine Minute später: das Minuten-Ereignis kommt wieder, der 5-Minuten-Lauf noch nicht
|
||||||
|
$setAgo = fn(string $key, int $sec) => $pdo->prepare('UPDATE settings SET setting_value = ? WHERE setting_key = ?')->execute([date('Y-m-d H:i:s', time() - $sec), $key]);
|
||||||
|
$setAgo('cron_run_plugins_last_run', 70);
|
||||||
|
unset($out);
|
||||||
|
exec("{$php} {$script} 2>&1", $out, $code);
|
||||||
|
$check($n('minute') === 2 && $n('lauf') === 1, 'nach einer Minute: Minuten-Ereignis ja, 5-Minuten-Ereignis noch nicht');
|
||||||
|
// Fünf Minuten später: beide
|
||||||
|
$setAgo('cron_run_plugins_last_run', 70);
|
||||||
|
$setAgo('cron_plugins_run_last', 310);
|
||||||
|
unset($out);
|
||||||
|
exec("{$php} {$script} 2>&1", $out, $code);
|
||||||
|
$check($n('minute') === 3 && $n('lauf') === 2, 'nach fünf Minuten: beide Ereignisse');
|
||||||
|
|
||||||
|
// Der Webzugriff bleibt gesperrt
|
||||||
|
$src = (string) file_get_contents(dirname(__DIR__) . '/cron/run_plugins.php');
|
||||||
|
$check(str_contains($src, "php_sapi_name() !== 'cli' && !defined('DESKHUB_CRON_RUNNER')") && str_contains($src, 'Kein Web-Zugriff'), 'Webzugriff auf das Skript ist gesperrt');
|
||||||
|
|
||||||
|
// Eintrag im Crontab-Block und im eingebauten Hintergrund-Betrieb
|
||||||
|
require_once dirname(__DIR__) . '/includes/crontab_installer.php';
|
||||||
|
$block = crontab_marker_block();
|
||||||
|
$check(preg_match('#^\* \* \* \* \* .*/cron/run_plugins\.php#m', $block) === 1, 'Crontab-Block enthält den Plugin-Job jede Minute');
|
||||||
|
$defs = pseudo_cron_job_definitions();
|
||||||
|
$check(isset($defs['run_plugins']) && $defs['run_plugins']['fn'] === 'run_plugins_cron' && $defs['run_plugins']['interval'] === 60 && is_file(dirname(__DIR__) . '/cron/' . $defs['run_plugins']['file']), 'eingebauter Hintergrund-Betrieb führt den Job (jede Minute)');
|
||||||
|
$settingsSrc = (string) file_get_contents(dirname(__DIR__) . '/staff/settings.php');
|
||||||
|
// Jeder Job der Statusliste braucht eine Anzeige „letzter Lauf“ (sonst bleibt die Zeit leer)
|
||||||
|
preg_match('/\$cronJobs = \[(.*?)\n\];/s', $settingsSrc, $mj);
|
||||||
|
preg_match('/\$cronLastRun = \[(.*?)\n \];/s', $settingsSrc, $ml);
|
||||||
|
preg_match_all("/^\s*'([a-z_]+)'\s*=>/m", $mj[1] ?? '', $jobKeys);
|
||||||
|
preg_match_all("/^\s*'([a-z_]+)'\s*=>/m", $ml[1] ?? '', $lastKeys);
|
||||||
|
$check(count($jobKeys[1]) >= 10 && array_diff($jobKeys[1], $lastKeys[1]) === [], 'jeder Job der Statusliste hat eine Anzeige des letzten Laufs (fehlt: ' . implode(', ', array_diff($jobKeys[1], $lastKeys[1])) . ')');
|
||||||
|
$check(str_contains($settingsSrc, "get_setting('cron_run_plugins_last_run')"), 'der Plugin-Job liest seinen letzten Lauf aus der Einstellung, die das Skript schreibt');
|
||||||
|
$check(str_contains($settingsSrc, "'run_plugins'") && str_contains($settingsSrc, 'run_plugins_cron'), 'Job steht in der Statusliste unter Einstellungen → Cron-Jobs');
|
||||||
|
$check(!str_contains((string) file_get_contents(dirname(__DIR__) . '/includes/pseudo_cron.php'), "plugin_hook('cron.run'"), 'Plugin-Aufgaben werden nicht noch ein zweites Mal im Hintergrund-Betrieb angestoßen');
|
||||||
|
$check(plugin_hook_permission('cron.minute') === 'hooks.cron' && plugin_hook_permission('cron.run') === 'hooks.cron', 'das Minuten-Ereignis braucht dieselbe Berechtigung wie cron.run');
|
||||||
|
|
||||||
|
foreach (scandir($pluginDir) ?: [] as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
|
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
|
||||||
|
}
|
||||||
|
@rmdir($pluginDir);
|
||||||
|
@unlink($zipPath);
|
||||||
|
@unlink($db);
|
||||||
|
@unlink($marker);
|
||||||
|
if ($failures) {
|
||||||
|
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
echo 'Plugin cron job checks passed.' . "\n";
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft das Plugin "Datenschutz": Tickets ohne Registrierung werden nach der Frist unwiderruflich anonymisiert, registrierte Kunden nicht,
|
||||||
|
* Löschung auf Antrag, Sicherung gegen versehentliches Einschalten, Einstellungen und Protokoll ohne Kundendaten.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$pluginDir = sys_get_temp_dir() . '/deskhub-ds-' . bin2hex(random_bytes(4));
|
||||||
|
mkdir($pluginDir, 0755, true);
|
||||||
|
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
|
||||||
|
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';
|
||||||
|
|
||||||
|
$failures = [];
|
||||||
|
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||||
|
if (!$ok) $failures[] = $what;
|
||||||
|
};
|
||||||
|
$pdo = get_db();
|
||||||
|
|
||||||
|
require_once __DIR__ . '/fixtures/paths.php';
|
||||||
|
$src = plugin_source_dir_or_skip('datenschutz', 'plugin_datenschutz');
|
||||||
|
$zipPath = sys_get_temp_dir() . '/ds-' . bin2hex(random_bytes(3)) . '.zip';
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
$zip->open($zipPath, ZipArchive::CREATE);
|
||||||
|
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) {
|
||||||
|
$zip->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1)));
|
||||||
|
}
|
||||||
|
$zip->close();
|
||||||
|
$r = plugin_install_zip($pdo, $zipPath);
|
||||||
|
$check($r['ok'] && $r['slug'] === 'datenschutz', 'Plugin lässt sich installieren: ' . json_encode($r));
|
||||||
|
$act = plugin_set_active($pdo, 'datenschutz', true, ['hooks.cron', 'ui.menu', 'db.migrations']);
|
||||||
|
$check($act['ok'], 'Plugin lässt sich aktivieren: ' . json_encode($act));
|
||||||
|
$state = &plugin_state();
|
||||||
|
$state['booted'] = false;
|
||||||
|
plugins_boot();
|
||||||
|
$check(count(plugin_admin_menu_items(['id' => 1, 'role' => 'admin'])) === 1 && count(plugin_admin_menu_items(['id' => 2, 'role' => 'agent'])) === 0 && count(plugin_menu_items(['id' => 1, 'role' => 'admin'])) === 0, 'Menüpunkt nur für Administratoren im Adminportal');
|
||||||
|
|
||||||
|
$dept = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
|
||||||
|
$prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn();
|
||||||
|
$admin = ['id' => (int) $pdo->query("SELECT id FROM staff WHERE role = 'admin' ORDER BY id LIMIT 1")->fetchColumn(), 'role' => 'admin'];
|
||||||
|
$agent = ['id' => 999, 'role' => 'agent'];
|
||||||
|
$day = fn(int $d) => date('Y-m-d H:i:s', time() - $d * 86400);
|
||||||
|
$mkUser = function (string $name, string $email) use ($pdo): int {
|
||||||
|
$pdo->prepare('INSERT INTO users (name, email, password_hash, phone, created_at) VALUES (?,?,?,?,?)')->execute([$name, $email, 'hash', '0170 1234567', db_now()]);
|
||||||
|
return (int) $pdo->lastInsertId();
|
||||||
|
};
|
||||||
|
$n = 0;
|
||||||
|
$mk = function (string $name, string $email, string $status, int $ageDays, ?int $userId = null, ?int $closedAgo = null) use ($pdo, $dept, $prio, &$n, $day): int {
|
||||||
|
$n++;
|
||||||
|
$closed = in_array($status, ['closed', 'resolved'], true) ? $day($closedAgo ?? $ageDays) : null;
|
||||||
|
$pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, requester_ip, user_id, department_id, priority_id, status, source, closed_at, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)')
|
||||||
|
->execute(['DS-' . $n, 'Thema ' . $n, $name, $email, '203.0.113.7', $userId, $dept, $prio, $status, 'web', $closed, $day($ageDays), $day($closedAgo ?? $ageDays)]);
|
||||||
|
$id = (int) $pdo->lastInsertId();
|
||||||
|
$pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'customer', ?, 0, ?)")->execute([$id, 'Mein Name ist ' . $name . ', Tel. 0171 555', $day($ageDays)]);
|
||||||
|
$pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'staff', 'Wir kümmern uns.', 0, ?)")->execute([$id, $day($ageDays)]);
|
||||||
|
$pdo->prepare('INSERT INTO ticket_collaborators (ticket_id, email, name, created_at) VALUES (?,?,?,?)')->execute([$id, 'mitleser@kunde.example', 'Mitleser', db_now()]);
|
||||||
|
return $id;
|
||||||
|
};
|
||||||
|
$row = fn(int $id) => $pdo->query("SELECT * FROM tickets WHERE id = {$id}")->fetch();
|
||||||
|
$isAnon = fn(int $id) => $row($id)['requester_name'] === 'Anonymisiert' && str_starts_with($row($id)['requester_email'], 'anon_') && str_ends_with($row($id)['requester_email'], '@deleted.local');
|
||||||
|
|
||||||
|
$user = $mkUser('Erika Muster', 'erika@muster.example');
|
||||||
|
$old = $mk('Gast Alt', 'gast.alt@kunde.example', 'closed', 45);
|
||||||
|
$oldResolved = $mk('Gast Gelöst', 'gast.geloest@kunde.example', 'resolved', 40);
|
||||||
|
$young = $mk('Gast Neu', 'gast.neu@kunde.example', 'closed', 10);
|
||||||
|
$openOld = $mk('Gast Offen', 'gast.offen@kunde.example', 'open', 60);
|
||||||
|
$regOld = $mk('Erika Muster', 'erika@muster.example', 'closed', 90, $user);
|
||||||
|
$guestSameMail = $mk('Erika Gast', 'erika@muster.example', 'closed', 90);
|
||||||
|
$closedLongAgoCreatedRecently = $mk('Gast Z', 'gast.z@kunde.example', 'closed', 5, null, 5);
|
||||||
|
$cf = $pdo->prepare("INSERT INTO custom_fields (label, field_type, is_active, created_at) VALUES ('Telefon','text',1,?)");
|
||||||
|
try { $cf->execute([db_now()]); $cfId = (int) $pdo->lastInsertId(); $pdo->prepare('INSERT INTO ticket_field_values (ticket_id, custom_field_id, value) VALUES (?,?,?)')->execute([$old, $cfId, '0171 999']); } catch (Throwable $e) { $cfId = 0; }
|
||||||
|
$pdo->prepare('INSERT INTO ticket_access_tokens (ticket_id, token_hash, token_enc, created_at) VALUES (?,?,?,?)')->execute([$old, hash('sha256', 'abc'), 'enc', db_now()]);
|
||||||
|
$pdo->prepare('INSERT INTO ticket_ratings (ticket_id, rating, comment, created_at) VALUES (?,?,?,?)')->execute([$old, 4, 'Ich bin Gast Alt', db_now()]);
|
||||||
|
|
||||||
|
// Standardwerte und Schutz gegen versehentliches Einschalten
|
||||||
|
$cfg = datenschutz_config();
|
||||||
|
$check($cfg['enabled'] === false && $cfg['amount'] === 30 && $cfg['unit'] === 'days' && $cfg['basis'] === 'closed' && !$cfg['include_open'] && !in_array('messages', $cfg['fields'], true) && in_array('name', $cfg['fields'], true) && in_array('email', $cfg['fields'], true), 'Standard: ausgeschaltet, 30 Tage nach dem Schließen');
|
||||||
|
$check(datenschutz_run($pdo) === 0 && !$isAnon($old), 'ausgeschaltet wird nichts anonymisiert');
|
||||||
|
$check(datenschutz_candidate_count($pdo) === 3, 'Vorschau zählt: gelöste/geschlossene Gast-Tickets nach der Frist (alt, gelöst, Gast mit gleicher Adresse): ' . datenschutz_candidate_count($pdo));
|
||||||
|
plugin_set_setting('datenschutz', 'enabled', '1');
|
||||||
|
|
||||||
|
// Lauf
|
||||||
|
$n1 = datenschutz_run($pdo);
|
||||||
|
$check($n1 === 3, 'Lauf anonymisiert 3 Tickets: ' . $n1);
|
||||||
|
$check($isAnon($old) && $isAnon($oldResolved) && $isAnon($guestSameMail), 'Gast-Tickets nach der Frist sind anonymisiert');
|
||||||
|
$check($row($old)['requester_ip'] === '0.0.0.0', 'IP-Adresse ist entfernt');
|
||||||
|
$check(!$isAnon($young) && !$isAnon($openOld) && !$isAnon($regOld) && !$isAnon($closedLongAgoCreatedRecently), 'junge, offene und registrierte Tickets bleiben unberührt');
|
||||||
|
$check($row($regOld)['requester_name'] === 'Erika Muster' && (int) $row($regOld)['user_id'] === $user, 'registrierter Kunde bleibt vollständig erhalten');
|
||||||
|
$check((int) $pdo->query("SELECT COUNT(*) FROM users WHERE id = {$user} AND name = 'Erika Muster' AND email = 'erika@muster.example'")->fetchColumn() === 1, 'Konto des registrierten Kunden bleibt');
|
||||||
|
$check((int) $pdo->query("SELECT COUNT(*) FROM ticket_collaborators WHERE ticket_id = {$old}")->fetchColumn() === 0 && (int) $pdo->query("SELECT COUNT(*) FROM ticket_collaborators WHERE ticket_id = {$young}")->fetchColumn() === 1, 'Mitleser werden entfernt');
|
||||||
|
$check((int) $pdo->query("SELECT COUNT(*) FROM ticket_access_tokens WHERE ticket_id = {$old}")->fetchColumn() === 0, 'Zugangslinks sind ungültig');
|
||||||
|
$check($pdo->query("SELECT comment FROM ticket_ratings WHERE ticket_id = {$old}")->fetchColumn() === null, 'Bewertungs-Kommentar ist entfernt');
|
||||||
|
if ($cfId) $check($pdo->query("SELECT value FROM ticket_field_values WHERE ticket_id = {$old}")->fetchColumn() === null, 'Zusatzfelder sind geleert');
|
||||||
|
$check($pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$old} AND author_type = 'customer'")->fetchColumn() === 'Mein Name ist Gast Alt, Tel. 0171 555', 'Nachrichten bleiben standardmäßig erhalten');
|
||||||
|
$check(datenschutz_run($pdo) === 0, 'ein zweiter Lauf findet nichts mehr');
|
||||||
|
$e1 = $row($old)['requester_email'];
|
||||||
|
$check(!str_contains($e1, 'gast') && !str_contains($e1, hash('sha256', 'gast.alt@kunde.example')) && strlen($e1) > 20, 'Ersatzadresse hat keinen Bezug zur echten Adresse');
|
||||||
|
$check($row($old)['requester_email'] !== $row($oldResolved)['requester_email'], 'Ersatzadressen sind je Ticket verschieden');
|
||||||
|
$check((int) $pdo->query("SELECT COUNT(*) FROM activity_log WHERE ticket_id = {$old} AND action LIKE '%anonymisiert%'")->fetchColumn() === 1, 'Aktivität im Ticket vermerkt');
|
||||||
|
$log = $pdo->query('SELECT * FROM plugin_datenschutz_log ORDER BY id DESC LIMIT 1')->fetch();
|
||||||
|
$check($log['kind'] === 'auto' && (int) $log['tickets'] === 3 && !str_contains((string) $log['note'], '@'), 'Protokoll ohne Kundendaten');
|
||||||
|
|
||||||
|
// Einstellungen: Frist, Berechnung ab Erstellung, offene Tickets, Nachrichten
|
||||||
|
plugin_set_setting('datenschutz', 'amount', '7');
|
||||||
|
$check(datenschutz_candidate_count($pdo) === 1, 'Frist 7 Tage: nur das 10 Tage alte geschlossene Gast-Ticket');
|
||||||
|
plugin_set_setting('datenschutz', 'basis', 'created');
|
||||||
|
$check(datenschutz_candidate_count($pdo) === 1, 'ab Erstellung: dasselbe Ticket');
|
||||||
|
plugin_set_setting('datenschutz', 'include_open', '1');
|
||||||
|
$check(datenschutz_candidate_count($pdo) === 2, 'mit offenen Tickets: auch das alte offene Gast-Ticket');
|
||||||
|
plugin_set_setting('datenschutz', 'fields', 'name,email,ip,collaborators,fieldvalues,tokens,rating,messages');
|
||||||
|
datenschutz_run($pdo);
|
||||||
|
$check($isAnon($young) && $isAnon($openOld), 'Frist 7 Tage: weitere Tickets anonymisiert');
|
||||||
|
$check($pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$young} AND author_type = 'customer'")->fetchColumn() === DATENSCHUTZ_TEXT_REMOVED && $pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$young} AND author_type = 'staff'")->fetchColumn() === 'Wir kümmern uns.', 'Kundennachrichten entfernt, Antworten der Mitarbeiter bleiben');
|
||||||
|
$check(!$isAnon($closedLongAgoCreatedRecently) && !$isAnon($regOld), '5 Tage altes und registriertes bleiben');
|
||||||
|
$check(!$cfg['include_open'] && datenschutz_config()['amount'] === 7, 'Einstellungen werden gelesen');
|
||||||
|
plugin_set_setting('datenschutz', 'amount', '0');
|
||||||
|
$check(datenschutz_config()['amount'] === 1, 'Frist hat eine Untergrenze');
|
||||||
|
|
||||||
|
// Anhänge
|
||||||
|
$uploads = rtrim((string) config()['uploads']['path'], '/');
|
||||||
|
@mkdir($uploads, 0775, true);
|
||||||
|
$file = 'ds-test-' . bin2hex(random_bytes(3)) . '.txt';
|
||||||
|
file_put_contents($uploads . '/' . $file, 'Inhalt');
|
||||||
|
$att = $mk('Gast Anhang', 'gast.anhang@kunde.example', 'closed', 100);
|
||||||
|
$th = (int) $pdo->query("SELECT id FROM ticket_threads WHERE ticket_id = {$att} AND author_type = 'customer'")->fetchColumn();
|
||||||
|
$pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)')->execute([$th, 'lebenslauf.pdf', $file, 6, db_now()]);
|
||||||
|
plugin_set_setting('datenschutz', 'amount', '30');
|
||||||
|
plugin_set_setting('datenschutz', 'basis', 'closed');
|
||||||
|
plugin_set_setting('datenschutz', 'include_open', '0');
|
||||||
|
plugin_set_setting('datenschutz', 'fields', 'name,email,ip,collaborators,fieldvalues,tokens,rating');
|
||||||
|
datenschutz_run($pdo);
|
||||||
|
$check(is_file($uploads . '/' . $file), 'ohne die Einstellung bleibt der Anhang');
|
||||||
|
$att2 = $mk('Gast Anhang 2', 'gast.anhang2@kunde.example', 'closed', 100);
|
||||||
|
$th2 = (int) $pdo->query("SELECT id FROM ticket_threads WHERE ticket_id = {$att2} AND author_type = 'customer'")->fetchColumn();
|
||||||
|
$file2 = 'ds-test-' . bin2hex(random_bytes(3)) . '.txt';
|
||||||
|
file_put_contents($uploads . '/' . $file2, 'Inhalt');
|
||||||
|
$pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)')->execute([$th2, 'ausweis.jpg', $file2, 6, db_now()]);
|
||||||
|
plugin_set_setting('datenschutz', 'fields', 'name,email,ip,collaborators,fieldvalues,tokens,rating,attachments');
|
||||||
|
datenschutz_run($pdo);
|
||||||
|
$check(!is_file($uploads . '/' . $file2) && $pdo->query("SELECT stored_name FROM ticket_attachments WHERE thread_id = {$th2}")->fetchColumn() === 'geloescht', 'mit der Einstellung wird der Anhang gelöscht');
|
||||||
|
@unlink($uploads . '/' . $file);
|
||||||
|
|
||||||
|
// Hintergrundlauf höchstens einmal pro Stunde
|
||||||
|
plugin_set_setting('datenschutz', 'last_tick', (string) time());
|
||||||
|
$late = $mk('Gast Spät', 'gast.spaet@kunde.example', 'closed', 100);
|
||||||
|
$check(datenschutz_tick($pdo) === 0 && !$isAnon($late), 'Hintergrundlauf wartet eine Stunde');
|
||||||
|
plugin_set_setting('datenschutz', 'last_tick', '0');
|
||||||
|
$check(datenschutz_tick($pdo) >= 1 && $isAnon($late), 'Hintergrundlauf anonymisiert nach Ablauf');
|
||||||
|
|
||||||
|
// ── Auswahl der Datenarten und Frist-Einheiten ─────────────────────────────
|
||||||
|
plugin_set_setting('datenschutz', 'amount', '30');
|
||||||
|
plugin_set_setting('datenschutz', 'unit', 'days');
|
||||||
|
plugin_set_setting('datenschutz', 'basis', 'closed');
|
||||||
|
plugin_set_setting('datenschutz', 'include_open', '0');
|
||||||
|
$nameOnly = $mk('Nur Name', 'nur.name@kunde.example', 'closed', 100);
|
||||||
|
plugin_set_setting('datenschutz', 'fields', 'name');
|
||||||
|
$check(datenschutz_run($pdo) === 1, 'nur Name: ein Ticket bearbeitet');
|
||||||
|
$r1 = $row($nameOnly);
|
||||||
|
$check($r1['requester_name'] === 'Anonymisiert' && $r1['requester_email'] === 'nur.name@kunde.example' && $r1['requester_ip'] === '203.0.113.7', 'nur Name: E-Mail und IP bleiben');
|
||||||
|
$check((int) $pdo->query("SELECT COUNT(*) FROM ticket_collaborators WHERE ticket_id = {$nameOnly}")->fetchColumn() === 1, 'nur Name: Mitleser bleiben');
|
||||||
|
$check(datenschutz_run($pdo) === 0 && datenschutz_candidate_count($pdo) === 0, 'bearbeitete Tickets werden nicht erneut angefasst, auch wenn die E-Mail noch echt ist');
|
||||||
|
$mailOnly = $mk('Nur Mail', 'nur.mail@kunde.example', 'closed', 100);
|
||||||
|
plugin_set_setting('datenschutz', 'fields', 'email');
|
||||||
|
datenschutz_run($pdo);
|
||||||
|
$r2 = $row($mailOnly);
|
||||||
|
$check($r2['requester_name'] === 'Nur Mail' && str_starts_with($r2['requester_email'], 'anon_') && $r2['requester_ip'] === '203.0.113.7', 'nur E-Mail: Name und IP bleiben');
|
||||||
|
$both = $mk('Beides', 'beides@kunde.example', 'closed', 100);
|
||||||
|
plugin_set_setting('datenschutz', 'fields', 'name,email');
|
||||||
|
datenschutz_run($pdo);
|
||||||
|
$r3 = $row($both);
|
||||||
|
$check($r3['requester_name'] === 'Anonymisiert' && str_starts_with($r3['requester_email'], 'anon_') && $r3['requester_ip'] === '203.0.113.7', 'Name und E-Mail: IP bleibt');
|
||||||
|
$full = $mk('Komplett', 'komplett@kunde.example', 'closed', 100);
|
||||||
|
plugin_set_setting('datenschutz', 'fields', implode(',', array_keys(datenschutz_field_labels())));
|
||||||
|
datenschutz_run($pdo);
|
||||||
|
$check($isAnon($full) && $row($full)['requester_ip'] === '0.0.0.0' && $pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$full} AND author_type = 'customer'")->fetchColumn() === DATENSCHUTZ_TEXT_REMOVED, 'komplett: alles entfernt');
|
||||||
|
$check(datenschutz_config()['fields'] === array_keys(datenschutz_field_labels()), 'Auswahl wird gelesen');
|
||||||
|
plugin_set_setting('datenschutz', 'fields', 'quatsch,name');
|
||||||
|
$check(datenschutz_config()['fields'] === ['name'], 'ungültige Datenarten werden ignoriert');
|
||||||
|
plugin_set_setting('datenschutz', 'fields', '');
|
||||||
|
$none = $mk('Nichts', 'nichts@kunde.example', 'closed', 100);
|
||||||
|
$check(datenschutz_config()['fields'] === [] && datenschutz_run($pdo) === 0 && $row($none)['requester_name'] === 'Nichts', 'ohne ausgewählte Datenarten passiert nichts');
|
||||||
|
plugin_set_setting('datenschutz', 'fields', 'name,email,ip,collaborators,fieldvalues,tokens,rating');
|
||||||
|
$check(array_keys(datenschutz_presets()) === ['standard', 'full', 'name', 'email', 'name_email'] && datenschutz_presets()['name'][1] === ['name'], 'Vorlagen für die Auswahl');
|
||||||
|
foreach (['days' => ['1', 'Tag'], 'weeks' => ['2', 'Wochen'], 'months' => ['1', 'Monat'], 'years' => ['3', 'Jahre']] as $unit => [$amount, $word]) {
|
||||||
|
plugin_set_setting('datenschutz', 'unit', $unit);
|
||||||
|
plugin_set_setting('datenschutz', 'amount', $amount);
|
||||||
|
$c = datenschutz_config();
|
||||||
|
$expected = strtotime("-{$amount} {$unit}");
|
||||||
|
$check(abs(strtotime(datenschutz_cutoff($c)) - $expected) < 5 && datenschutz_frist_label($c) === "{$amount} {$word}", "Frist in {$unit}: " . datenschutz_frist_label($c));
|
||||||
|
}
|
||||||
|
plugin_set_setting('datenschutz', 'unit', 'jahrhunderte');
|
||||||
|
$check(datenschutz_config()['unit'] === 'days', 'unbekannte Einheit fällt auf Tage zurück');
|
||||||
|
plugin_set_setting('datenschutz', 'unit', 'months');
|
||||||
|
plugin_set_setting('datenschutz', 'amount', '2');
|
||||||
|
$mid = $mk('Zwei Monate', 'zwei@kunde.example', 'closed', 45);
|
||||||
|
$old2 = $mk('Drei Monate', 'drei@kunde.example', 'closed', 95);
|
||||||
|
datenschutz_run($pdo);
|
||||||
|
$check(!$isAnon($mid) && $isAnon($old2), 'Frist 2 Monate: 45 Tage alt bleibt, 95 Tage alt wird anonymisiert');
|
||||||
|
plugin_set_setting('datenschutz', 'unit', 'days');
|
||||||
|
plugin_set_setting('datenschutz', 'amount', '30');
|
||||||
|
// ── Löschung auf Antrag ────────────────────────────────────────────────────
|
||||||
|
$check(datenschutz_find_customer($pdo, 'unbekannt@x.example') === null && datenschutz_find_customer($pdo, '') === null, 'unbekannter Kunde');
|
||||||
|
$found = datenschutz_find_customer($pdo, ' ERIKA@muster.example ');
|
||||||
|
$check($found && (int) $found['user']['id'] === $user && $found['tickets'] === 1 && $found['open'] === 0 && !$found['already'], 'Kunde wird gefunden (Groß-/Kleinschreibung egal), Tickets gezählt');
|
||||||
|
$check(!datenschutz_delete_customer($pdo, $agent, $user, false)['ok'], 'Mitarbeiter dürfen nicht löschen');
|
||||||
|
$openReg = $mk('Erika Muster', 'erika@muster.example', 'open', 3, $user);
|
||||||
|
$blocked = datenschutz_delete_customer($pdo, $admin, $user, false);
|
||||||
|
$check(!$blocked['ok'] && str_contains($blocked['error'], 'offene Tickets'), 'mit offenen Tickets: Hinweis statt Löschung');
|
||||||
|
$check((int) $pdo->query("SELECT COUNT(*) FROM users WHERE id = {$user} AND name = 'Erika Muster'")->fetchColumn() === 1, 'nichts wurde verändert');
|
||||||
|
$pdo->prepare('INSERT INTO customer_notes (user_id, staff_id, note, created_at) VALUES (?,?,?,?)')->execute([$user, $admin['id'], 'Ruft oft an', db_now()]);
|
||||||
|
$pdo->prepare('INSERT INTO password_resets (account_type, email, token_hash, expires_at, created_at) VALUES (?,?,?,?,?)')->execute(['customer', 'erika@muster.example', 'x', db_now(), db_now()]);
|
||||||
|
$del = datenschutz_delete_customer($pdo, $admin, $user, true);
|
||||||
|
$check($del['ok'] && $del['tickets'] === 2, 'Löschung mit Schließen der offenen Tickets: ' . json_encode($del));
|
||||||
|
$u = $pdo->query("SELECT * FROM users WHERE id = {$user}")->fetch();
|
||||||
|
$check($u['name'] === 'Anonymisiert' && str_starts_with($u['email'], 'anon_') && $u['phone'] === null && $u['password_hash'] !== 'hash' && strlen($u['password_hash']) > 30, 'Konto ist anonymisiert, Anmeldung nicht mehr möglich');
|
||||||
|
$check($isAnon($regOld) && $isAnon($openReg) && $row($openReg)['status'] === 'closed' && $row($regOld)['user_id'] === null, 'alle Tickets des Kunden sind anonymisiert, das offene geschlossen');
|
||||||
|
$check($pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$regOld} AND author_type = 'customer'")->fetchColumn() === DATENSCHUTZ_TEXT_REMOVED, 'bei Löschung auf Antrag werden die Nachrichten immer entfernt (unabhängig von der Einstellung)');
|
||||||
|
$check((int) $pdo->query("SELECT COUNT(*) FROM customer_notes WHERE user_id = {$user}")->fetchColumn() === 0 && (int) $pdo->query("SELECT COUNT(*) FROM password_resets WHERE email = 'erika@muster.example'")->fetchColumn() === 0, 'Notizen und Passwort-Rücksetzung sind gelöscht');
|
||||||
|
$l = $pdo->query('SELECT * FROM plugin_datenschutz_log ORDER BY id DESC LIMIT 1')->fetch();
|
||||||
|
$check($l['kind'] === 'request' && (int) $l['staff_id'] === $admin['id'] && str_contains((string) $l['note'], 'e***@m***.example') && !str_contains((string) $l['note'], 'erika@'), 'Protokoll enthält nur die verkürzte Adresse: ' . $l['note']);
|
||||||
|
$check(!datenschutz_delete_customer($pdo, $admin, $user, true)['ok'], 'ein zweites Mal geht nicht');
|
||||||
|
$check(datenschutz_find_customer($pdo, 'erika@muster.example') === null, 'Kunde ist unter der alten Adresse nicht mehr auffindbar');
|
||||||
|
$check(!datenschutz_delete_customer($pdo, $admin, 99999, true)['ok'], 'unbekannte Kunden-ID');
|
||||||
|
$check(datenschutz_mask('ab@firma.de') === 'a***@f***.de' && datenschutz_mask('x') === 'x***', 'Maskierung für das Protokoll');
|
||||||
|
|
||||||
|
// Selbstheilung: fehlt die Tabelle nach einem Update (ältere DeskHub-Dateien), holt das Plugin die Änderung selbst nach
|
||||||
|
$pdo->exec('DROP TABLE plugin_datenschutz_done');
|
||||||
|
$pdo->exec("DELETE FROM schema_migrations WHERE migration LIKE 'plugin_datenschutz_002%'");
|
||||||
|
datenschutz_ensure_schema(true);
|
||||||
|
$check((int) $pdo->query("SELECT COUNT(*) FROM sqlite_master WHERE name = 'plugin_datenschutz_done'")->fetchColumn() === 1, 'Selbstheilung: die fehlende Tabelle wird nachgeholt');
|
||||||
|
// ── Reiter der Seite ───────────────────────────────────────────────────────
|
||||||
|
$pagePath = $pluginDir . '/datenschutz/pages/datenschutz.php';
|
||||||
|
$adminStaff = ['id' => $admin['id'], 'role' => 'admin', 'name' => 'Chef'];
|
||||||
|
$render = function (string $tab, array $get = [], ?array $who = null) use ($pdo, $adminStaff, $pagePath): string {
|
||||||
|
$_GET = ($tab === '' ? [] : ['tab' => $tab]) + $get;
|
||||||
|
$_SERVER['REQUEST_METHOD'] = 'GET';
|
||||||
|
$staff = $who ?? $adminStaff;
|
||||||
|
ob_start();
|
||||||
|
include $pagePath;
|
||||||
|
return (string) ob_get_clean();
|
||||||
|
};
|
||||||
|
$pdo->exec('DELETE FROM plugin_datenschutz_log');
|
||||||
|
for ($i = 1; $i <= 120; $i++) datenschutz_log($pdo, 'auto', $i, 0, "Eintrag-{$i}-ende", null);
|
||||||
|
$home = $render('');
|
||||||
|
$check(str_contains($home, '<h3>Regeln</h3>') && !str_contains($home, 'Eintrag-') && !str_contains($home, 'name="fields[]"') && !str_contains($home, 'E-Mail-Adresse des Kunden'), 'Übersicht: nur Regeln und Status');
|
||||||
|
$check(str_contains($home, 'tab=einstellungen') && str_contains($home, 'tab=antrag') && str_contains($home, 'tab=protokoll'), 'Reiterleiste mit allen Reitern');
|
||||||
|
$set = $render('einstellungen');
|
||||||
|
$check(str_contains($set, 'name="fields[]"') && !str_contains($set, '<h3>Regeln</h3>'), 'Reiter Einstellungen');
|
||||||
|
$req = $render('antrag');
|
||||||
|
$check(str_contains($req, 'E-Mail-Adresse des Kunden') && !str_contains($req, 'name="fields[]"'), 'Reiter Löschung auf Antrag');
|
||||||
|
$p1 = $render('protokoll');
|
||||||
|
$check(substr_count($p1, '-ende') === 50 && str_contains($p1, 'Eintrag-120-ende') && !str_contains($p1, 'Eintrag-70-ende') && str_contains($p1, 'Seite 1 von 3') && !str_contains($p1, '<h3>Regeln</h3>'), 'Protokoll: eigener Reiter, 50 pro Seite, neueste zuerst');
|
||||||
|
$p3 = $render('protokoll', ['seite' => '3']);
|
||||||
|
$check(substr_count($p3, '-ende') === 20 && str_contains($p3, 'Eintrag-1-ende'), 'Protokoll Seite 3 mit 20 Einträgen');
|
||||||
|
$check(str_contains($render('protokoll', ['seite' => '99']), 'Seite 3 von 3'), 'zu große Seitenzahl wird begrenzt');
|
||||||
|
$check(str_contains($render('quatsch'), '<h3>Regeln</h3>'), 'unbekannter Reiter → Übersicht');
|
||||||
|
$check(str_contains($render('', [], ['id' => 2, 'role' => 'agent', 'name' => 'X']), 'nur für Administratoren') && !str_contains($render('protokoll', [], ['id' => 2, 'role' => 'agent', 'name' => 'X']), 'Eintrag-'), 'Nicht-Admins sehen nichts');
|
||||||
|
$check(datenschutz_tab_url('protokoll', ['seite' => 2]) === '/staff/plugin.php?p=datenschutz&page=datenschutz&tab=protokoll&seite=2', 'datenschutz_tab_url');
|
||||||
|
|
||||||
|
unset($GLOBALS['x']);
|
||||||
|
foreach (scandir($pluginDir) ?: [] as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
|
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
|
||||||
|
}
|
||||||
|
@rmdir($pluginDir);
|
||||||
|
@unlink($zipPath);
|
||||||
|
if ($failures) {
|
||||||
|
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
echo 'Datenschutz plugin checks passed.' . "\n";
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, dass der Administrator eine Mail bekommt, wenn ein Plugin Fehler wirft oder wegen Fehlern abgeschaltet wird, und wenn ein
|
||||||
|
* Hintergrundjob scheitert, mit Drosselung und Ausschalter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$pluginDir = sys_get_temp_dir() . '/deskhub-pm-' . bin2hex(random_bytes(4));
|
||||||
|
mkdir($pluginDir, 0755, true);
|
||||||
|
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
|
||||||
|
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';
|
||||||
|
|
||||||
|
$failures = [];
|
||||||
|
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||||
|
if (!$ok) $failures[] = $what;
|
||||||
|
};
|
||||||
|
$pdo = get_db();
|
||||||
|
set_setting('admin_email', 'admin@firma.example');
|
||||||
|
$mails = [];
|
||||||
|
$GLOBALS['error_alert_send'] = function (string $to, string $subject, string $html) use (&$mails): bool { $mails[] = compact('to', 'subject', 'html'); return true; };
|
||||||
|
|
||||||
|
$zipPath = sys_get_temp_dir() . '/pm-' . bin2hex(random_bytes(3)) . '.zip';
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
$zip->open($zipPath, ZipArchive::CREATE);
|
||||||
|
$zip->addFromString('mailprobe/plugin.json', json_encode(['slug' => 'mailprobe', 'name' => 'Mail-Probe', 'version' => '1.0.0', 'permissions' => ['hooks.cron']]));
|
||||||
|
$zip->addFromString('mailprobe/plugin.php', "<?php\nplugin_add_hook('cron.run', function () { throw new RuntimeException('Spalte fehlt: first_fail_at'); });\n");
|
||||||
|
$zip->close();
|
||||||
|
$check(plugin_install_zip($pdo, $zipPath)['ok'] && plugin_set_active($pdo, 'mailprobe', true, ['hooks.cron'])['ok'], 'Test-Plugin installiert und aktiv');
|
||||||
|
$state = &plugin_state();
|
||||||
|
$state['booted'] = false;
|
||||||
|
plugins_boot();
|
||||||
|
|
||||||
|
// 1. Fehler: Mail „meldet einen Fehler“
|
||||||
|
plugin_hook('cron.run');
|
||||||
|
$check(count($mails) === 1 && $mails[0]['to'] === 'admin@firma.example' && str_contains($mails[0]['subject'], 'Plugin „Mail-Probe“ meldet einen Fehler'), 'erster Fehler: Mail an den Administrator: ' . json_encode(array_column($mails, 'subject'), JSON_UNESCAPED_UNICODE));
|
||||||
|
$h = $mails[0]['html'] ?? '';
|
||||||
|
$check(str_contains($h, 'Spalte fehlt: first_fail_at') && str_contains($h, 'Fehler 1 von ' . PLUGIN_FAIL_LIMIT) && str_contains($h, 'plugins/mailprobe') && !str_contains($h, 'Zeile 0') && !str_contains($h, 'Die aufgerufene Seite konnte nicht angezeigt werden'), 'Inhalt nennt Plugin, Fehlertext und Zähler, ohne die Seiten-Formulierung');
|
||||||
|
// 2. Derselbe Fehler noch einmal: gedrosselt
|
||||||
|
plugin_hook('cron.run');
|
||||||
|
$check(count($mails) === 1, 'derselbe Fehler wird nicht noch einmal gemailt (höchstens einmal pro Stunde)');
|
||||||
|
// 3. Abschaltung nach dem dritten Fehler: eigene Mail
|
||||||
|
plugin_hook('cron.run');
|
||||||
|
$row = plugins_installed($pdo)['mailprobe'];
|
||||||
|
$check((int) $row['is_active'] === 0 && (int) $row['fail_count'] >= PLUGIN_FAIL_LIMIT, 'Plugin ist nach den Fehlern deaktiviert');
|
||||||
|
$off = array_values(array_filter($mails, fn($m) => str_contains($m['subject'], 'wurde wegen Fehlern deaktiviert')));
|
||||||
|
$check(count($off) === 1 && str_contains($off[0]['html'], 'automatisch deaktiviert') && str_contains($off[0]['html'], 'mailprobe'), 'Mail „wegen Fehlern deaktiviert“ kommt trotz Drosselung (eigener Betreff)');
|
||||||
|
|
||||||
|
// Ausschalter
|
||||||
|
set_setting('error_alert', '0');
|
||||||
|
$before = count($mails);
|
||||||
|
plugin_record_failure('mailprobe', 'Ein ganz anderer Fehler');
|
||||||
|
$check(count($mails) === $before, 'bei ausgeschalteter Fehler-Mail wird nichts gesendet');
|
||||||
|
set_setting('error_alert', '1');
|
||||||
|
|
||||||
|
// Hintergrundjob: Meldung
|
||||||
|
error_alert_notice('Hintergrundjob „x“ ist fehlgeschlagen', 'Der Hintergrundjob „x“ hat einen Fehler gemeldet.', 'cron/x.php', 'Verbindung abgelehnt');
|
||||||
|
$last = end($mails);
|
||||||
|
$check(str_contains($last['subject'], 'Hintergrundjob „x“ ist fehlgeschlagen') && str_contains($last['html'], 'Verbindung abgelehnt'), 'fehlgeschlagene Hintergrundjobs werden gemeldet');
|
||||||
|
// Wirkt auch im eingebauten Hintergrund-Betrieb
|
||||||
|
$pc = (string) file_get_contents(dirname(__DIR__) . '/includes/pseudo_cron.php');
|
||||||
|
$check(str_contains($pc, 'error_alert_notice('), 'der eingebaute Hintergrund-Betrieb meldet fehlgeschlagene Jobs');
|
||||||
|
|
||||||
|
unset($GLOBALS['error_alert_send']);
|
||||||
|
foreach (scandir($pluginDir) ?: [] as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
|
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
|
||||||
|
}
|
||||||
|
@rmdir($pluginDir);
|
||||||
|
@unlink($zipPath);
|
||||||
|
if ($failures) {
|
||||||
|
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
echo 'Plugin error mail checks passed.' . "\n";
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft das Plugin "Störungsmitteilung und Rundmail": Empfängergruppen (offene Tickets, Abteilung, Organisation), Filter für unbrauchbare
|
||||||
|
* Adressen, Rechte der Mitarbeiter, Versand in Stapeln mit Protokoll, Abbruch, Begrenzungen und Probemail.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$pluginDir = sys_get_temp_dir() . '/deskhub-rm-' . bin2hex(random_bytes(4));
|
||||||
|
mkdir($pluginDir, 0755, true);
|
||||||
|
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
|
||||||
|
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';
|
||||||
|
|
||||||
|
$failures = [];
|
||||||
|
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||||
|
if (!$ok) $failures[] = $what;
|
||||||
|
};
|
||||||
|
$pdo = get_db();
|
||||||
|
|
||||||
|
require_once __DIR__ . '/fixtures/paths.php';
|
||||||
|
$src = plugin_source_dir_or_skip('rundmail', 'plugin_rundmail');
|
||||||
|
$zipPath = sys_get_temp_dir() . '/rm-' . bin2hex(random_bytes(3)) . '.zip';
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
$zip->open($zipPath, ZipArchive::CREATE);
|
||||||
|
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) {
|
||||||
|
$zip->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1)));
|
||||||
|
}
|
||||||
|
$zip->close();
|
||||||
|
$r = plugin_install_zip($pdo, $zipPath);
|
||||||
|
$check($r['ok'] && $r['slug'] === 'rundmail', 'Plugin lässt sich installieren: ' . json_encode($r));
|
||||||
|
$act = plugin_set_active($pdo, 'rundmail', true, ['hooks.cron', 'ui.menu', 'db.migrations']);
|
||||||
|
$check($act['ok'], 'Plugin lässt sich aktivieren: ' . json_encode($act));
|
||||||
|
$state = &plugin_state();
|
||||||
|
$state['booted'] = false;
|
||||||
|
plugins_boot();
|
||||||
|
$check(count(plugin_menu_items(['id' => 1, 'role' => 'agent'])) === 1, 'Menüpunkt ist für Mitarbeiter sichtbar');
|
||||||
|
|
||||||
|
$sentMails = [];
|
||||||
|
$GLOBALS['rundmail_send'] = function (string $to, string $subject, string $html) use (&$sentMails): bool {
|
||||||
|
if (str_contains($to, 'kaputt')) return false;
|
||||||
|
$sentMails[] = compact('to', 'subject', 'html');
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
$d1 = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
|
||||||
|
$pdo->prepare("INSERT INTO departments (name, email, is_active, created_at) VALUES ('Technik', 'technik@team.example', 1, ?)")->execute([db_now()]);
|
||||||
|
$d2 = (int) $pdo->lastInsertId();
|
||||||
|
$prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn();
|
||||||
|
$mkStaff = function (string $name, string $role, ?int $dept) use ($pdo): array {
|
||||||
|
$pdo->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)')
|
||||||
|
->execute([$name, strtolower($name) . '@team.example', 'x', $role, $dept, db_now()]);
|
||||||
|
return ['id' => (int) $pdo->lastInsertId(), 'role' => $role, 'department_id' => $dept, 'name' => $name, 'email' => strtolower($name) . '@team.example'];
|
||||||
|
};
|
||||||
|
$anna = $mkStaff('Anna', 'agent', $d1);
|
||||||
|
$chef = $mkStaff('Chef', 'admin', null);
|
||||||
|
$pdo->prepare("INSERT INTO organizations (name, created_at) VALUES ('Muster GmbH', ?)")->execute([db_now()]);
|
||||||
|
$org = (int) $pdo->lastInsertId();
|
||||||
|
$mkUser = function (string $name, string $email, ?int $org) use ($pdo): int {
|
||||||
|
$pdo->prepare('INSERT INTO users (name, email, password_hash, organization_id, created_at) VALUES (?,?,?,?,?)')->execute([$name, $email, 'x', $org, db_now()]);
|
||||||
|
return (int) $pdo->lastInsertId();
|
||||||
|
};
|
||||||
|
$u1 = $mkUser('Erika Muster', 'erika@muster.example', $org);
|
||||||
|
$u2 = $mkUser('Max Muster', 'max@muster.example', $org);
|
||||||
|
$n = 0;
|
||||||
|
$mk = function (string $name, string $email, int $dept, string $status = 'open', ?int $userId = null, int $spam = 0) use ($pdo, $prio, &$n): int {
|
||||||
|
$n++;
|
||||||
|
$now = db_now();
|
||||||
|
$pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, user_id, department_id, priority_id, status, source, is_spam, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)')
|
||||||
|
->execute(['RM-' . $n, 'T' . $n, $name, $email, $userId, $dept, $prio, $status, 'web', $spam, $now, $now]);
|
||||||
|
return (int) $pdo->lastInsertId();
|
||||||
|
};
|
||||||
|
$mk('Erika Muster', 'erika@muster.example', $d1, 'open', $u1);
|
||||||
|
$mk('Erika Muster', 'ERIKA@muster.example', $d1, 'pending', $u1); // gleiche Adresse, einmal
|
||||||
|
$mk('Gast Eins', 'gast1@kunde.example', $d1);
|
||||||
|
$mk('Gast Zwei', 'gast2@kunde.example', $d2);
|
||||||
|
$mk('Fertig', 'fertig@kunde.example', $d1, 'closed');
|
||||||
|
$mk('Gelöst', 'geloest@kunde.example', $d1, 'resolved');
|
||||||
|
$mk('Spammer', 'spam@kunde.example', $d1, 'open', null, 1);
|
||||||
|
$mk('Discord', 'discord-123@discord.invalid', $d1);
|
||||||
|
$mk('Anonym', 'anon_abcd@deleted.local', $d1);
|
||||||
|
$mk('Kaputt', 'kaputt@kunde.example', $d1);
|
||||||
|
$pdo->prepare('INSERT INTO banlist (match_type, value, created_at) VALUES (?,?,?)')->execute(['email', 'gesperrt@kunde.example', db_now()]);
|
||||||
|
$mk('Gesperrt', 'gesperrt@kunde.example', $d1);
|
||||||
|
$mails = fn(array $rows) => array_map(fn($r) => $r['email'], $rows);
|
||||||
|
|
||||||
|
// Empfänger
|
||||||
|
$all = $mails(rundmail_recipients($pdo, $chef, 'open', null));
|
||||||
|
sort($all);
|
||||||
|
$check($all === ['erika@muster.example', 'gast1@kunde.example', 'gast2@kunde.example', 'kaputt@kunde.example'], 'offene Tickets: nur brauchbare Adressen, einmal je Adresse: ' . json_encode($all));
|
||||||
|
$dep = $mails(rundmail_recipients($pdo, $chef, 'department', $d2));
|
||||||
|
$check($dep === ['gast2@kunde.example'], 'Abteilung: nur offene Tickets dieser Abteilung');
|
||||||
|
$orgAll = $mails(rundmail_recipients($pdo, $chef, 'organization', $org));
|
||||||
|
$check($orgAll === ['erika@muster.example', 'max@muster.example'], 'Organisation (Administrator): alle Mitglieder');
|
||||||
|
$orgStaff = $mails(rundmail_recipients($pdo, $anna, 'organization', $org));
|
||||||
|
$check($orgStaff === ['erika@muster.example'], 'Organisation (Mitarbeiter): nur Mitglieder mit offenem Ticket in der eigenen Abteilung');
|
||||||
|
$annaOpen = $mails(rundmail_recipients($pdo, $anna, 'open', null));
|
||||||
|
sort($annaOpen);
|
||||||
|
$check($annaOpen === ['erika@muster.example', 'gast1@kunde.example', 'kaputt@kunde.example'], 'Mitarbeiter sehen nur Kunden der eigenen Abteilung');
|
||||||
|
$check(rundmail_recipients($pdo, $anna, 'department', $d2) === [], 'fremde Abteilung ist für Mitarbeiter leer');
|
||||||
|
$groupsAnna = array_column(rundmail_groups($pdo, $anna), 'key');
|
||||||
|
$groupsChef = array_column(rundmail_groups($pdo, $chef), 'key');
|
||||||
|
$check(!in_array('department:' . $d2, $groupsAnna, true) && in_array('department:' . $d2, $groupsChef, true) && in_array('organization:' . $org, $groupsAnna, true), 'Gruppenauswahl richtet sich nach den Abteilungen');
|
||||||
|
$check(rundmail_parse_group($pdo, 'department:99999') === null && rundmail_parse_group($pdo, 'x') === null && rundmail_parse_group($pdo, 'open')['type'] === 'open', 'Gruppenschlüssel werden geprüft');
|
||||||
|
|
||||||
|
// Prüfungen
|
||||||
|
$check(rundmail_validate('', 'Text') !== null && rundmail_validate('Betreff', '') !== null && rundmail_validate("A\nB", 'x') !== null && rundmail_validate(str_repeat('x', 151), 'x') !== null, 'Betreff und Text werden geprüft');
|
||||||
|
$check(!rundmail_create($pdo, $anna, 'department:' . $d2, 'Hallo', 'Text')['ok'], 'Mitarbeiter können nicht an fremde Abteilungen senden');
|
||||||
|
$check(!rundmail_create($pdo, $chef, 'open', '', 'Text')['ok'], 'ohne Betreff kein Versand');
|
||||||
|
$check(!rundmail_create($pdo, $chef, 'organization:99999', 'Hallo', 'Text')['ok'], 'unbekannte Organisation');
|
||||||
|
|
||||||
|
// Rendern
|
||||||
|
[$subj, $html] = rundmail_render(['subject' => 'Wartung', 'body' => "Hallo {name},\n<b>fett</b>"], 'Erika <script>');
|
||||||
|
$check($subj === 'Wartung' && str_contains($html, 'Hallo Erika <script>') && !str_contains($html, '<script>') && !str_contains($html, '<b>fett') && str_contains($html, '<br'), 'Text wird maskiert, Name eingesetzt, Zeilenumbrüche bleiben');
|
||||||
|
[, $anon] = rundmail_render(['subject' => 'x', 'body' => 'Hallo {name}'], '');
|
||||||
|
$check(str_contains($anon, 'Hallo Kunde'), 'ohne Namen steht „Kunde“');
|
||||||
|
|
||||||
|
// Versand in Stapeln (Admin: 4 Empfänger, davon einer mit Fehler)
|
||||||
|
$res = rundmail_create($pdo, $chef, 'open', 'Aktuelle Störung', 'Hallo {name}, wir arbeiten daran.');
|
||||||
|
$check($res['ok'] && $res['total'] === 4, 'Rundmail wird angelegt: ' . json_encode($res));
|
||||||
|
$msg = $pdo->query("SELECT * FROM plugin_rundmail_messages WHERE id = {$res['id']}")->fetch();
|
||||||
|
$check($msg['status'] === 'done' && (int) $msg['sent'] === 3 && (int) $msg['failed'] === 1, 'kleine Gruppe geht sofort raus, der Fehler wird gezählt: ' . json_encode($msg));
|
||||||
|
$sentTo = array_column($sentMails, 'to');
|
||||||
|
sort($sentTo);
|
||||||
|
$check($sentTo === ['erika@muster.example', 'gast1@kunde.example', 'gast2@kunde.example'], 'jeder Empfänger bekommt eine eigene Mail (keine Sammelmail)');
|
||||||
|
$check(str_contains(array_values(array_filter($sentMails, fn($m) => $m['to'] === 'erika@muster.example'))[0]['html'], 'Hallo Erika Muster'), 'Name wird je Empfänger eingesetzt');
|
||||||
|
$err = $pdo->query("SELECT error FROM plugin_rundmail_queue WHERE email = 'kaputt@kunde.example'")->fetchColumn();
|
||||||
|
$check(str_contains((string) $err, 'fehlgeschlagen'), 'Fehler steht im Protokoll');
|
||||||
|
|
||||||
|
// Große Gruppe: Stapel und Hintergrundlauf
|
||||||
|
$bulk = [];
|
||||||
|
for ($i = 0; $i < 60; $i++) $bulk[] = $mk('Kunde ' . $i, "bulk{$i}@kunde.example", $d2);
|
||||||
|
$sentMails = [];
|
||||||
|
$res2 = rundmail_create($pdo, $chef, 'department:' . $d2, 'Wartung', 'Hallo {name}');
|
||||||
|
$check($res2['ok'] && $res2['total'] === 61 && count($sentMails) === 25, 'erster Stapel: 25 Mails sofort');
|
||||||
|
$st = $pdo->query("SELECT status, sent FROM plugin_rundmail_messages WHERE id = {$res2['id']}")->fetch();
|
||||||
|
$check($st['status'] === 'sending' && (int) $st['sent'] === 25, 'Nachricht steht auf „Läuft“');
|
||||||
|
$check(rundmail_tick($pdo) === 25 && count($sentMails) === 50, 'Hintergrundlauf verschickt den nächsten Stapel');
|
||||||
|
$check(rundmail_tick($pdo) === 11 && count($sentMails) === 61, 'letzter Stapel');
|
||||||
|
$st = $pdo->query("SELECT status, sent, failed, finished_at FROM plugin_rundmail_messages WHERE id = {$res2['id']}")->fetch();
|
||||||
|
$check($st['status'] === 'done' && (int) $st["sent"] === 61 && $st["finished_at"] !== null, 'Nachricht ist fertig');
|
||||||
|
$check(rundmail_tick($pdo) === 0, 'danach ist nichts mehr zu tun');
|
||||||
|
|
||||||
|
// Abbruch
|
||||||
|
$sentMails = [];
|
||||||
|
$res3 = rundmail_create($pdo, $chef, 'department:' . $d2, 'Noch eine', 'Hallo');
|
||||||
|
$check(!rundmail_cancel($pdo, $anna, $res3['id']), 'Mitarbeiter können fremde Versände nicht abbrechen');
|
||||||
|
$check(rundmail_cancel($pdo, $chef, $res3['id']), 'Administrator bricht ab');
|
||||||
|
$check(rundmail_tick($pdo) === 0 && count($sentMails) === 25, 'nach dem Abbruch geht nichts mehr raus');
|
||||||
|
$st = $pdo->query("SELECT status, sent FROM plugin_rundmail_messages WHERE id = {$res3['id']}")->fetch();
|
||||||
|
$check($st['status'] === 'cancelled' && (int) $st['sent'] === 25, 'Stand nach Abbruch: abgebrochen, 25 gesendet');
|
||||||
|
$check(!rundmail_cancel($pdo, $chef, $res3['id']), 'Fertige oder abgebrochene Versände lassen sich nicht noch einmal abbrechen');
|
||||||
|
|
||||||
|
// Begrenzungen für Mitarbeiter
|
||||||
|
$pdo->exec("DELETE FROM plugin_rundmail_messages");
|
||||||
|
$pdo->exec("DELETE FROM plugin_rundmail_queue");
|
||||||
|
for ($i = 0; $i < 250; $i++) $mk('Viele ' . $i, "viele{$i}@kunde.example", $d1);
|
||||||
|
$tooMany = rundmail_create($pdo, $anna, 'open', 'Viele', 'Text');
|
||||||
|
$check(!$tooMany['ok'] && str_contains($tooMany['error'], 'höchstens 200'), 'Mitarbeiter dürfen höchstens 200 Empfänger: ' . json_encode($tooMany));
|
||||||
|
$pdo->exec("DELETE FROM tickets WHERE ticket_number LIKE 'RM-%' AND requester_email LIKE 'viele%'");
|
||||||
|
$pdo->exec("DELETE FROM tickets WHERE requester_email LIKE 'bulk%'");
|
||||||
|
for ($i = 0; $i < 5; $i++) rundmail_create($pdo, $anna, 'open', 'Hallo ' . $i, 'Text');
|
||||||
|
$limited = rundmail_create($pdo, $anna, 'open', 'Sechste', 'Text');
|
||||||
|
$check(!$limited['ok'] && str_contains($limited['error'], 'letzten Stunde'), 'höchstens 5 Rundmails pro Stunde für Mitarbeiter');
|
||||||
|
$check(count(rundmail_history($pdo, $anna)) === 5 && count(rundmail_history($pdo, $chef)) === 5, 'Verlauf: Mitarbeiter sehen ihre eigenen, Administratoren alle');
|
||||||
|
$check(count(rundmail_history($pdo, $mkStaff('Ben', 'agent', $d1))) === 0, 'fremde Nachrichten sehen Mitarbeiter nicht');
|
||||||
|
|
||||||
|
// Probemail
|
||||||
|
$sentMails = [];
|
||||||
|
$t = rundmail_test($anna, 'Wartung', 'Hallo {name}');
|
||||||
|
$check($t['ok'] && $sentMails[0]['to'] === 'anna@team.example' && str_starts_with($sentMails[0]['subject'], '[TEST]') && str_contains($sentMails[0]['html'], 'Hallo Anna'), 'Probemail geht an den Mitarbeiter');
|
||||||
|
$check(!rundmail_test($anna, '', 'x')['ok'], 'Probemail prüft die Eingaben');
|
||||||
|
|
||||||
|
unset($GLOBALS['rundmail_send']);
|
||||||
|
foreach (scandir($pluginDir) ?: [] as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
|
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
|
||||||
|
}
|
||||||
|
@rmdir($pluginDir);
|
||||||
|
@unlink($zipPath);
|
||||||
|
if ($failures) {
|
||||||
|
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
echo 'Rundmail plugin checks passed.' . "\n";
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft das Plugin "Schichtübergabe": Notizen, Sichtbarkeit je Abteilung, ungelesen/gelesen, Menü-Zähler, wichtige Notizen in der Seitenleiste,
|
||||||
|
* Löschrechte und automatisches Aufräumen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$pluginDir = sys_get_temp_dir() . '/deskhub-sh-' . bin2hex(random_bytes(4));
|
||||||
|
mkdir($pluginDir, 0755, true);
|
||||||
|
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
|
||||||
|
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';
|
||||||
|
|
||||||
|
$failures = [];
|
||||||
|
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||||
|
if (!$ok) $failures[] = $what;
|
||||||
|
};
|
||||||
|
$pdo = get_db();
|
||||||
|
|
||||||
|
require_once __DIR__ . '/fixtures/paths.php';
|
||||||
|
$src = plugin_source_dir_or_skip('schichtuebergabe', 'plugin_schichtuebergabe');
|
||||||
|
$zipPath = sys_get_temp_dir() . '/sh-' . bin2hex(random_bytes(3)) . '.zip';
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
$zip->open($zipPath, ZipArchive::CREATE);
|
||||||
|
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) {
|
||||||
|
$zip->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1)));
|
||||||
|
}
|
||||||
|
$zip->close();
|
||||||
|
$r = plugin_install_zip($pdo, $zipPath);
|
||||||
|
$check($r['ok'] && $r['slug'] === 'schichtuebergabe', 'Plugin lässt sich installieren: ' . json_encode($r));
|
||||||
|
$act = plugin_set_active($pdo, 'schichtuebergabe', true, ['hooks.cron', 'ui.menu', 'ui.sidebar', 'db.migrations']);
|
||||||
|
$check($act['ok'], 'Plugin lässt sich aktivieren: ' . json_encode($act));
|
||||||
|
$state = &plugin_state();
|
||||||
|
$state['booted'] = false;
|
||||||
|
plugins_boot();
|
||||||
|
$check(count(plugin_menu_items(['id' => 1, 'role' => 'agent'])) === 1, 'Menüpunkt ist für jeden Mitarbeiter sichtbar');
|
||||||
|
|
||||||
|
$d1 = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
|
||||||
|
$pdo->prepare("INSERT INTO departments (name, email, is_active, created_at) VALUES ('Technik', 'technik@team.example', 1, ?)")->execute([db_now()]);
|
||||||
|
$d2 = (int) $pdo->lastInsertId();
|
||||||
|
$mk = function (string $name, string $role, ?int $dept) use ($pdo): array {
|
||||||
|
$pdo->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)')
|
||||||
|
->execute([$name, strtolower($name) . '@team.example', 'x', $role, $dept, db_now()]);
|
||||||
|
return ['id' => (int) $pdo->lastInsertId(), 'role' => $role, 'department_id' => $dept, 'name' => $name];
|
||||||
|
};
|
||||||
|
$anna = $mk('Anna', 'agent', $d1);
|
||||||
|
$ben = $mk('Ben', 'agent', $d2);
|
||||||
|
$chef = $mk('Chef', 'admin', null);
|
||||||
|
|
||||||
|
// Anlegen und Prüfungen
|
||||||
|
$check(!schicht_create($pdo, $anna, ' ', null, false)['ok'], 'leere Notiz wird abgelehnt');
|
||||||
|
$check(!schicht_create($pdo, $anna, str_repeat('x', 3001), null, false)['ok'], 'zu lange Notiz wird abgelehnt');
|
||||||
|
$check(!schicht_create($pdo, $anna, 'Test', $d2, false)['ok'], 'nur für eigene Abteilungen schreiben');
|
||||||
|
$check(!schicht_create($pdo, $anna, 'Test', 99999, false)['ok'] && !schicht_create($pdo, $chef, 'Test', 99999, false)['ok'], 'unbekannte Abteilung wird abgelehnt');
|
||||||
|
$all = schicht_create($pdo, $anna, "Server X zickt.\nBitte beobachten.", null, true);
|
||||||
|
$mine = schicht_create($pdo, $anna, 'Nur für Abteilung 1', $d1, false);
|
||||||
|
$tech = schicht_create($pdo, $chef, 'Nur für Technik', $d2, false);
|
||||||
|
$check($all['ok'] && $mine['ok'] && $tech['ok'], 'Notizen lassen sich anlegen');
|
||||||
|
|
||||||
|
// Sichtbarkeit
|
||||||
|
$ids = fn(array $rows) => array_map(fn($n) => (int) $n['id'], $rows);
|
||||||
|
$check($ids(schicht_list($pdo, $ben)) === [$tech['id'], $all['id']], 'Ben sieht „alle“ und Technik, nicht Abteilung 1: ' . json_encode($ids(schicht_list($pdo, $ben))));
|
||||||
|
$check($ids(schicht_list($pdo, $anna)) === [$mine['id'], $all['id']], 'Anna sieht „alle“ und ihre Abteilung');
|
||||||
|
$check(count(schicht_list($pdo, $chef)) === 3, 'Administrator sieht alles');
|
||||||
|
|
||||||
|
// Ungelesen / gelesen
|
||||||
|
$check(schicht_unread_count($pdo, $anna) === 0, 'Verfasser hat die eigene Notiz schon gelesen');
|
||||||
|
$check(schicht_unread_count($pdo, $ben) === 2 && schicht_unread_count($pdo, $chef) === 2, 'Andere sehen sie als neu (Ben 2, Chef 2 ohne die eigene)');
|
||||||
|
$unread = schicht_unread($pdo, $ben);
|
||||||
|
$check(str_contains((string) $unread[1]['message'], "\n") && $unread[1]['author'] === 'Anna', 'Notiz enthält Zeilenumbruch und Verfasser');
|
||||||
|
$_SESSION['staff_id'] = $ben['id'];
|
||||||
|
$label = schicht_menu_label();
|
||||||
|
$check($label === 'Schichtübergabe (2 neu)', 'Menü zeigt den Zähler: ' . $label);
|
||||||
|
schicht_mark_read($pdo, $ben, [$all['id']]);
|
||||||
|
schicht_mark_read($pdo, $ben, [$all['id']]);
|
||||||
|
$check(schicht_unread_count($pdo, $ben) === 1, 'gelesene Notiz zählt nicht mehr (auch doppelt markieren schadet nicht)');
|
||||||
|
schicht_mark_read($pdo, $ben, array_map(fn($n) => $n['id'], schicht_unread($pdo, $ben)));
|
||||||
|
$check(schicht_unread_count($pdo, $ben) === 0, 'alles gelesen');
|
||||||
|
$check(schicht_readers($pdo, $all['id']) === ['Anna', 'Ben'], 'Verfasser sieht, wer gelesen hat: ' . json_encode(schicht_readers($pdo, $all['id'])));
|
||||||
|
unset($_SESSION['staff_id']);
|
||||||
|
|
||||||
|
// Seitenleiste: nur wichtige und ungelesene
|
||||||
|
$html = plugin_filter('ticket.view.sidebar', '', ['ticketId' => 1, 'ticket' => [], 'staff' => $chef]);
|
||||||
|
$check(str_contains($html, 'Wichtige Schichtübergabe') && str_contains($html, 'Server X zickt') && !str_contains($html, 'Nur für Technik'), 'Seitenleiste zeigt nur wichtige ungelesene Notizen');
|
||||||
|
$check(plugin_filter('ticket.view.sidebar', '', ['ticketId' => 1, 'ticket' => [], 'staff' => $ben]) === '', 'wer sie gelesen hat, sieht sie nicht mehr in der Seitenleiste');
|
||||||
|
$xss = schicht_create($pdo, $anna, '<script>alert(1)</script>', null, true);
|
||||||
|
$html = plugin_filter('ticket.view.sidebar', '', ['ticketId' => 1, 'ticket' => [], 'staff' => $ben]);
|
||||||
|
$check(str_contains($html, '<script>') && !str_contains($html, '<script>'), 'Inhalte werden in der Seitenleiste maskiert');
|
||||||
|
$check(plugin_filter('ticket.view.sidebar', '', ['ticketId' => 1, 'ticket' => [], 'staff' => null]) === '', 'ohne Mitarbeiter keine Seitenleiste');
|
||||||
|
|
||||||
|
// Der Verfasser sieht seine eigene wichtige Notiz ebenfalls (12 Stunden), mit der Zahl der Leser
|
||||||
|
$ownHtml = plugin_filter('ticket.view.sidebar', '', ['ticketId' => 1, 'ticket' => [], 'staff' => $anna]);
|
||||||
|
$check(str_contains($ownHtml, 'Ihre Notiz') && str_contains($ownHtml, 'Server X zickt') && str_contains($ownHtml, 'von 1 gelesen'), 'Verfasser sieht seine wichtige Notiz in der Seitenleiste, mit „von 1 gelesen“ (Ben): ' . strip_tags($ownHtml));
|
||||||
|
$check(!str_contains($ownHtml, 'Nur für Abteilung 1'), 'unwichtige eigene Notizen erscheinen dort nicht');
|
||||||
|
$pdo->prepare('UPDATE plugin_schichtuebergabe_notes SET created_at = ? WHERE id = ?')->execute([date('Y-m-d H:i:s', time() - 13 * 3600), $all['id']]);
|
||||||
|
$check(!str_contains(plugin_filter('ticket.view.sidebar', '', ['ticketId' => 1, 'ticket' => [], 'staff' => $anna]), 'Server X zickt'), 'nach 12 Stunden verschwindet die eigene Notiz aus der Seitenleiste');
|
||||||
|
$pdo->prepare('UPDATE plugin_schichtuebergabe_notes SET created_at = ? WHERE id = ?')->execute([db_now(), $all['id']]);
|
||||||
|
$check(schicht_unread_count($pdo, $anna) === 0, 'die eigene Notiz zählt trotzdem nicht als „neu“ im Menü');
|
||||||
|
$both = schicht_sidebar_notes($pdo, $chef);
|
||||||
|
$check(count(array_unique(array_column($both, 'id'))) === count($both), 'keine Notiz doppelt (ungelesen und eigene)');
|
||||||
|
|
||||||
|
// Löschen
|
||||||
|
$check(!schicht_delete($pdo, $ben, $all['id']), 'fremde Notiz kann ein Mitarbeiter nicht löschen');
|
||||||
|
$check(schicht_delete($pdo, $anna, $mine['id']), 'der Verfasser kann löschen');
|
||||||
|
$check(schicht_delete($pdo, $chef, $all['id']), 'Administrator kann jede Notiz löschen');
|
||||||
|
$check((int) $pdo->query("SELECT COUNT(*) FROM plugin_schichtuebergabe_reads WHERE note_id IN ({$all['id']}, {$mine['id']})")->fetchColumn() === 0, 'Lesemarken werden mit gelöscht');
|
||||||
|
$check(!schicht_delete($pdo, $chef, 99999), 'unbekannte Notiz');
|
||||||
|
|
||||||
|
// Aufbewahrung
|
||||||
|
plugin_set_setting('schichtuebergabe', 'keep_days', '7');
|
||||||
|
$pdo->prepare('UPDATE plugin_schichtuebergabe_notes SET created_at = ? WHERE id = ?')->execute([date('Y-m-d H:i:s', time() - 10 * 86400), $tech['id']]);
|
||||||
|
$check(!in_array($tech['id'], $ids(schicht_list($pdo, $chef)), true), 'Notizen älter als die Frist werden nicht mehr angezeigt');
|
||||||
|
plugin_set_setting('schichtuebergabe', 'last_cleanup', '0');
|
||||||
|
$check(schicht_cleanup($pdo) >= 1 && (int) $pdo->query("SELECT COUNT(*) FROM plugin_schichtuebergabe_notes WHERE id = {$tech['id']}")->fetchColumn() === 0, 'Aufräumen löscht abgelaufene Notizen');
|
||||||
|
$check(schicht_cleanup($pdo) === 0, 'Aufräumen läuft höchstens einmal pro Stunde');
|
||||||
|
plugin_set_setting('schichtuebergabe', 'keep_days', '0');
|
||||||
|
$check(schicht_keep_days() === 1, 'Frist hat eine Untergrenze');
|
||||||
|
|
||||||
|
plugin_set_setting('schichtuebergabe', 'keep_days', '30');
|
||||||
|
// Deutlicher Hinweis oben auf jeder Seite (auch direkt nach der Anmeldung), solange etwas ungelesen ist
|
||||||
|
$check(plugin_filter_permission('staff.page.top') === 'ui.sidebar', 'der Seitenkopf-Hinweis braucht die Berechtigung ui.sidebar (hat das Plugin)');
|
||||||
|
$check(str_contains((string) file_get_contents(dirname(__DIR__) . '/staff/_header.php'), "plugin_filter('staff.page.top'"), 'das Layout des Mitarbeiterbereichs ruft den Hinweis oben auf jeder Seite auf');
|
||||||
|
$bannerFor = fn(?array $who, string $page = 'reports') => plugin_filter('staff.page.top', '', ['staff' => $who, 'activePage' => $page]);
|
||||||
|
$pdo->exec('DELETE FROM plugin_schichtuebergabe_reads');
|
||||||
|
$pdo->exec('DELETE FROM plugin_schichtuebergabe_notes');
|
||||||
|
$pdo->exec('DELETE FROM plugin_schichtuebergabe_reads');
|
||||||
|
$check($bannerFor($ben) === '', 'ohne Notizen kein Hinweis');
|
||||||
|
$n1 = schicht_create($pdo, $anna, "Normale Info\nzweite Zeile", null, false);
|
||||||
|
$h1 = $bannerFor($ben);
|
||||||
|
$check(str_contains($h1, 'Schichtübergabe: 1 neue Notiz') && str_contains($h1, 'Normale Info') && str_contains($h1, '#0f6f6a') && !str_contains($h1, 'wichtig)') && str_contains($h1, 'page=schichtuebergabe') && str_contains($h1, 'Normale Info<br />'), 'eine normale Notiz: grüner Hinweis mit Text und Link');
|
||||||
|
$n2 = schicht_create($pdo, $anna, 'Server X ist ausgefallen', null, true);
|
||||||
|
$h2 = $bannerFor($ben);
|
||||||
|
$check(str_contains($h2, '2 neue Notizen') && str_contains($h2, '(1 wichtig)') && str_contains($h2, '#dc2626') && strpos($h2, 'Server X ist ausgefallen') < strpos($h2, 'Normale Info'), 'mit wichtiger Notiz: roter Hinweis, die wichtige zuerst');
|
||||||
|
$check($bannerFor($anna, 'tickets') === '' && !str_contains($bannerFor($anna, 'tickets'), 'neue Notiz'), 'auf anderen Seiten sieht der Verfasser keinen „neu“-Hinweis zu seinen eigenen Notizen');
|
||||||
|
$own = $bannerFor($anna, 'reports');
|
||||||
|
$check(str_contains($own, 'Ihre Notiz an die nächste Schicht') && str_contains($own, 'Server X ist ausgefallen') && str_contains($own, 'Normale Info') && str_contains($own, 'noch von niemandem gelesen') && !str_contains($own, 'neue Notiz') && str_contains($own, '#93c5fd'), 'auf der Übersicht sieht der Verfasser seine eigenen Notizen als ruhige Bestätigung (blau), noch ungelesen: ' . substr_count($own, 'schicht-banner-own') . ' Kästen');
|
||||||
|
$check(substr_count($own, 'schicht-banner-own') === 2 && str_contains($own, '(wichtig)') && str_contains($own, 'alle Mitarbeiter'), 'beide Notizen als eigene Kästen, die wichtige ist gekennzeichnet');
|
||||||
|
$check(str_contains($bannerFor($anna, 'dashboard'), 'Ihre Notiz an die nächste Schicht'), 'auch auf der Dashboard-Seite');
|
||||||
|
schicht_mark_read($pdo, $ben, [$n2['id']]);
|
||||||
|
$check(str_contains($bannerFor($anna, 'reports'), 'Gelesen von: Ben'), 'der Verfasser sieht, wer die Notiz gelesen hat');
|
||||||
|
$check($bannerFor($ben, 'plugin_schichtuebergabe') === '', 'auf der Seite der Schichtübergabe selbst entfallen die Hinweise');
|
||||||
|
$pdo->prepare('UPDATE plugin_schichtuebergabe_notes SET created_at = ? WHERE id IN (?, ?)')->execute([date('Y-m-d H:i:s', time() - 13 * 3600), $n1['id'], $n2['id']]);
|
||||||
|
$check(!str_contains($bannerFor($anna, 'reports'), 'Ihre Notiz an die nächste Schicht'), 'nach 12 Stunden verschwindet die Bestätigung des Verfassers');
|
||||||
|
$pdo->prepare('UPDATE plugin_schichtuebergabe_notes SET created_at = ? WHERE id IN (?, ?)')->execute([db_now(), $n1['id'], $n2['id']]);
|
||||||
|
$check($bannerFor($ben, 'plugin_schichtuebergabe') === '', 'auf der Seite der Schichtübergabe selbst erscheint der Hinweis nicht');
|
||||||
|
for ($i = 0; $i < 4; $i++) schicht_create($pdo, $anna, 'Weitere ' . $i, null, false);
|
||||||
|
$check(str_contains($bannerFor($ben), '… und 2 weitere') && substr_count($bannerFor($ben), '<strong>Anna</strong>') === 3, 'höchstens drei Notizen im Hinweis, der Rest als „weitere“');
|
||||||
|
schicht_create($pdo, $anna, '<img src=x onerror=alert(1)>', null, true);
|
||||||
|
$check(!str_contains($bannerFor($ben), '<img') && str_contains($bannerFor($ben), '<img'), 'Inhalte im Hinweis werden maskiert');
|
||||||
|
schicht_mark_read($pdo, $ben, array_map(fn($n) => $n['id'], schicht_unread($pdo, $ben)));
|
||||||
|
$check($bannerFor($ben) === '', 'nach dem Lesen verschwindet der Hinweis');
|
||||||
|
$tc = schicht_create($pdo, $chef, 'Nur Technik', $d2, false);
|
||||||
|
$check(!str_contains($bannerFor($anna, 'tickets'), 'Nur Technik') && str_contains($bannerFor($ben), 'Nur Technik'), 'Abteilungsnotizen erscheinen nur bei der Abteilung');
|
||||||
|
$check($bannerFor(null) === '', 'ohne Mitarbeiter kein Hinweis');
|
||||||
|
|
||||||
|
// ── Seite: Gelesen-Knöpfe, nichts wird beim Öffnen automatisch gelesen ──────
|
||||||
|
$_SESSION['csrf_token'] = 'tok';
|
||||||
|
$shPage = $pluginDir . '/schichtuebergabe/pages/schichtuebergabe.php';
|
||||||
|
$shRender = function (array $who, string $method = 'GET', array $post = []) use ($pdo, $shPage): string {
|
||||||
|
$_SERVER['REQUEST_METHOD'] = $method;
|
||||||
|
$_POST = $post + ($method === 'POST' ? ['csrf_token' => 'tok'] : []);
|
||||||
|
$staff = $who;
|
||||||
|
ob_start();
|
||||||
|
include $shPage;
|
||||||
|
return (string) ob_get_clean();
|
||||||
|
};
|
||||||
|
$pdo->exec('DELETE FROM plugin_schichtuebergabe_reads');
|
||||||
|
$pdo->exec('DELETE FROM plugin_schichtuebergabe_notes');
|
||||||
|
$u1 = schicht_create($pdo, $anna, 'Lesetest eins', null, false)['id'];
|
||||||
|
$u2 = schicht_create($pdo, $anna, 'Lesetest zwei', null, true)['id'];
|
||||||
|
$pg = $shRender($ben);
|
||||||
|
$check(count(schicht_unread($pdo, $ben)) === 2 && str_contains($pg, 'Alle 2 als gelesen markieren') && substr_count($pg, '✓ Gelesen') === 2, 'Öffnen der Seite markiert nichts automatisch, Knöpfe sind da');
|
||||||
|
$check(str_contains($bannerFor($ben), '✓ Gelesen') && str_contains($bannerFor($ben), 'value="mark_all"'), 'der Hinweis hat einen Gelesen-Knopf');
|
||||||
|
$pg = $shRender($ben, 'POST', ['action' => 'mark_read', 'id' => (string) $u1]);
|
||||||
|
$check(count(schicht_unread($pdo, $ben)) === 1 && str_contains($pg, 'Als gelesen markiert') && str_contains($pg, 'Alle 1 als gelesen markieren'), 'eine Notiz einzeln als gelesen markieren');
|
||||||
|
$pg = $shRender($ben, 'POST', ['action' => 'mark_all']);
|
||||||
|
$check(count(schicht_unread($pdo, $ben)) === 0 && !str_contains($pg, 'als gelesen markieren</button>') && $bannerFor($ben) === '', 'alle als gelesen: Hinweis verschwindet');
|
||||||
|
$check(count(schicht_unread($pdo, $anna)) === 0 && !str_contains($shRender($anna), '✓ Gelesen'), 'eigene Notizen sind schon gelesen, kein Knopf');
|
||||||
|
$check(substr_count($bannerFor($anna, 'reports'), 'schicht-banner-own') === 2 && str_contains($bannerFor($anna, 'reports'), 'value="hide_own"'), 'eigene Bestätigungen haben einen Ausblenden-Knopf');
|
||||||
|
$shRender($anna, 'POST', ['action' => 'hide_own', 'id' => (string) $u2]);
|
||||||
|
$check(substr_count($bannerFor($anna, 'reports'), 'schicht-banner-own') === 1 && !str_contains($bannerFor($anna, 'reports'), 'Lesetest zwei') && str_contains($shRender($anna), 'Lesetest zwei'), 'ausgeblendete Bestätigung verschwindet, die Notiz bleibt in der Liste');
|
||||||
|
$check(substr_count($bannerFor($chef, 'reports'), 'schicht-banner-own') === 0 && count(schicht_unread($pdo, $ben)) === 0, 'Ausblenden betrifft nur den eigenen Mitarbeiter');
|
||||||
|
|
||||||
|
foreach (scandir($pluginDir) ?: [] as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
|
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
|
||||||
|
}
|
||||||
|
@rmdir($pluginDir);
|
||||||
|
@unlink($zipPath);
|
||||||
|
if ($failures) {
|
||||||
|
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
echo 'Schichtübergabe plugin checks passed.' . "\n";
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft das Plugin "Spam- und Missbrauchsfilter": Bewertung (Phishing, Marken-Nachahmung, Links, Wegwerf-Adressen, Wiederholungen, Vertrauen,
|
||||||
|
* Listen, eigene Wörter), Entscheidung beim Anlegen eines Tickets (ablegen, Verdacht, nichts), Korrekturen, Seitenleiste und Maskierung.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$pluginDir = sys_get_temp_dir() . '/deskhub-sf-' . bin2hex(random_bytes(4));
|
||||||
|
mkdir($pluginDir, 0755, true);
|
||||||
|
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
|
||||||
|
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';
|
||||||
|
|
||||||
|
$failures = [];
|
||||||
|
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||||
|
if (!$ok) $failures[] = $what;
|
||||||
|
};
|
||||||
|
$pdo = get_db();
|
||||||
|
|
||||||
|
require_once __DIR__ . '/fixtures/paths.php';
|
||||||
|
$src = plugin_source_dir_or_skip('spamfilter', 'plugin_spamfilter');
|
||||||
|
$zipPath = sys_get_temp_dir() . '/sf-' . bin2hex(random_bytes(3)) . '.zip';
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
$zip->open($zipPath, ZipArchive::CREATE);
|
||||||
|
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) {
|
||||||
|
$zip->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1)));
|
||||||
|
}
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
$r = plugin_install_zip($pdo, $zipPath);
|
||||||
|
$check($r['ok'] && $r['slug'] === 'spamfilter', 'Plugin lässt sich installieren: ' . json_encode($r));
|
||||||
|
$act = plugin_set_active($pdo, 'spamfilter', true, ['hooks.tickets', 'ui.menu', 'ui.sidebar', 'db.migrations']);
|
||||||
|
$check($act['ok'], 'Plugin lässt sich aktivieren: ' . json_encode($act));
|
||||||
|
$state = &plugin_state();
|
||||||
|
$state['booted'] = false;
|
||||||
|
plugins_boot();
|
||||||
|
$check(isset(plugin_state()['loaded']['spamfilter']), 'Plugin wird geladen');
|
||||||
|
$check(count(plugin_menu_items(['id' => 1, 'role' => 'admin'])) === 1, 'Menüpunkt ist registriert');
|
||||||
|
|
||||||
|
$dept = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
|
||||||
|
$prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn();
|
||||||
|
$n = 0;
|
||||||
|
$mk = function (string $name, string $email, string $subject, string $message, string $source = 'web', string $status = 'open', ?string $created = null) use ($pdo, $dept, $prio, &$n): int {
|
||||||
|
$n++;
|
||||||
|
$created ??= db_now();
|
||||||
|
$pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)')
|
||||||
|
->execute(['SF-' . $n, $subject, $name, $email, $dept, $prio, $status, $source, $created, $created]);
|
||||||
|
$id = (int) $pdo->lastInsertId();
|
||||||
|
$pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'customer', ?, 0, ?)")->execute([$id, $message, $created]);
|
||||||
|
return $id;
|
||||||
|
};
|
||||||
|
$sc = fn(array $m) => spamfilter_score($pdo, $m);
|
||||||
|
|
||||||
|
// ── Bewertung ──────────────────────────────────────────────────────────────
|
||||||
|
$phish = ['name' => 'Netflix Support', 'email' => 'billing@secure-mailer.example', 'subject' => 'Your membership is on hold', 'message' => "We're processing your refund. Click here https://bit.ly/abc123 to update your payment."];
|
||||||
|
$p = $sc($phish);
|
||||||
|
$check($p['score'] >= 70, 'Phishing-Mail liegt über der Spam-Schwelle: ' . $p['score'] . ' ' . json_encode($p['reasons'], JSON_UNESCAPED_UNICODE));
|
||||||
|
$check(count(array_filter($p['reasons'], fn($x) => str_contains($x, 'Marke netflix'))) === 1, 'Marken-Nachahmung wird erkannt');
|
||||||
|
$check(count(array_filter($p['reasons'], fn($x) => str_contains($x, 'Link-Verkürzer'))) === 1, 'Link-Verkürzer wird erkannt');
|
||||||
|
$ok = $sc(['name' => 'Erika Muster', 'email' => 'erika@kunde.example', 'subject' => 'Passwort zurücksetzen', 'message' => 'Hallo, ich habe mein Passwort vergessen und komme nicht mehr in mein Konto. Können Sie mir helfen?']);
|
||||||
|
$check($ok['score'] < 35, 'normale Kundenanfrage bleibt unter dem Verdacht: ' . $ok['score']);
|
||||||
|
$legit = $sc(['name' => 'Anna Muster', 'email' => 'anna@kunde.example', 'subject' => 'Frage zu Amazon-Bestellung', 'message' => 'Ich habe bei Amazon bestellt und das Paket ist nicht da.']);
|
||||||
|
$check($legit['score'] < 35, 'eine Marke im Text allein ist kein Spam (nur im Absendernamen zählt sie): ' . $legit['score']);
|
||||||
|
$custom = $sc(['name' => 'Apple', 'email' => 'jemand@apple.example', 'subject' => 'Hallo', 'message' => 'Gruß von Apple.']);
|
||||||
|
$check(!array_filter($custom['reasons'], fn($x) => str_contains($x, 'Marke')), 'Marke mit passender Domain ist unverdächtig');
|
||||||
|
|
||||||
|
plugin_set_setting('spamfilter', 'allow', "partner.example\n# Kommentar\nkim@firma.example");
|
||||||
|
$check($sc(['name' => 'Netflix', 'email' => 'x@partner.example', 'subject' => 'Your membership is on hold', 'message' => $phish['message']])['score'] === 0, 'Domain auf der Freigabeliste wird nie bewertet');
|
||||||
|
$check($sc(['name' => 'Netflix', 'email' => 'x@mail.partner.example', 'subject' => 'Your membership is on hold', 'message' => $phish['message']])['allowed'] === true, 'auch Unterdomains der Freigabeliste');
|
||||||
|
$check($sc(['name' => 'Kim', 'email' => 'kim@firma.example', 'subject' => 'Your membership is on hold', 'message' => $phish['message']])['allowed'] === true, 'einzelne Adresse auf der Freigabeliste');
|
||||||
|
plugin_set_setting('spamfilter', 'allow', '');
|
||||||
|
plugin_set_setting('spamfilter', 'block', 'boese.example');
|
||||||
|
$check($sc(['name' => 'X', 'email' => 'a@boese.example', 'subject' => 'Hallo', 'message' => 'Ein harmloser Text ohne Besonderheiten.'])['score'] >= 100, 'Sperrliste gibt 100 Punkte');
|
||||||
|
plugin_set_setting('spamfilter', 'block', '');
|
||||||
|
$check($sc(['name' => 'X', 'email' => 'a@mailinator.com', 'subject' => 'Hallo', 'message' => 'Ein harmloser Text ohne Besonderheiten.'])['score'] === 30, 'Wegwerf-Adresse gibt 30 Punkte');
|
||||||
|
$many = $sc(['name' => 'X', 'email' => 'a@kunde.example', 'subject' => 'Hallo', 'message' => str_repeat('http://x.example/a ', 10)]);
|
||||||
|
$check($many['score'] >= 30, 'viele Links geben Punkte');
|
||||||
|
$caps = $sc(['name' => 'X', 'email' => 'a@kunde.example', 'subject' => 'DRINGEND BITTE SOFORT ANTWORTEN', 'message' => 'Ein normaler Text mit genug Zeichen für den Test.']);
|
||||||
|
$check(count(array_filter($caps['reasons'], fn($x) => str_contains($x, 'Großbuchstaben'))) === 1, 'Betreff in Großbuchstaben gibt Punkte');
|
||||||
|
plugin_set_setting('spamfilter', 'words', "lieferpilz=50\n# nichts\ngewinnspiel");
|
||||||
|
$cw = $sc(['name' => 'X', 'email' => 'a@kunde.example', 'subject' => 'Hallo', 'message' => 'Das Lieferpilz-Angebot ist toll. Gewinnspiel inklusive.']);
|
||||||
|
$check($cw['score'] === 50 + 20, 'eigene Wörter mit und ohne Punkte (50 + 20): ' . $cw['score']);
|
||||||
|
plugin_set_setting('spamfilter', 'words', '');
|
||||||
|
|
||||||
|
// Vertrauen: bekannter Absender mit früherem gelösten Ticket
|
||||||
|
$mk('Stammkunde', 'stamm@kunde.example', 'Altes Ticket', 'Hilfe', 'web', 'closed', date('Y-m-d H:i:s', time() - 5 * 86400));
|
||||||
|
$mild = ['name' => 'Stammkunde', 'email' => 'stamm@kunde.example', 'subject' => 'Hallo', 'message' => 'Bitte klicken Sie hier für den Gewinn, gewonnen!'];
|
||||||
|
$unknown = $sc(['email' => 'neu@kunde.example'] + $mild);
|
||||||
|
$known = $sc($mild);
|
||||||
|
$check($known['score'] === max(0, $unknown['score'] - 40), 'bekannter Absender bekommt 40 Punkte Abzug (' . $unknown['score'] . ' → ' . $known['score'] . ')');
|
||||||
|
$check($sc(['user_id' => 5] + ['email' => 'neu@kunde.example'] + $mild)['score'] === max(0, $unknown['score'] - 30), 'angemeldeter Kunde bekommt 30 Punkte Abzug');
|
||||||
|
|
||||||
|
// Kampagne: gleicher Betreff von drei Absendern
|
||||||
|
$mk('A', 'a1@x1.example', 'Rechnung offen 4711', 'Bitte zahlen', 'web');
|
||||||
|
$mk('B', 'b1@x2.example', 'Rechnung offen 4711', 'Bitte zahlen', 'web');
|
||||||
|
$camp = $sc(['name' => 'C', 'email' => 'c1@x3.example', 'subject' => 'Rechnung offen 4711', 'message' => 'Bitte zahlen Sie den offenen Betrag sofort an dieses Konto.']);
|
||||||
|
$check(count(array_filter($camp['reasons'], fn($x) => str_contains($x, 'verschiedenen Absendern'))) >= 1, 'gleicher Betreff von mehreren Absendern wird erkannt: ' . json_encode($camp['reasons'], JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
|
// ── Entscheidung beim Anlegen ──────────────────────────────────────────────
|
||||||
|
$tSpam = $mk($phish['name'], $phish['email'], $phish['subject'], $phish['message'], 'web');
|
||||||
|
webhook_dispatch($pdo, 'ticket.created', $tSpam);
|
||||||
|
$row = $pdo->query("SELECT is_spam, status FROM tickets WHERE id = {$tSpam}")->fetch();
|
||||||
|
$check((int) $row['is_spam'] === 1 && $row['status'] === 'closed', 'eindeutiger Spam wird über das Ticket-Ereignis abgelegt');
|
||||||
|
$tag = fn(int $id, string $name) => (int) $pdo->query("SELECT COUNT(*) FROM ticket_tags tt JOIN tags g ON g.id = tt.tag_id WHERE tt.ticket_id = {$id} AND g.name = " . $pdo->quote($name))->fetchColumn();
|
||||||
|
$check($tag($tSpam, 'Spam') === 1, 'abgelegtes Ticket trägt den Tag „Spam“');
|
||||||
|
$check($pdo->query("SELECT decision FROM plugin_spamfilter_log WHERE ticket_id = {$tSpam}")->fetchColumn() === 'spam', 'Entscheidung steht im Protokoll');
|
||||||
|
|
||||||
|
$tSus = $mk('Y', 'y@mailinator.com', 'Hallo', 'Schauen Sie hier: http://bit.ly/zz1 bitte.', 'web');
|
||||||
|
webhook_dispatch($pdo, 'ticket.created', $tSus);
|
||||||
|
$row = $pdo->query("SELECT is_spam, status FROM tickets WHERE id = {$tSus}")->fetch();
|
||||||
|
$check((int) $row['is_spam'] === 0 && $row['status'] === 'open' && $tag($tSus, 'Spam-Verdacht') === 1, 'Zweifelsfall bleibt offen und bekommt den Tag „Spam-Verdacht“');
|
||||||
|
|
||||||
|
$tOk = $mk('Erika', 'erika2@kunde.example', 'Frage', 'Wie lange dauert der Versand meiner Bestellung ungefähr?', 'web');
|
||||||
|
webhook_dispatch($pdo, 'ticket.created', $tOk);
|
||||||
|
$check((int) $pdo->query("SELECT is_spam FROM tickets WHERE id = {$tOk}")->fetchColumn() === 0 && $tag($tOk, 'Spam-Verdacht') === 0 && $pdo->query("SELECT decision FROM plugin_spamfilter_log WHERE ticket_id = {$tOk}")->fetchColumn() === 'ok', 'normales Ticket bleibt unberührt');
|
||||||
|
|
||||||
|
foreach (['chat', 'staff'] as $src) {
|
||||||
|
$tx = $mk($phish['name'], $phish['email'], $phish['subject'], $phish['message'], $src);
|
||||||
|
webhook_dispatch($pdo, 'ticket.created', $tx);
|
||||||
|
$check((int) $pdo->query("SELECT is_spam FROM tickets WHERE id = {$tx}")->fetchColumn() === 0 && !$pdo->query("SELECT COUNT(*) FROM plugin_spamfilter_log WHERE ticket_id = {$tx}")->fetchColumn(), "Tickets mit Quelle „{$src}“ werden nicht bewertet");
|
||||||
|
}
|
||||||
|
$tMail = $mk($phish['name'], $phish['email'], $phish['subject'], $phish['message'], 'email');
|
||||||
|
webhook_dispatch($pdo, 'ticket.created', $tMail);
|
||||||
|
$check((int) $pdo->query("SELECT is_spam FROM tickets WHERE id = {$tMail}")->fetchColumn() === 1, 'E-Mail-Tickets werden ebenfalls abgelegt');
|
||||||
|
|
||||||
|
plugin_set_setting('spamfilter', 'enabled', '0');
|
||||||
|
$tOff = $mk($phish['name'], $phish['email'], $phish['subject'], $phish['message'], 'web');
|
||||||
|
webhook_dispatch($pdo, 'ticket.created', $tOff);
|
||||||
|
$check((int) $pdo->query("SELECT is_spam FROM tickets WHERE id = {$tOff}")->fetchColumn() === 0, 'ausgeschaltet: nichts passiert');
|
||||||
|
plugin_set_setting('spamfilter', 'enabled', '1');
|
||||||
|
plugin_set_setting('spamfilter', 'auto', '0');
|
||||||
|
$tNoAuto = $mk($phish['name'], $phish['email'], $phish['subject'], $phish['message'], 'web');
|
||||||
|
webhook_dispatch($pdo, 'ticket.created', $tNoAuto);
|
||||||
|
$check((int) $pdo->query("SELECT is_spam FROM tickets WHERE id = {$tNoAuto}")->fetchColumn() === 0 && $tag($tNoAuto, 'Spam-Verdacht') === 1, 'ohne automatische Ablage wird nur markiert');
|
||||||
|
plugin_set_setting('spamfilter', 'auto', '1');
|
||||||
|
plugin_set_setting('spamfilter', 'ban', '1');
|
||||||
|
$tBan = $mk($phish['name'], 'ban-me@secure-mailer.example', $phish['subject'], $phish['message'], 'web');
|
||||||
|
webhook_dispatch($pdo, 'ticket.created', $tBan);
|
||||||
|
$check((int) $pdo->query("SELECT COUNT(*) FROM banlist WHERE value = 'ban-me@secure-mailer.example'")->fetchColumn() === 1, 'mit der Option wird der Absender gesperrt');
|
||||||
|
plugin_set_setting('spamfilter', 'ban', '0');
|
||||||
|
|
||||||
|
// ── Korrekturen ────────────────────────────────────────────────────────────
|
||||||
|
$check(spamfilter_not_spam($pdo, $tSpam, 1, true), '„Kein Spam“ klappt');
|
||||||
|
$row = $pdo->query("SELECT is_spam, status FROM tickets WHERE id = {$tSpam}")->fetch();
|
||||||
|
$check((int) $row['is_spam'] === 0 && $row['status'] === 'open' && $tag($tSpam, 'Spam') === 0, 'Ticket ist wieder offen, Tag entfernt');
|
||||||
|
$check(in_array($phish['email'], spamfilter_lines((string) plugin_setting('spamfilter', 'allow', '')), true), 'Absender landet auf der Freigabeliste');
|
||||||
|
$check($pdo->query("SELECT decision FROM plugin_spamfilter_log WHERE ticket_id = {$tSpam}")->fetchColumn() === 'manual_ok', 'Protokoll zeigt die Korrektur');
|
||||||
|
$check(spamfilter_confirm_spam($pdo, $tSus, 1, true), '„Doch Spam“ klappt');
|
||||||
|
$check((int) $pdo->query("SELECT is_spam FROM tickets WHERE id = {$tSus}")->fetchColumn() === 1 && $tag($tSus, 'Spam-Verdacht') === 0, 'Verdachts-Ticket ist abgelegt, Verdachts-Tag entfernt');
|
||||||
|
$check((int) $pdo->query("SELECT COUNT(*) FROM banlist WHERE value = 'y@mailinator.com'")->fetchColumn() === 1, 'Absender gesperrt');
|
||||||
|
$check(!spamfilter_confirm_spam($pdo, $tSus, 1, false), 'zweites „Doch Spam“ wird abgelehnt');
|
||||||
|
|
||||||
|
// ── Seitenleiste und Maskierung ────────────────────────────────────────────
|
||||||
|
$evil = $mk('Netflix <script>alert(1)</script>', 'e@evil.example', 'Your membership is on hold', $phish['message'], 'web');
|
||||||
|
webhook_dispatch($pdo, 'ticket.created', $evil);
|
||||||
|
$html = plugin_filter('ticket.view.sidebar', '', ['ticketId' => $evil, 'ticket' => [], 'staff' => ['id' => 1, 'role' => 'admin']]);
|
||||||
|
$check(str_contains($html, 'Spamfilter') && str_contains($html, 'Punkte') && !str_contains($html, '<script>'), 'Seitenleiste zeigt die Bewertung und maskiert alles');
|
||||||
|
$check(plugin_filter('ticket.view.sidebar', '', ['ticketId' => 999999, 'ticket' => [], 'staff' => []]) === '', 'ohne Bewertung keine Seitenleiste');
|
||||||
|
$st = spamfilter_stats($pdo);
|
||||||
|
$check($st['spam'] >= 2 && $st['ok'] >= 1 && $st['suspect'] >= 1, 'Kennzahlen zählen die Entscheidungen: ' . json_encode($st));
|
||||||
|
|
||||||
|
foreach (scandir($pluginDir) ?: [] as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
|
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
|
||||||
|
}
|
||||||
|
@rmdir($pluginDir);
|
||||||
|
@unlink($zipPath);
|
||||||
|
|
||||||
|
if ($failures) {
|
||||||
|
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
echo 'Spamfilter plugin checks passed.' . "\n";
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft das Plugin "Website- und Server-Überwachung": Eingaben, Ausfall nach mehreren Fehlschlägen (Ticket, Mail), Entwarnung (Ticket gelöst),
|
||||||
|
* Zertifikatswarnung und Erneuerung, Pausieren, Fälligkeit, und die echten Prüfungen gegen einen lokalen Webserver und Port.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$pluginDir = sys_get_temp_dir() . '/deskhub-ue-' . bin2hex(random_bytes(4));
|
||||||
|
mkdir($pluginDir, 0755, true);
|
||||||
|
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
|
||||||
|
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';
|
||||||
|
|
||||||
|
$failures = [];
|
||||||
|
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||||
|
if (!$ok) $failures[] = $what;
|
||||||
|
};
|
||||||
|
$pdo = get_db();
|
||||||
|
|
||||||
|
require_once __DIR__ . '/fixtures/paths.php';
|
||||||
|
$src = plugin_source_dir_or_skip('ueberwachung', 'plugin_ueberwachung');
|
||||||
|
$zipPath = sys_get_temp_dir() . '/ue-' . bin2hex(random_bytes(3)) . '.zip';
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
$zip->open($zipPath, ZipArchive::CREATE);
|
||||||
|
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) {
|
||||||
|
$zip->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1)));
|
||||||
|
}
|
||||||
|
$zip->close();
|
||||||
|
$r = plugin_install_zip($pdo, $zipPath);
|
||||||
|
$check($r['ok'] && $r['slug'] === 'ueberwachung', 'Plugin lässt sich installieren: ' . json_encode($r));
|
||||||
|
$act = plugin_set_active($pdo, 'ueberwachung', true, ['hooks.cron', 'ui.menu', 'db.migrations']);
|
||||||
|
$check($act['ok'], 'Plugin lässt sich aktivieren: ' . json_encode($act));
|
||||||
|
$state = &plugin_state();
|
||||||
|
$state['booted'] = false;
|
||||||
|
plugins_boot();
|
||||||
|
$check(count(plugin_admin_menu_items(['id' => 1, 'role' => 'admin'])) === 1 && count(plugin_menu_items(['id' => 1, 'role' => 'admin'])) === 0 && count(plugin_admin_menu_items(['id' => 2, 'role' => 'agent'])) === 0, 'Menüpunkt steht im Adminportal und nur für Administratoren');
|
||||||
|
|
||||||
|
set_setting('admin_email', 'admin@firma.example');
|
||||||
|
$mails = [];
|
||||||
|
$GLOBALS['ueberwachung_mail'] = function (string $to, string $subject, string $html) use (&$mails): void { $mails[] = compact('to', 'subject', 'html'); };
|
||||||
|
$answer = ['ok' => true, 'error' => null, 'ms' => 40, 'cert_expires' => null];
|
||||||
|
$GLOBALS['ueberwachung_probe'] = function (array $m) use (&$answer): array { return $answer; };
|
||||||
|
$mon = fn(int $id) => $pdo->query("SELECT * FROM plugin_ueberwachung_monitors WHERE id = {$id}")->fetch();
|
||||||
|
$ticket = fn(?int $id) => $id ? $pdo->query("SELECT * FROM tickets WHERE id = " . (int) $id)->fetch() : null;
|
||||||
|
|
||||||
|
// Eingaben
|
||||||
|
$bad = fn(array $in) => !ueberwachung_save($pdo, $in)['ok'];
|
||||||
|
$check($bad(['name' => '', 'type' => 'http', 'target' => 'https://x.example/']), 'Name ist Pflicht');
|
||||||
|
$check($bad(['name' => 'A', 'type' => 'http', 'target' => 'ftp://x.example']) && $bad(['name' => 'A', 'type' => 'http', 'target' => 'javascript:alert(1)']) && $bad(['name' => 'A', 'type' => 'http', 'target' => 'https://']), 'nur http(s)-Adressen');
|
||||||
|
$check($bad(['name' => 'A', 'type' => 'port', 'target' => 'host']) && $bad(['name' => 'A', 'type' => 'port', 'target' => 'host:99999']) && $bad(['name' => 'A', 'type' => 'port', 'target' => 'a b:80']) && $bad(['name' => 'A', 'type' => 'xyz', 'target' => 'h:80']), 'Port, Host und Art werden geprüft');
|
||||||
|
$web = ueberwachung_save($pdo, ['name' => 'Support', 'type' => 'http', 'target' => 'https://support.example.de/', 'interval_min' => 0, 'timeout_sec' => 999, 'cert_warn_days' => 14]);
|
||||||
|
$m1 = $mon($web['id']);
|
||||||
|
$check($web['ok'] && (int) $m1['interval_min'] === 1 && (int) $m1['timeout_sec'] === 30, 'Werte werden begrenzt');
|
||||||
|
$check(ueberwachung_save($pdo, ['name' => 'Support neu', 'type' => 'http', 'target' => 'https://support.example.de/', 'interval_min' => 5], $web['id'])['ok'] && $mon($web['id'])['name'] === 'Support neu', 'Ändern');
|
||||||
|
ueberwachung_save($pdo, ['name' => 'Support', 'type' => 'http', 'target' => 'https://support.example.de/', 'interval_min' => 5], $web['id']);
|
||||||
|
$id = $web['id'];
|
||||||
|
|
||||||
|
// Erste Prüfung in Ordnung
|
||||||
|
ueberwachung_check($pdo, $id);
|
||||||
|
$m = $mon($id);
|
||||||
|
$check($m['state'] === 'up' && (int) $m['fails'] === 0 && (int) $m['last_ms'] === 40 && $m['last_check_at'] !== null, 'erste Prüfung: in Ordnung');
|
||||||
|
$check($mails === [] && (int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === 0, 'ohne Fehler keine Mail und kein Ticket');
|
||||||
|
|
||||||
|
// Ein einzelner Fehler löst noch nichts aus (Schwelle 2)
|
||||||
|
$answer = ['ok' => false, 'error' => 'HTTP-Status 503', 'ms' => 90, 'cert_expires' => null];
|
||||||
|
ueberwachung_check($pdo, $id);
|
||||||
|
$m = $mon($id);
|
||||||
|
$check($m['state'] === 'up' && (int) $m['fails'] === 1 && $mails === [] && (int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === 0, 'ein einzelner Fehlschlag meldet noch nichts');
|
||||||
|
// Kurzer Aussetzer behoben
|
||||||
|
$answer = ['ok' => true, 'error' => null, 'ms' => 50, 'cert_expires' => null];
|
||||||
|
ueberwachung_check($pdo, $id);
|
||||||
|
$check((int) $mon($id)['fails'] === 0 && $mails === [], 'ein Aussetzer ohne Folgen setzt den Zähler zurück');
|
||||||
|
|
||||||
|
// Ausfall: zweiter Fehlschlag in Folge
|
||||||
|
$answer = ['ok' => false, 'error' => 'HTTP-Status 503', 'ms' => 90, 'cert_expires' => null];
|
||||||
|
ueberwachung_check($pdo, $id);
|
||||||
|
ueberwachung_check($pdo, $id);
|
||||||
|
$m = $mon($id);
|
||||||
|
$t = $ticket($m['ticket_id'] ? (int) $m['ticket_id'] : null);
|
||||||
|
$check($m['state'] === 'down' && $m['down_since'] !== null && $t && str_contains($t['subject'], 'Ausfall: Support'), 'Ausfall: Zustand „down“ und Ticket');
|
||||||
|
$check($t && $t['status'] === 'open' && $t['requester_name'] === 'Überwachung' && $t['requester_email'] === 'admin@firma.example', 'Ticket ist offen, Absender „Überwachung“');
|
||||||
|
$tag = (int) $pdo->query("SELECT COUNT(*) FROM ticket_tags tt JOIN tags g ON g.id = tt.tag_id WHERE tt.ticket_id = " . (int) $m['ticket_id'] . " AND g.name = 'Überwachung'")->fetchColumn();
|
||||||
|
$check($tag === 1, 'Ticket trägt den Tag „Überwachung“');
|
||||||
|
$check(count($mails) === 1 && str_contains($mails[0]['subject'], 'Ausfall: Support') && $mails[0]['to'] === 'admin@firma.example' && str_contains($mails[0]['html'], 'HTTP-Status 503'), 'Mail an den Administrator');
|
||||||
|
$check((int) $pdo->query("SELECT COUNT(*) FROM plugin_ueberwachung_events WHERE kind = 'down'")->fetchColumn() === 1, 'Ereignis im Protokoll');
|
||||||
|
// Weiter fehlerhaft: kein zweites Ticket, keine zweite Mail
|
||||||
|
ueberwachung_check($pdo, $id);
|
||||||
|
ueberwachung_check($pdo, $id);
|
||||||
|
$check((int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === 1 && count($mails) === 1, 'anhaltender Ausfall erzeugt kein weiteres Ticket und keine weitere Mail');
|
||||||
|
|
||||||
|
// Entwarnung
|
||||||
|
$pdo->prepare('UPDATE plugin_ueberwachung_monitors SET down_since = ? WHERE id = ?')->execute([date('Y-m-d H:i:s', time() - 780), $id]);
|
||||||
|
$answer = ['ok' => true, 'error' => null, 'ms' => 45, 'cert_expires' => null];
|
||||||
|
ueberwachung_check($pdo, $id);
|
||||||
|
$m = $mon($id);
|
||||||
|
$t = $ticket((int) $pdo->query('SELECT id FROM tickets ORDER BY id LIMIT 1')->fetchColumn());
|
||||||
|
$check($m['state'] === 'up' && $m['down_since'] === null && $m['ticket_id'] === null, 'wieder erreichbar: Zustand zurückgesetzt');
|
||||||
|
$check($t['status'] === 'resolved' && $t['resolved_at'] !== null, 'Ticket ist automatisch gelöst');
|
||||||
|
$note = $pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$t['id']} AND is_internal_note = 1 ORDER BY id DESC LIMIT 1")->fetchColumn();
|
||||||
|
$check(str_contains((string) $note, 'Wieder erreichbar') && str_contains((string) $note, '13 Minuten'), 'interne Notiz nennt die Dauer: ' . $note);
|
||||||
|
$check(count($mails) === 2 && str_contains($mails[1]['subject'], 'Entwarnung') && str_contains($mails[1]['html'], '13 Minuten'), 'Entwarnungs-Mail mit Dauer');
|
||||||
|
|
||||||
|
// Neuer Ausfall nach Entwarnung legt ein neues Ticket an
|
||||||
|
$answer = ['ok' => false, 'error' => 'Nicht erreichbar: Zeitüberschreitung', 'ms' => 8000, 'cert_expires' => null];
|
||||||
|
ueberwachung_check($pdo, $id);
|
||||||
|
ueberwachung_check($pdo, $id);
|
||||||
|
$check((int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === 2 && $mon($id)['state'] === 'down', 'ein neuer Ausfall bekommt ein neues Ticket');
|
||||||
|
// ohne automatisches Lösen bleibt das Ticket offen
|
||||||
|
plugin_set_setting('ueberwachung', 'auto_resolve', '0');
|
||||||
|
$answer = ['ok' => true, 'error' => null, 'ms' => 45, 'cert_expires' => null];
|
||||||
|
ueberwachung_check($pdo, $id);
|
||||||
|
$last = $ticket((int) $pdo->query('SELECT MAX(id) FROM tickets')->fetchColumn());
|
||||||
|
$threads = $pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$last['id']}")->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
$check($last['status'] === 'open' && count(array_filter($threads, fn($t) => str_contains((string) $t, 'Wieder erreichbar'))) === 1, 'ohne „automatisch lösen“ bleibt das Ticket offen, die Notiz steht trotzdem: ' . json_encode([$last['id'], $last['status'], $threads], JSON_UNESCAPED_UNICODE));
|
||||||
|
plugin_set_setting('ueberwachung', 'auto_resolve', '1');
|
||||||
|
|
||||||
|
// Ohne Tickets und ohne Mail
|
||||||
|
plugin_set_setting('ueberwachung', 'create_tickets', '0');
|
||||||
|
plugin_set_setting('ueberwachung', 'mail', '0');
|
||||||
|
$before = [(int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn(), count($mails)];
|
||||||
|
$answer = ['ok' => false, 'error' => 'x', 'ms' => 1, 'cert_expires' => null];
|
||||||
|
ueberwachung_check($pdo, $id);
|
||||||
|
ueberwachung_check($pdo, $id);
|
||||||
|
$check($mon($id)['state'] === 'down' && [(int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn(), count($mails)] === $before, 'Einstellungen: keine Tickets und keine Mails möglich, der Zustand wird trotzdem erfasst');
|
||||||
|
$answer = ['ok' => true, 'error' => null, 'ms' => 1, 'cert_expires' => null];
|
||||||
|
ueberwachung_check($pdo, $id);
|
||||||
|
plugin_set_setting('ueberwachung', 'create_tickets', '1');
|
||||||
|
plugin_set_setting('ueberwachung', 'mail', '1');
|
||||||
|
|
||||||
|
// Zertifikat
|
||||||
|
$mails = [];
|
||||||
|
$ticketsBefore = (int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn();
|
||||||
|
$answer = ['ok' => true, 'error' => null, 'ms' => 45, 'cert_expires' => time() + 60 * 86400];
|
||||||
|
ueberwachung_check($pdo, $id);
|
||||||
|
$check((int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === $ticketsBefore && $mon($id)['cert_expires'] !== null && $mon($id)['cert_ticket_id'] === null, 'Zertifikat noch 60 Tage gültig: nichts zu melden');
|
||||||
|
$answer['cert_expires'] = time() + 9 * 86400 + 3600;
|
||||||
|
ueberwachung_check($pdo, $id);
|
||||||
|
$m = $mon($id);
|
||||||
|
$ct = $ticket((int) $m['cert_ticket_id']);
|
||||||
|
$check($ct && str_contains($ct['subject'], 'Zertifikat: Support') && str_contains($ct['subject'] . $pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$ct['id']} LIMIT 1")->fetchColumn(), 'läuft in 9 Tagen ab'), 'Zertifikat läuft in 9 Tagen ab: Ticket');
|
||||||
|
$check(count($mails) === 1 && str_contains($mails[0]['subject'], 'Zertifikat'), 'Mail zur Zertifikatswarnung');
|
||||||
|
ueberwachung_check($pdo, $id);
|
||||||
|
$check((int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === $ticketsBefore + 1 && count($mails) === 1, 'die Warnung kommt nur einmal');
|
||||||
|
$answer['cert_expires'] = time() + 90 * 86400;
|
||||||
|
ueberwachung_check($pdo, $id);
|
||||||
|
$ct = $ticket((int) $ct['id']);
|
||||||
|
$check($ct['status'] === 'resolved' && $mon($id)['cert_ticket_id'] === null && str_contains(end($mails)['subject'], 'erneuert'), 'Zertifikat erneuert: Ticket gelöst, Entwarnung');
|
||||||
|
$answer['cert_expires'] = time() - 2 * 86400;
|
||||||
|
ueberwachung_check($pdo, $id);
|
||||||
|
$check($mon($id)['cert_ticket_id'] !== null && (int) $pdo->query("SELECT COUNT(*) FROM plugin_ueberwachung_events WHERE kind = 'cert' AND message LIKE '%abgelaufen%'")->fetchColumn() === 1, 'abgelaufenes Zertifikat wird als abgelaufen gemeldet');
|
||||||
|
$answer['cert_expires'] = null;
|
||||||
|
|
||||||
|
// Ticket-Anlage trotz Sperre: die eigene Adresse steht auf der Sperrliste, das normale Anlegen lehnt ab, der Rückfall legt das Ticket trotzdem an
|
||||||
|
$pdo->prepare('INSERT INTO banlist (match_type, value, created_at) VALUES (?,?,?)')->execute(['email', 'admin@firma.example', db_now()]);
|
||||||
|
$mails = [];
|
||||||
|
$tCount = (int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn();
|
||||||
|
$fb = ueberwachung_save($pdo, ['name' => 'Rückfall', 'type' => 'port', 'target' => 'rueckfall.example:25'])['id'];
|
||||||
|
$answer = ['ok' => false, 'error' => 'Nicht erreichbar: Verbindung abgelehnt', 'ms' => 5, 'cert_expires' => null];
|
||||||
|
ueberwachung_check($pdo, $fb);
|
||||||
|
ueberwachung_check($pdo, $fb);
|
||||||
|
$fm = $mon($fb);
|
||||||
|
$ft = $ticket($fm['ticket_id'] ? (int) $fm['ticket_id'] : null);
|
||||||
|
$check($fm['state'] === 'down' && $ft && str_contains($ft['subject'], 'Ausfall: Rückfall') && (int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === $tCount + 1, 'trotz Sperrliste wird das Ausfall-Ticket angelegt (Rückfall)');
|
||||||
|
$check($ft && $ft['status'] === 'open' && $ft['requester_name'] === 'Überwachung' && (int) $pdo->query("SELECT COUNT(*) FROM ticket_threads WHERE ticket_id = {$ft['id']}")->fetchColumn() === 1, 'Rückfall-Ticket hat Nachricht und Absender');
|
||||||
|
$check((int) $pdo->query("SELECT COUNT(*) FROM ticket_tags tt JOIN tags g ON g.id = tt.tag_id WHERE tt.ticket_id = {$ft['id']} AND g.name = 'Überwachung'")->fetchColumn() === 1, 'Rückfall-Ticket trägt den Tag');
|
||||||
|
$err = (string) plugin_setting('ueberwachung', 'ticket_error', '');
|
||||||
|
$check(str_contains($err, 'am normalen Weg vorbei') && str_contains($err, 'gesperrt'), 'der Grund steht auf der Seite: ' . $err);
|
||||||
|
$check(count($mails) === 1, 'die Mail an den Administrator kommt trotzdem');
|
||||||
|
$answer = ['ok' => true, 'error' => null, 'ms' => 5, 'cert_expires' => null];
|
||||||
|
ueberwachung_check($pdo, $fb);
|
||||||
|
$check($ticket((int) $ft['id'])['status'] === 'resolved', 'Entwarnung löst auch das Rückfall-Ticket');
|
||||||
|
// Ohne Sperre geht es auf dem normalen Weg und der Hinweis verschwindet
|
||||||
|
$pdo->exec('DELETE FROM banlist');
|
||||||
|
$tid = ueberwachung_open_ticket($pdo, 'Test: Überwachung', 'Testticket');
|
||||||
|
$check($tid && plugin_setting('ueberwachung', 'ticket_error', '') === '' && $ticket($tid)['requester_email'] === 'admin@firma.example', 'ohne Sperre: normaler Weg, Hinweis weg');
|
||||||
|
// Ist die gewählte Priorität ungültig, scheitern beide Wege: Fehler wird sichtbar statt still verschluckt
|
||||||
|
plugin_set_setting('ueberwachung', 'priority_id', '99999');
|
||||||
|
$none = ueberwachung_open_ticket($pdo, 'Test 2', 'x');
|
||||||
|
$check($none === null && str_contains((string) plugin_setting('ueberwachung', 'ticket_error', ''), 'konnte nicht angelegt werden'), 'scheitert alles, steht der Grund auf der Seite: ' . plugin_setting('ueberwachung', 'ticket_error', ''));
|
||||||
|
plugin_set_setting('ueberwachung', 'priority_id', '0');
|
||||||
|
plugin_set_setting('ueberwachung', 'ticket_error', '');
|
||||||
|
$pdo->exec('DELETE FROM plugin_ueberwachung_monitors');
|
||||||
|
|
||||||
|
// ── Inhalt der Tickets: Name und Adresse, Tabelle, lesbare Fehler, Hinweise ──
|
||||||
|
$pdo->exec('DELETE FROM plugin_ueberwachung_monitors');
|
||||||
|
$mails = [];
|
||||||
|
$GLOBALS['ueberwachung_probe'] = function (array $m) use (&$answer): array { return $answer; };
|
||||||
|
$ct = ueberwachung_save($pdo, ['name' => 'E-Book <b>Shop</b>', 'type' => 'http', 'target' => 'https://e-book.example.net/start', 'interval_min' => 5, 'expect_text' => 'Willkommen'])['id'];
|
||||||
|
$answer = ['ok' => true, 'error' => null, 'ms' => 120, 'cert_expires' => null];
|
||||||
|
ueberwachung_check($pdo, $ct);
|
||||||
|
$answer = ['ok' => false, 'error' => 'HTTP-Status 503', 'ms' => 2500, 'cert_expires' => time() + 40 * 86400, 'status' => 503, 'issuer' => "Let's Encrypt", 'cert_subject' => 'e-book.example.net'];
|
||||||
|
ueberwachung_check($pdo, $ct);
|
||||||
|
$first = $mon($ct)['first_fail_at'];
|
||||||
|
ueberwachung_check($pdo, $ct);
|
||||||
|
$m = $mon($ct);
|
||||||
|
$t = $ticket((int) $m['ticket_id']);
|
||||||
|
$msg = (string) $pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$t['id']} ORDER BY id LIMIT 1")->fetchColumn();
|
||||||
|
$check($t['subject'] === 'Ausfall: E-Book <b>Shop</b> (e-book.example.net)', 'Betreff nennt Name und Adresse der Seite: ' . $t['subject']);
|
||||||
|
$check($first !== null && $m['down_since'] === $first, 'Beginn des Ausfalls ist der erste Fehlschlag');
|
||||||
|
foreach (['<table', '<strong>Dienst</strong>', 'E-Book <b>Shop</b>', 'https://e-book.example.net/start', 'Webseite (HTTP/HTTPS)', 'HTTP-Status 503', '<strong>HTTP-Status</strong>', '503', 'Fehlgeschlagene Prüfungen in Folge', '2 (Prüfung alle 5 Minuten', 'Letzter erfolgreicher Abruf', 'Erwarteter Text', 'Willkommen', 'Zertifikat gültig bis', 'Aussteller', 'Let's Encrypt', 'Mögliche Ursachen und nächste Schritte', '<li>', 'automatisch gelöst'] as $needle) {
|
||||||
|
$check(str_contains($msg, $needle), "Ticket enthält „{$needle}“");
|
||||||
|
}
|
||||||
|
$check(!str_contains($msg, '<b>Shop</b>'), 'der Name wird im Ticket maskiert');
|
||||||
|
$check(!str_contains($msg, "\n\nDiese Meldung"), 'kein loser Fließtext mehr');
|
||||||
|
$check(str_contains((string) $mails[0]['html'], '<table') && str_contains($mails[0]['subject'], 'Ausfall: E-Book'), 'die Mail an den Administrator hat dieselbe Tabelle');
|
||||||
|
// Entwarnung nennt Beginn, Ende und Dauer
|
||||||
|
$answer = ['ok' => true, 'error' => null, 'ms' => 130, 'cert_expires' => null];
|
||||||
|
ueberwachung_check($pdo, $ct);
|
||||||
|
$note = (string) $pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$t['id']} AND is_internal_note = 1 ORDER BY id DESC LIMIT 1")->fetchColumn();
|
||||||
|
$check(str_contains($note, 'Wieder erreichbar') && preg_match('/Von \d\d\.\d\d\.\d{4} \d\d:\d\d:\d\d bis \d\d\.\d\d\.\d{4}/', $note) === 1, 'Notiz bei Entwarnung nennt von/bis: ' . $note);
|
||||||
|
$check(str_contains(end($mails)['subject'], 'Entwarnung: E-Book') && str_contains(end($mails)['html'], '<table'), 'Entwarnungs-Mail mit Tabelle');
|
||||||
|
$check($mon($ct)['first_fail_at'] === null, 'erster Fehlschlag wird nach der Entwarnung zurückgesetzt');
|
||||||
|
// Zertifikat-Ticket
|
||||||
|
$answer = ['ok' => true, 'error' => null, 'ms' => 100, 'cert_expires' => time() + 5 * 86400 + 600, 'issuer' => "Let's Encrypt", 'cert_subject' => 'e-book.example.net'];
|
||||||
|
ueberwachung_check($pdo, $ct);
|
||||||
|
$cert = $ticket((int) $mon($ct)['cert_ticket_id']);
|
||||||
|
$certMsg = (string) $pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$cert['id']} ORDER BY id LIMIT 1")->fetchColumn();
|
||||||
|
$check(str_starts_with($cert['subject'], 'Zertifikat: E-Book') && str_contains($certMsg, 'läuft in 5 Tagen ab') && str_contains($certMsg, 'Aussteller') && str_contains($certMsg, 'Warnung ab') && str_contains($certMsg, 'Zertifikat erneuern'), 'Zertifikat-Ticket mit Tabelle und Hinweisen');
|
||||||
|
|
||||||
|
// Lesbare Fehlertexte
|
||||||
|
$d = fn(string $raw) => ueberwachung_describe_error($raw);
|
||||||
|
$check(str_contains($d('fopen(https://x.example): Failed to open stream: php_network_getaddresses: getaddrinfo failed'), 'DNS'), 'Fehlertext: DNS');
|
||||||
|
$check(str_contains($d('Connection refused'), 'Verbindung abgelehnt') && str_contains($d('Connection timed out'), 'Zeitüberschreitung') && str_contains($d('No route to host'), 'nicht erreichbar'), 'Fehlertexte: abgelehnt, Zeitüberschreitung, keine Route');
|
||||||
|
$check(str_starts_with($d('SSL operation failed with code 1. certificate verify failed'), 'Zertifikat/TLS-Fehler (Zertifikat ungültig'), 'Fehlertext: Zertifikat ungültig');
|
||||||
|
$check(str_starts_with($d('SSL: certificate has expired'), 'Zertifikat/TLS-Fehler (Zertifikat abgelaufen'), 'Fehlertext: Zertifikat abgelaufen');
|
||||||
|
$check(str_contains($d('Could not resolve host: e-book.example.net'), 'DNS') && str_contains($d('Operation timed out after 8001 milliseconds'), 'Zeitüberschreitung') && str_contains($d('Failed to connect to x port 443: Connection refused'), 'abgelehnt'), 'cURL-Meldungen werden übersetzt');
|
||||||
|
$check(ueberwachung_default_page('Welcome to nginx!') !== null && ueberwachung_default_page('Apache2 Ubuntu Default Page: It works') !== null && ueberwachung_default_page('502 Bad Gateway') !== null && ueberwachung_default_page('Index of /') !== null, 'Standard- und Fehlerseiten werden am Titel erkannt');
|
||||||
|
$check(ueberwachung_default_page('Default Site') !== null && ueberwachung_default_page('Start', "<p>If you're seeing this site then you're trying to access a host that isn't set up yet.</p>") !== null, 'Titel „Default Site“ und der Standardtext des Proxy Managers werden erkannt');
|
||||||
|
$check(ueberwachung_next_wait(['fails' => 0, 'state' => 'up', 'interval_min' => 3]) === 165 && ueberwachung_next_wait(['fails' => 1, 'state' => 'up', 'interval_min' => 3]) === 45 && ueberwachung_next_wait(['fails' => 2, 'state' => 'down', 'interval_min' => 3]) === 165 && ueberwachung_next_wait(['fails' => 0, 'state' => 'up', 'interval_min' => 1]) === 45, 'nach dem ersten Fehlschlag wird nach 45 Sekunden nachgeprüft, sonst im eingestellten Abstand');
|
||||||
|
$check(ueberwachung_default_page('E-Book Shop – Startseite') === null && ueberwachung_default_page('') === null && ueberwachung_default_page('Nginx Proxy Manager') === null, 'normale Titel und der Nginx Proxy Manager selbst gelten nicht als Standardseite');
|
||||||
|
$check(ueberwachung_page_title("<html><head>\n<title> Mein & Dein\n Shop </title>") === 'Mein & Dein Shop', 'Seitentitel wird sauber gelesen');
|
||||||
|
$check(str_contains(ueberwachung_hints(['type' => 'http'], 'Der Server antwortet mit einer Standard- oder Fehlerseite statt mit der Anwendung')[0], 'Reverse-Proxy'), 'Hinweis für Standardseiten nennt den Reverse-Proxy');
|
||||||
|
$tlsBad = ['type' => 'http', 'target' => 'https://a.example/', 'verify_tls' => 1, 'last_check_at' => '2026-01-01 00:00:00', 'last_error' => 'Zertifikat/TLS-Fehler (Zertifikat ungültig): x'];
|
||||||
|
$tlsOk = ['type' => 'http', 'target' => 'https://b.example/', 'verify_tls' => 1, 'last_check_at' => '2026-01-01 00:00:00', 'last_error' => null];
|
||||||
|
$check(ueberwachung_ca_warning([$tlsBad], true) === true, 'Warnung: alle HTTPS-Ziele scheitern am Zertifikat und das System vermutet ein fehlendes CA-Bundle');
|
||||||
|
$check(ueberwachung_ca_warning([$tlsBad, $tlsOk], true) === false, 'keine Warnung, sobald irgendein HTTPS-Ziel mit Prüfung erreicht wird (die Prüfung funktioniert)');
|
||||||
|
$check(ueberwachung_ca_warning([$tlsBad], false) === false && ueberwachung_ca_warning([], true) === false, 'keine Warnung ohne Verdacht des Systems oder ohne Ziele');
|
||||||
|
$check(ueberwachung_ca_warning([['verify_tls' => 0] + $tlsBad], true) === false && ueberwachung_ca_warning([['target' => 'http://c.example/'] + $tlsBad], true) === false, 'Ziele ohne Zertifikatsprüfung und ohne HTTPS zählen nicht');
|
||||||
|
$check(str_starts_with($d('SSL certificate problem: self-signed certificate'), 'Zertifikat/TLS-Fehler (Zertifikat ungültig') && str_starts_with($d('SSL: no alternative certificate subject name matches target host name'), 'Zertifikat/TLS-Fehler (Zertifikat ungültig'), 'cURL-Zertifikatsfehler werden übersetzt');
|
||||||
|
$check(str_contains($d('fopen(): SSL operation failed with code 1. OpenSSL Error messages: error:0A000086:SSL routines::certificate verify failed | fopen(https://x): Failed to open stream: operation failed'), 'Zertifikat ungültig'), 'alle PHP-Warnungen zusammen: die Zertifikatsursache geht nicht mehr verloren');
|
||||||
|
$vt = ueberwachung_validate(['name' => 'A', 'type' => 'http', 'target' => 'https://x.example/', 'verify_tls' => 0]);
|
||||||
|
$vt2 = ueberwachung_validate(['name' => 'A', 'type' => 'http', 'target' => 'https://x.example/']);
|
||||||
|
$check($vt['data']['verify_tls'] === 0 && $vt2['data']['verify_tls'] === 1, 'Zertifikatsprüfung lässt sich je Ziel abschalten (Standard: an)');
|
||||||
|
$generic = $d('fopen(https://e-book.viper.ipv64.net): Failed to open stream: operation failed');
|
||||||
|
$check(str_starts_with($generic, 'Nicht erreichbar:') && !str_contains($generic, 'fopen') && !str_contains($generic, 'e-book'), 'allgemeiner Fehler ohne technische Reste: ' . $generic);
|
||||||
|
$check(ueberwachung_subject('Ausfall', ['name' => 'e-book.example.net', 'target' => 'https://e-book.example.net/']) === 'Ausfall: e-book.example.net', 'Betreff ohne doppelte Adresse, wenn sie schon im Namen steht');
|
||||||
|
$check(ueberwachung_subject('Ausfall', ['name' => 'Mail', 'target' => 'mail.example.de:993']) === 'Ausfall: Mail (mail.example.de)', 'Betreff bei Port-Zielen');
|
||||||
|
$check(ueberwachung_hints(['type' => 'http'], 'HTTP-Status 503')[0] !== ueberwachung_hints(['type' => 'port'], 'Nicht erreichbar: x')[0], 'Hinweise passen zur Art des Fehlers');
|
||||||
|
$pdo->exec('DELETE FROM plugin_ueberwachung_monitors');
|
||||||
|
|
||||||
|
// Nachträgliche Änderung des Prüfabstands wird gespeichert und gilt für die Fälligkeit
|
||||||
|
$GLOBALS['probeCalls'] = 0;
|
||||||
|
$GLOBALS['ueberwachung_probe'] = function (array $m) use (&$answer): array { $GLOBALS['probeCalls']++; return $answer; };
|
||||||
|
$answer = ['ok' => true, 'error' => null, 'ms' => 5, 'cert_expires' => null];
|
||||||
|
$iv = ueberwachung_save($pdo, ['name' => 'Abstand', 'type' => 'port', 'target' => 'abstand.example:25', 'interval_min' => 5])['id'];
|
||||||
|
ueberwachung_check($pdo, $iv);
|
||||||
|
$check((int) $mon($iv)['interval_min'] === 5, 'Prüfabstand 5 Minuten gespeichert');
|
||||||
|
$check(ueberwachung_save($pdo, ['name' => 'Abstand', 'type' => 'port', 'target' => 'abstand.example:25', 'interval_min' => 3], $iv)['ok'] && (int) $mon($iv)['interval_min'] === 3, 'nachträgliche Änderung auf 3 Minuten wird gespeichert');
|
||||||
|
$check($mon($iv)['last_check_at'] !== null && $mon($iv)['name'] === 'Abstand', 'Änderung löscht weder Name noch bisherige Ergebnisse');
|
||||||
|
$calls = $GLOBALS['probeCalls'];
|
||||||
|
$pdo->prepare('UPDATE plugin_ueberwachung_monitors SET last_check_at = ? WHERE id = ?')->execute([date('Y-m-d H:i:s', time() - 120), $iv]);
|
||||||
|
ueberwachung_tick($pdo);
|
||||||
|
$check($GLOBALS['probeCalls'] === $calls, 'nach 2 Minuten noch nicht fällig (Abstand 3)');
|
||||||
|
$pdo->prepare('UPDATE plugin_ueberwachung_monitors SET last_check_at = ? WHERE id = ?')->execute([date('Y-m-d H:i:s', time() - 175), $iv]);
|
||||||
|
ueberwachung_tick($pdo);
|
||||||
|
$check($GLOBALS['probeCalls'] === $calls + 1, 'nach knapp 3 Minuten ist die Prüfung fällig (sobald der Hintergrunddienst läuft)');
|
||||||
|
$pdo->prepare('UPDATE plugin_ueberwachung_monitors SET last_check_at = ? WHERE id = ?')->execute([date('Y-m-d H:i:s', time() - 175), $iv]);
|
||||||
|
ueberwachung_save($pdo, ['name' => 'Abstand', 'type' => 'port', 'target' => 'abstand.example:25', 'interval_min' => 5], $iv);
|
||||||
|
$calls = $GLOBALS['probeCalls'];
|
||||||
|
ueberwachung_tick($pdo);
|
||||||
|
$check($GLOBALS['probeCalls'] === $calls, 'zurück auf 5 Minuten: nach knapp 3 Minuten nicht fällig');
|
||||||
|
// Das Plugin hängt am Minuten-Ereignis (eingestellter Abstand) und am 5-Minuten-Ereignis (Rückfall), ohne doppelt zu prüfen
|
||||||
|
$pdo->prepare('UPDATE plugin_ueberwachung_monitors SET last_check_at = ? WHERE id = ?')->execute([date('Y-m-d H:i:s', time() - 400), $iv]);
|
||||||
|
$calls = $GLOBALS['probeCalls'];
|
||||||
|
plugin_hook('cron.minute', ['time' => time()]);
|
||||||
|
$check($GLOBALS['probeCalls'] === $calls + 1, 'das Minuten-Ereignis prüft fällige Ziele');
|
||||||
|
plugin_hook('cron.run', ['time' => time()]);
|
||||||
|
$check($GLOBALS['probeCalls'] === $calls + 1, 'das 5-Minuten-Ereignis direkt danach prüft nicht noch einmal');
|
||||||
|
$pdo->prepare('UPDATE plugin_ueberwachung_monitors SET last_check_at = ? WHERE id = ?')->execute([date('Y-m-d H:i:s', time() - 400), $iv]);
|
||||||
|
plugin_hook('cron.run', ['time' => time()]);
|
||||||
|
$check($GLOBALS['probeCalls'] === $calls + 2, 'Rückfall: auch das 5-Minuten-Ereignis allein prüft fällige Ziele (ältere DeskHub-Dateien)');
|
||||||
|
// Nach einem ersten Fehlschlag wird schon nach knapp einer Minute nachgeprüft (Abstand 5 Minuten)
|
||||||
|
$pdo->prepare("UPDATE plugin_ueberwachung_monitors SET fails = 1, state = 'up', last_check_at = ? WHERE id = ?")->execute([date('Y-m-d H:i:s', time() - 50), $iv]);
|
||||||
|
$calls = $GLOBALS['probeCalls'];
|
||||||
|
ueberwachung_tick($pdo);
|
||||||
|
$check($GLOBALS['probeCalls'] === $calls + 1, 'nach einem ersten Fehlschlag wird nach ~1 Minute nachgeprüft statt erst nach 5');
|
||||||
|
$pdo->prepare("UPDATE plugin_ueberwachung_monitors SET fails = 0, state = 'up', last_check_at = ? WHERE id = ?")->execute([date('Y-m-d H:i:s', time() - 50), $iv]);
|
||||||
|
$calls = $GLOBALS['probeCalls'];
|
||||||
|
ueberwachung_tick($pdo);
|
||||||
|
$check($GLOBALS['probeCalls'] === $calls, 'ohne Fehlschlag gilt weiter der eingestellte Abstand');
|
||||||
|
$pdo->exec('DELETE FROM plugin_ueberwachung_monitors');
|
||||||
|
$GLOBALS['ueberwachung_probe'] = function (array $m) use (&$answer): array { return $answer; };
|
||||||
|
|
||||||
|
// Fälligkeit und Pausieren
|
||||||
|
$GLOBALS['probeCalls'] = 0;
|
||||||
|
$GLOBALS['ueberwachung_probe'] = function (array $m) use (&$answer): array { $GLOBALS['probeCalls']++; return $answer; };
|
||||||
|
$pdo->exec('DELETE FROM plugin_ueberwachung_monitors');
|
||||||
|
$a = ueberwachung_save($pdo, ['name' => 'A', 'type' => 'port', 'target' => 'a.example:25', 'interval_min' => 5])['id'];
|
||||||
|
$b = ueberwachung_save($pdo, ['name' => 'B', 'type' => 'port', 'target' => 'b.example:25', 'interval_min' => 5, 'is_active' => 0])['id'];
|
||||||
|
$check(ueberwachung_tick($pdo) === 1 && $GLOBALS['probeCalls'] === 1, 'Hintergrundlauf prüft nur aktive Ziele');
|
||||||
|
$check(ueberwachung_tick($pdo) === 0, 'frisch geprüfte Ziele sind nicht fällig');
|
||||||
|
$pdo->prepare('UPDATE plugin_ueberwachung_monitors SET last_check_at = ? WHERE id = ?')->execute([date('Y-m-d H:i:s', time() - 400), $a]);
|
||||||
|
$check(ueberwachung_tick($pdo) === 1, 'nach dem Intervall ist es wieder fällig');
|
||||||
|
$check(!ueberwachung_check($pdo, 99999), 'unbekanntes Ziel');
|
||||||
|
ueberwachung_delete($pdo, $a);
|
||||||
|
$check((int) $pdo->query('SELECT COUNT(*) FROM plugin_ueberwachung_monitors')->fetchColumn() === 1 && (int) $pdo->query("SELECT COUNT(*) FROM plugin_ueberwachung_events WHERE monitor_id = {$a}")->fetchColumn() === 0, 'Löschen entfernt Ziel und Ereignisse');
|
||||||
|
// Fehler in der Prüfung selbst
|
||||||
|
$GLOBALS['ueberwachung_probe'] = function (array $m): array { throw new RuntimeException('kaputt'); };
|
||||||
|
$c = ueberwachung_save($pdo, ['name' => 'C', 'type' => 'port', 'target' => 'c.example:25'])['id'];
|
||||||
|
ueberwachung_check($pdo, $c);
|
||||||
|
$check(str_contains((string) $mon($c)['last_error'], 'Prüfung fehlgeschlagen') && (int) $mon($c)['fails'] === 1, 'Ausnahmen in der Prüfung werden als Fehler gewertet');
|
||||||
|
unset($GLOBALS['ueberwachung_probe'], $GLOBALS['ueberwachung_mail']);
|
||||||
|
|
||||||
|
// Selbstheilung: fehlt nach einem Update die Spalte (ältere DeskHub-Dateien), holt das Plugin die Änderung selbst nach
|
||||||
|
$pdo->exec('ALTER TABLE plugin_ueberwachung_monitors DROP COLUMN first_fail_at');
|
||||||
|
$pdo->exec("DELETE FROM schema_migrations WHERE migration LIKE 'plugin_ueberwachung_002%'");
|
||||||
|
$colsOf = fn() => array_column($pdo->query('PRAGMA table_info(plugin_ueberwachung_monitors)')->fetchAll(), 'name');
|
||||||
|
$check(!in_array('first_fail_at', $colsOf(), true), 'Vorbereitung: Spalte fehlt');
|
||||||
|
ueberwachung_ensure_schema(true);
|
||||||
|
$check(in_array('first_fail_at', $colsOf(), true), 'Selbstheilung: die fehlende Spalte wird nachgeholt');
|
||||||
|
ueberwachung_ensure_schema(true);
|
||||||
|
$check(in_array('first_fail_at', $colsOf(), true), 'Selbstheilung ist wiederholbar');
|
||||||
|
|
||||||
|
// ── Echte Prüfungen gegen einen lokalen Webserver und Port ──────────────────
|
||||||
|
$router = sys_get_temp_dir() . '/ue-router-' . bin2hex(random_bytes(3)) . '.php';
|
||||||
|
file_put_contents($router, <<<'ROUTER'
|
||||||
|
<?php
|
||||||
|
$c = (int) ($_GET['code'] ?? 200);
|
||||||
|
http_response_code($c);
|
||||||
|
if (($_GET['page'] ?? '') === 'npm') {
|
||||||
|
echo '<!doctype html><html><head><title>Default Site</title></head><body><h1>Congratulations!</h1><p>You\'ve successfully started the Nginx Proxy Manager.</p><p>If you\'re seeing this site then you\'re trying to access a host that isn\'t set up yet.</p></body></html>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (($_GET['page'] ?? '') === 'neutral') {
|
||||||
|
echo '<html><head><title>Start</title></head><body><p>You\'ve successfully started the Nginx Proxy Manager.</p></body></html>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (($_GET['page'] ?? '') === 'default') {
|
||||||
|
echo '<html><head><title>Congratulations! You have successfully started</title></head><body>Willkommen</body></html>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
echo $c === 200 ? '<html><head><title>Meine Anwendung</title></head><body>Willkommen beim Test-Server</body></html>' : 'Fehler';
|
||||||
|
ROUTER);
|
||||||
|
$port = 0;
|
||||||
|
for ($try = 0; $try < 20 && !$port; $try++) {
|
||||||
|
$p = random_int(20000, 60000);
|
||||||
|
$s = @stream_socket_server("tcp://127.0.0.1:{$p}");
|
||||||
|
if ($s) { fclose($s); $port = $p; }
|
||||||
|
}
|
||||||
|
$proc = $port ? @proc_open([PHP_BINARY, '-S', "127.0.0.1:{$port}", $router], [0 => ['pipe', 'r'], 1 => ['file', sys_get_temp_dir() . '/ue-srv-out.txt', 'w'], 2 => ['file', sys_get_temp_dir() . '/ue-srv-err.txt', 'w']], $pipes) : false;
|
||||||
|
$up = false;
|
||||||
|
if ($proc) {
|
||||||
|
for ($i = 0; $i < 40 && !$up; $i++) { usleep(100000); $c = @stream_socket_client("tcp://127.0.0.1:{$port}", $en, $es, 0.3); if ($c) { fclose($c); $up = true; } }
|
||||||
|
}
|
||||||
|
if ($up) {
|
||||||
|
$base = ['id' => 1, 'type' => 'http', 'expect_text' => '', 'timeout_sec' => 5, 'target' => ''];
|
||||||
|
$ok = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/"] + $base);
|
||||||
|
$check($ok['ok'] && $ok['ms'] >= 0 && $ok['cert_expires'] === null, 'echte Prüfung: Webseite erreichbar: ' . json_encode($ok));
|
||||||
|
$e500 = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/?code=503"] + $base);
|
||||||
|
$check(!$e500['ok'] && str_contains((string) $e500['error'], '503'), 'echte Prüfung: HTTP 503 wird erkannt');
|
||||||
|
$txt = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/", 'expect_text' => 'Willkommen'] + $base);
|
||||||
|
$check($txt['ok'], 'echte Prüfung: erwarteter Text vorhanden');
|
||||||
|
$noTxt = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/", 'expect_text' => 'Nicht da'] + $base);
|
||||||
|
$check(!$noTxt['ok'] && str_contains((string) $noTxt['error'], 'Text'), 'echte Prüfung: fehlender Text wird erkannt');
|
||||||
|
$def = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/?page=default"] + $base);
|
||||||
|
$check(!$def['ok'] && str_contains((string) $def['error'], 'Standard- oder Fehlerseite') && str_contains((string) $def['error'], 'Congratulations'), 'echte Prüfung: Standardseite mit 200 gilt als Ausfall: ' . ($def['error'] ?? ''));
|
||||||
|
$npm = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/?page=npm"] + $base);
|
||||||
|
$check(!$npm['ok'] && str_contains((string) $npm['error'], 'Default Site') && ($npm['status'] ?? 0) === 200, 'echte Prüfung: Standardseite des Nginx Proxy Managers („Default Site“, Status 200) gilt als Ausfall: ' . ($npm['error'] ?? ''));
|
||||||
|
$neutral = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/?page=neutral"] + $base);
|
||||||
|
$check(!$neutral['ok'] && str_contains((string) $neutral['error'], 'Standardseite') && str_contains((string) $neutral['error'], 'Nginx Proxy Manager'), 'echte Prüfung: erkannt auch am Text der Seite, wenn der Titel nichtssagend ist');
|
||||||
|
$npmTxt = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/?page=npm", 'expect_text' => 'Congratulations'] + $base);
|
||||||
|
$check($npmTxt['ok'], 'mit erwartetem Text wird nichts als Standardseite gewertet');
|
||||||
|
$defTxt = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/?page=default", 'expect_text' => 'Willkommen'] + $base);
|
||||||
|
$check($defTxt['ok'], 'mit erwartetem Text entfällt die Erkennung von Standardseiten');
|
||||||
|
$check(($ok['title'] ?? '') === 'Meine Anwendung' && ($ok['status'] ?? 0) === 200, 'Titel und Status werden mitgeliefert');
|
||||||
|
$GLOBALS['ueberwachung_no_curl'] = true;
|
||||||
|
$defS = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/?page=default"] + $base);
|
||||||
|
unset($GLOBALS['ueberwachung_no_curl']);
|
||||||
|
$check(!$defS['ok'] && str_contains((string) $defS['error'], 'Standard- oder Fehlerseite'), 'dasselbe ohne cURL');
|
||||||
|
$pOk = ueberwachung_probe(['type' => 'port', 'target' => "127.0.0.1:{$port}"] + $base);
|
||||||
|
$check($pOk['ok'], 'echte Prüfung: offener Port');
|
||||||
|
// Rückfall ohne cURL (reine PHP-Streams) liefert dieselben Ergebnisse
|
||||||
|
$GLOBALS['ueberwachung_no_curl'] = true;
|
||||||
|
$sOk = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/"] + $base);
|
||||||
|
$s503 = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/?code=503"] + $base);
|
||||||
|
$sTxt = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/", 'expect_text' => 'Nicht da'] + $base);
|
||||||
|
unset($GLOBALS['ueberwachung_no_curl']);
|
||||||
|
$check($sOk['ok'] && !$s503['ok'] && str_contains((string) $s503['error'], '503') && !$sTxt['ok'] && str_contains((string) $sTxt['error'], 'Text'), 'ohne cURL (Streams): erreichbar, 503 und fehlender Text werden richtig erkannt');
|
||||||
|
$viaCurl = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/?code=503"] + $base);
|
||||||
|
$check(($viaCurl['status'] ?? 0) === 503, 'mit cURL steht der HTTP-Status im Ergebnis');
|
||||||
|
proc_terminate($proc);
|
||||||
|
proc_close($proc);
|
||||||
|
usleep(300000);
|
||||||
|
$pClosed = ueberwachung_probe(['type' => 'port', 'target' => "127.0.0.1:{$port}", 'timeout_sec' => 2] + $base);
|
||||||
|
$check(!$pClosed['ok'] && str_contains((string) $pClosed['error'], 'Nicht erreichbar'), 'echte Prüfung: geschlossener Port wird erkannt');
|
||||||
|
$gone = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/", 'timeout_sec' => 2] + $base);
|
||||||
|
$check(!$gone['ok'] && str_contains((string) $gone['error'], 'Nicht erreichbar'), 'echte Prüfung: nicht erreichbare Webseite wird erkannt');
|
||||||
|
} else {
|
||||||
|
fwrite(STDERR, "Hinweis: Lokaler Testserver konnte nicht gestartet werden, echte Prüfungen übersprungen.\n");
|
||||||
|
if ($proc) { proc_terminate($proc); proc_close($proc); }
|
||||||
|
}
|
||||||
|
@unlink($router);
|
||||||
|
@unlink(sys_get_temp_dir() . '/ue-srv-out.txt');
|
||||||
|
@unlink(sys_get_temp_dir() . '/ue-srv-err.txt');
|
||||||
|
|
||||||
|
foreach (scandir($pluginDir) ?: [] as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
|
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
|
||||||
|
}
|
||||||
|
@rmdir($pluginDir);
|
||||||
|
@unlink($zipPath);
|
||||||
|
if ($failures) {
|
||||||
|
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
echo 'Überwachung plugin checks passed.' . "\n";
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft den echten Cronjob der Überwachung (plugins/ueberwachung/cron.php): läuft nur auf der Kommandozeile, prüft fällige Ziele und
|
||||||
|
* meldet den Lauf, sodass die Seite den Hintergrunddienst als „läuft“ anzeigen kann.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$pluginDir = sys_get_temp_dir() . '/deskhub-uc-' . bin2hex(random_bytes(4));
|
||||||
|
$db = sys_get_temp_dir() . '/deskhub-uc-' . bin2hex(random_bytes(4)) . '.sqlite';
|
||||||
|
mkdir($pluginDir, 0755, true);
|
||||||
|
$env = ['DESKHUB_PLUGINS_DIR' => $pluginDir, 'DESKHUB_TESTING' => '1', 'DESKHUB_CATALOG_DEFAULTS' => __DIR__ . '/fixtures/no-catalog.php', 'DESKHUB_SQLITE_PATH' => $db, 'DESKHUB_BOOTSTRAP' => dirname(__DIR__) . '/includes/bootstrap.php'];
|
||||||
|
foreach ($env as $k => $v) putenv("{$k}={$v}");
|
||||||
|
require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
||||||
|
|
||||||
|
$failures = [];
|
||||||
|
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||||
|
if (!$ok) $failures[] = $what;
|
||||||
|
};
|
||||||
|
$pdo = get_db();
|
||||||
|
|
||||||
|
require_once __DIR__ . '/fixtures/paths.php';
|
||||||
|
$src = plugin_source_dir_or_skip('ueberwachung', 'plugin_ueberwachung_cron');
|
||||||
|
$zipPath = sys_get_temp_dir() . '/uc-' . bin2hex(random_bytes(3)) . '.zip';
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
$zip->open($zipPath, ZipArchive::CREATE);
|
||||||
|
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) {
|
||||||
|
$zip->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1)));
|
||||||
|
}
|
||||||
|
$zip->close();
|
||||||
|
$check(plugin_install_zip($pdo, $zipPath)['ok'], 'Plugin lässt sich installieren');
|
||||||
|
$check(plugin_set_active($pdo, 'ueberwachung', true, ['hooks.cron', 'ui.menu', 'db.migrations'])['ok'], 'Plugin lässt sich aktivieren');
|
||||||
|
$check(is_file($pluginDir . '/ueberwachung/cron.php'), 'cron.php liegt im Plugin');
|
||||||
|
|
||||||
|
// Ein Ziel, das nicht erreichbar ist (geschlossener Port)
|
||||||
|
$closed = 0;
|
||||||
|
for ($i = 0; $i < 20 && !$closed; $i++) {
|
||||||
|
$p = random_int(20000, 60000);
|
||||||
|
$s = @stream_socket_server("tcp://127.0.0.1:{$p}");
|
||||||
|
if ($s) { fclose($s); $closed = $p; }
|
||||||
|
}
|
||||||
|
$pdo->prepare("INSERT INTO plugin_ueberwachung_monitors (name, type, target, timeout_sec, created_at) VALUES ('Test', 'port', ?, 2, ?)")->execute(["127.0.0.1:{$closed}", db_now()]);
|
||||||
|
|
||||||
|
$php = escapeshellarg(PHP_BINARY);
|
||||||
|
$cron = escapeshellarg($pluginDir . '/ueberwachung/cron.php');
|
||||||
|
exec("{$php} {$cron} 2>&1", $out, $code);
|
||||||
|
$check($code === 0 && str_contains(implode("\n", $out), '1 Ziel(e) geprüft'), 'Cronjob prüft das fällige Ziel: ' . implode(' | ', $out));
|
||||||
|
$m = $pdo->query('SELECT * FROM plugin_ueberwachung_monitors')->fetch();
|
||||||
|
$check($m['last_check_at'] !== null && (int) $m['fails'] === 1 && str_contains((string) $m['last_error'], 'Nicht erreichbar'), 'Ergebnis wurde gespeichert (Fehlschlag Nr. 1)');
|
||||||
|
$tick = (int) $pdo->query("SELECT setting_value FROM settings WHERE setting_key = 'plugin.ueberwachung.last_tick'")->fetchColumn();
|
||||||
|
$check($tick > time() - 60, 'Zeitpunkt des Laufs ist vermerkt (für die Anzeige auf der Seite)');
|
||||||
|
unset($out);
|
||||||
|
exec("{$php} {$cron} 2>&1", $out, $code);
|
||||||
|
$check($code === 0 && str_contains(implode("\n", $out), '0 Ziel(e) geprüft'), 'ein zweiter Aufruf direkt danach prüft nichts (noch nicht fällig)');
|
||||||
|
|
||||||
|
// Deaktiviert: klare Meldung statt Fehler im Hintergrund
|
||||||
|
plugin_set_active($pdo, 'ueberwachung', false);
|
||||||
|
unset($out);
|
||||||
|
exec("{$php} {$cron} 2>&1", $out, $code);
|
||||||
|
$check($code === 1 && str_contains(implode("\n", $out), 'nicht aktiv'), 'deaktiviertes Plugin: Hinweis und Exit-Code 1');
|
||||||
|
|
||||||
|
foreach (scandir($pluginDir) ?: [] as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
|
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
|
||||||
|
}
|
||||||
|
@rmdir($pluginDir);
|
||||||
|
@unlink($zipPath);
|
||||||
|
@unlink($db);
|
||||||
|
if ($failures) {
|
||||||
|
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
echo 'Überwachung cron checks passed.' . "\n";
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, dass ein Update eines aktiven Plugins seine neuen Datenbank-Änderungen automatisch einspielt (vor dem Laden des Plugins),
|
||||||
|
* dass fehlerhafte Änderungen sichtbar werden statt still zu scheitern, und die Meldungen nach Installation und Update.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$pluginDir = sys_get_temp_dir() . '/deskhub-um-' . bin2hex(random_bytes(4));
|
||||||
|
mkdir($pluginDir, 0755, true);
|
||||||
|
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
|
||||||
|
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';
|
||||||
|
|
||||||
|
$failures = [];
|
||||||
|
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||||
|
if (!$ok) $failures[] = $what;
|
||||||
|
};
|
||||||
|
$pdo = get_db();
|
||||||
|
|
||||||
|
$mkZip = function (string $version, array $migrations, array $perms = ['db.migrations', 'hooks.cron']) use ($pluginDir): string {
|
||||||
|
$p = sys_get_temp_dir() . '/um-' . bin2hex(random_bytes(3)) . '.zip';
|
||||||
|
$z = new ZipArchive();
|
||||||
|
$z->open($p, ZipArchive::CREATE);
|
||||||
|
$z->addFromString('updprobe/plugin.json', json_encode(['slug' => 'updprobe', 'name' => 'Update-Probe', 'version' => $version, 'permissions' => $perms]));
|
||||||
|
// Das Plugin liest beim Laden die neue Spalte: ohne eingespielte Änderung käme es zu einem Fehler
|
||||||
|
$z->addFromString('updprobe/plugin.php', "<?php\nplugin_add_hook('cron.run', function () {});\n\$GLOBALS['updprobe_loaded'] = (\$GLOBALS['updprobe_loaded'] ?? 0) + 1;\n");
|
||||||
|
foreach ($migrations as $name => $sql) $z->addFromString('updprobe/migrations/' . $name, $sql);
|
||||||
|
$z->close();
|
||||||
|
return $p;
|
||||||
|
};
|
||||||
|
$reboot = function (): void {
|
||||||
|
$state = &plugin_state();
|
||||||
|
$state['booted'] = false;
|
||||||
|
plugins_boot();
|
||||||
|
};
|
||||||
|
$cols = fn() => array_column($pdo->query('PRAGMA table_info(plugin_updprobe_items)')->fetchAll(), 'name');
|
||||||
|
|
||||||
|
// Version 1 mit Tabelle
|
||||||
|
$v1 = $mkZip('1.0.0', ['001_items.sqlite.sql' => 'CREATE TABLE IF NOT EXISTS plugin_updprobe_items (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)', '001_items.mysql.sql' => 'CREATE TABLE IF NOT EXISTS plugin_updprobe_items (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL)']);
|
||||||
|
$r = plugin_install_zip($pdo, $v1);
|
||||||
|
$check($r['ok'] && !$r['updated'], 'Version 1 lässt sich installieren');
|
||||||
|
$check(plugin_install_message($r, plugins_installed($pdo)['updprobe']) === 'Update-Probe 1.0.0 installiert (nicht signiert). Es ist noch nicht aktiv.', 'Meldung bei Neuinstallation: ' . plugin_install_message($r, plugins_installed($pdo)['updprobe']));
|
||||||
|
$check(plugin_set_active($pdo, 'updprobe', true, ['db.migrations', 'hooks.cron'])['ok'], 'Version 1 lässt sich aktivieren');
|
||||||
|
$check($cols() === ['id', 'name'], 'Tabelle von Version 1 ist da');
|
||||||
|
$reboot();
|
||||||
|
$check(($GLOBALS['updprobe_loaded'] ?? 0) === 1, 'Plugin wird geladen');
|
||||||
|
$hash1 = $pdo->query("SELECT package_hash FROM plugins WHERE slug = 'updprobe'")->fetchColumn();
|
||||||
|
$check(get_setting('plugin_migrated.updprobe', '') === $hash1, 'Paketstand von Version 1 ist als eingespielt vermerkt');
|
||||||
|
$migrationsBefore = (int) $pdo->query("SELECT COUNT(*) FROM schema_migrations WHERE migration LIKE 'plugin_updprobe_%'")->fetchColumn();
|
||||||
|
|
||||||
|
// Version 2 bringt eine neue Spalte mit; das Plugin bleibt aktiv
|
||||||
|
$v2 = $mkZip('1.1.0', [
|
||||||
|
'001_items.sqlite.sql' => 'CREATE TABLE IF NOT EXISTS plugin_updprobe_items (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)',
|
||||||
|
'001_items.mysql.sql' => 'CREATE TABLE IF NOT EXISTS plugin_updprobe_items (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL)',
|
||||||
|
'002_extra.sqlite.sql' => 'ALTER TABLE plugin_updprobe_items ADD COLUMN extra TEXT NULL',
|
||||||
|
'002_extra.mysql.sql' => 'ALTER TABLE plugin_updprobe_items ADD COLUMN extra VARCHAR(50) NULL',
|
||||||
|
]);
|
||||||
|
$r2 = plugin_install_zip($pdo, $v2);
|
||||||
|
$row = plugins_installed($pdo)['updprobe'];
|
||||||
|
$check($r2['ok'] && $r2['updated'] && (int) $row['is_active'] === 1, 'Update eines aktiven Plugins: bleibt aktiv');
|
||||||
|
$msg = plugin_install_message($r2, $row);
|
||||||
|
$check(str_contains($msg, 'auf Version 1.1.0 aktualisiert') && str_contains($msg, 'läuft weiter') && !str_contains($msg, 'noch nicht aktiv'), 'Meldung beim Update eines aktiven Plugins: ' . $msg);
|
||||||
|
$check(!in_array('extra', $cols(), true), 'unmittelbar nach dem Update ist die neue Spalte noch nicht da (läuft beim nächsten Aufruf)');
|
||||||
|
$reboot();
|
||||||
|
$check(in_array('extra', $cols(), true), 'beim nächsten Aufruf wird die neue Spalte automatisch eingespielt');
|
||||||
|
$hash2 = $pdo->query("SELECT package_hash FROM plugins WHERE slug = 'updprobe'")->fetchColumn();
|
||||||
|
$check($hash2 !== $hash1 && get_setting('plugin_migrated.updprobe', '') === $hash2, 'Paketstand von Version 2 ist vermerkt');
|
||||||
|
$check((int) $pdo->query("SELECT COUNT(*) FROM schema_migrations WHERE migration LIKE 'plugin_updprobe_%'")->fetchColumn() === $migrationsBefore + 1, 'nur die neue Änderung wurde eingespielt');
|
||||||
|
$reboot();
|
||||||
|
$check((int) $pdo->query("SELECT COUNT(*) FROM schema_migrations WHERE migration LIKE 'plugin_updprobe_%'")->fetchColumn() === $migrationsBefore + 1, 'weitere Aufrufe spielen nichts mehr ein');
|
||||||
|
$check((int) $pdo->query("SELECT fail_count FROM plugins WHERE slug = 'updprobe'")->fetchColumn() === 0, 'keine Fehler gezählt');
|
||||||
|
|
||||||
|
// Version 3 mit kaputter Änderung: Fehler wird sichtbar, das Plugin wird nicht stillschweigend weiterbenutzt
|
||||||
|
$v3 = $mkZip('1.2.0', [
|
||||||
|
'001_items.sqlite.sql' => 'CREATE TABLE IF NOT EXISTS plugin_updprobe_items (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)',
|
||||||
|
'002_extra.sqlite.sql' => 'ALTER TABLE plugin_updprobe_items ADD COLUMN extra TEXT NULL',
|
||||||
|
'003_kaputt.sqlite.sql' => 'ALTER TABLE plugin_gibt_es_nicht ADD COLUMN x TEXT NULL',
|
||||||
|
]);
|
||||||
|
plugin_install_zip($pdo, $v3);
|
||||||
|
$reboot();
|
||||||
|
$p = plugins_installed($pdo)['updprobe'];
|
||||||
|
$check((int) $p['fail_count'] >= 1 && str_contains((string) $p['last_error'], 'Datenbank-Änderung fehlgeschlagen'), 'fehlerhafte Änderung wird gemeldet: ' . $p['last_error']);
|
||||||
|
$check(get_setting('plugin_migrated.updprobe', '') === $hash2, 'der kaputte Stand gilt nicht als eingespielt (nächster Versuch beim nächsten Aufruf)');
|
||||||
|
|
||||||
|
// Update mit neuer Berechtigung (nicht signiert): Plugin wird angehalten, Meldung sagt es
|
||||||
|
$v4 = $mkZip('1.3.0', ['001_items.sqlite.sql' => 'CREATE TABLE IF NOT EXISTS plugin_updprobe_items (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)'], ['db.migrations', 'hooks.cron', 'ui.menu']);
|
||||||
|
plugin_set_active($pdo, 'updprobe', true, ['db.migrations', 'hooks.cron']);
|
||||||
|
$r4 = plugin_install_zip($pdo, $v4);
|
||||||
|
$check($r4['needs_reapproval'] === ['ui.menu'] && (int) plugins_installed($pdo)['updprobe']['is_active'] === 0, 'nicht signiertes Update mit neuer Berechtigung wird angehalten');
|
||||||
|
$check(str_contains(plugin_install_message($r4, plugins_installed($pdo)['updprobe']), 'zusätzliche Berechtigungen') && str_contains(plugin_install_message($r4, plugins_installed($pdo)['updprobe']), 'nicht aktiv'), 'Meldung nennt die zusätzlichen Berechtigungen und dass das Plugin nicht aktiv ist');
|
||||||
|
// Update eines inaktiven Plugins ohne neue Berechtigungen
|
||||||
|
plugin_set_active($pdo, 'updprobe', true, ['db.migrations', 'hooks.cron', 'ui.menu']);
|
||||||
|
plugin_set_active($pdo, 'updprobe', false);
|
||||||
|
$v5 = $mkZip('1.4.0', ['001_items.sqlite.sql' => 'CREATE TABLE IF NOT EXISTS plugin_updprobe_items (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)'], ['db.migrations', 'hooks.cron', 'ui.menu']);
|
||||||
|
$r5 = plugin_install_zip($pdo, $v5);
|
||||||
|
$check(str_contains(plugin_install_message($r5, plugins_installed($pdo)['updprobe']), 'weiterhin nicht aktiv'), 'Meldung beim Update eines inaktiven Plugins');
|
||||||
|
|
||||||
|
// Ohne Berechtigung db.migrations kein automatisches Einspielen
|
||||||
|
$check(!str_contains((string) file_get_contents(dirname(__DIR__) . '/includes/plugins.php'), "plugin_ensure_migrated(\$slug, (string) (\$row['package_hash'] ?? ''));\n") || str_contains((string) file_get_contents(dirname(__DIR__) . '/includes/plugins.php'), "in_array('db.migrations', \$manifest['permissions'], true)) plugin_ensure_migrated"), 'nur Plugins mit der Berechtigung db.migrations bekommen automatische Änderungen');
|
||||||
|
|
||||||
|
foreach (scandir($pluginDir) ?: [] as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
|
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
|
||||||
|
}
|
||||||
|
@rmdir($pluginDir);
|
||||||
|
foreach ([$v1, $v2, $v3, $v4, $v5] as $z) @unlink($z);
|
||||||
|
if ($failures) {
|
||||||
|
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
echo 'Plugin update migration checks passed.' . "\n";
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft das Plugin "Urlaub und Vertretung": Eingabeprüfung, Beginn (Tickets an die Vertretung, Urlaubsstatus), neue Tickets,
|
||||||
|
* Ketten, Ende (Rückgabe nur der noch offenen und noch bei der Vertretung liegenden Tickets), Abbruch und Rechte.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$pluginDir = sys_get_temp_dir() . '/deskhub-vt-' . bin2hex(random_bytes(4));
|
||||||
|
mkdir($pluginDir, 0755, true);
|
||||||
|
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
|
||||||
|
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';
|
||||||
|
|
||||||
|
$failures = [];
|
||||||
|
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||||
|
if (!$ok) $failures[] = $what;
|
||||||
|
};
|
||||||
|
$pdo = get_db();
|
||||||
|
|
||||||
|
require_once __DIR__ . '/fixtures/paths.php';
|
||||||
|
$src = plugin_source_dir_or_skip('vertretung', 'plugin_vertretung');
|
||||||
|
$zipPath = sys_get_temp_dir() . '/vt-' . bin2hex(random_bytes(3)) . '.zip';
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
$zip->open($zipPath, ZipArchive::CREATE);
|
||||||
|
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) {
|
||||||
|
$zip->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1)));
|
||||||
|
}
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
$r = plugin_install_zip($pdo, $zipPath);
|
||||||
|
$check($r['ok'] && $r['slug'] === 'vertretung', 'Plugin lässt sich installieren: ' . json_encode($r));
|
||||||
|
$act = plugin_set_active($pdo, 'vertretung', true, ['hooks.tickets', 'hooks.cron', 'ui.menu', 'db.migrations']);
|
||||||
|
$check($act['ok'], 'Plugin lässt sich aktivieren: ' . json_encode($act));
|
||||||
|
$state = &plugin_state();
|
||||||
|
$state['booted'] = false;
|
||||||
|
plugins_boot();
|
||||||
|
$check(count(plugin_menu_items(['id' => 1, 'role' => 'agent'])) === 1, 'Menüpunkt ist für jeden Mitarbeiter sichtbar');
|
||||||
|
|
||||||
|
$mkStaff = function (string $name, string $role = 'agent') use ($pdo): int {
|
||||||
|
$pdo->prepare('INSERT INTO staff (name, email, password_hash, role, is_active, created_at) VALUES (?,?,?,?,1,?)')
|
||||||
|
->execute([$name, strtolower($name) . '@team.example', 'x', $role, db_now()]);
|
||||||
|
return (int) $pdo->lastInsertId();
|
||||||
|
};
|
||||||
|
$anna = $mkStaff('Anna'); $ben = $mkStaff('Ben'); $cara = $mkStaff('Cara'); $adm = $mkStaff('Chef', 'admin');
|
||||||
|
$actor = fn(int $id, string $role = 'agent') => ['id' => $id, 'role' => $role];
|
||||||
|
$dept = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
|
||||||
|
$prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn();
|
||||||
|
$n = 0;
|
||||||
|
$mk = function (int $assigned, string $status = 'open') use ($pdo, $dept, $prio, &$n): int {
|
||||||
|
$n++;
|
||||||
|
$now = db_now();
|
||||||
|
$pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, assigned_staff_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)')
|
||||||
|
->execute(['VT-' . $n, 'Anfrage ' . $n, 'Kunde', 'kunde@x.example', $dept, $prio, $status, 'web', $assigned ?: null, $now, $now]);
|
||||||
|
return (int) $pdo->lastInsertId();
|
||||||
|
};
|
||||||
|
$owner = fn(int $t) => (int) $pdo->query("SELECT assigned_staff_id FROM tickets WHERE id = {$t}")->fetchColumn();
|
||||||
|
$flag = fn(int $s) => $pdo->query("SELECT is_on_vacation, vacation_substitute_id FROM staff WHERE id = {$s}")->fetch();
|
||||||
|
$day = fn(int $offset) => date('Y-m-d', strtotime('2026-07-15 12:00:00') + $offset * 86400);
|
||||||
|
$GLOBALS['vertretung_today'] = $day(0);
|
||||||
|
|
||||||
|
// ── Eingabeprüfung und Rechte ──────────────────────────────────────────────
|
||||||
|
$bad = fn($res) => !$res['ok'];
|
||||||
|
$check($bad(vertretung_create($pdo, $actor($anna), $anna, $ben, $day(3), $day(1), '')), 'Ende vor Beginn wird abgelehnt');
|
||||||
|
$check($bad(vertretung_create($pdo, $actor($anna), $anna, $ben, $day(-5), $day(-2), '')), 'Zeitraum in der Vergangenheit wird abgelehnt');
|
||||||
|
$check($bad(vertretung_create($pdo, $actor($anna), $anna, $anna, $day(1), $day(2), '')), 'man kann sich nicht selbst vertreten');
|
||||||
|
$check($bad(vertretung_create($pdo, $actor($anna), $anna, 9999, $day(1), $day(2), '')), 'unbekannte Vertretung wird abgelehnt');
|
||||||
|
$check($bad(vertretung_create($pdo, $actor($anna), $anna, $ben, '2026-02-30', $day(2), '')), 'ungültiges Datum wird abgelehnt');
|
||||||
|
$check($bad(vertretung_create($pdo, $actor($anna), $ben, $cara, $day(1), $day(2), '')), 'Mitarbeiter tragen keine fremde Abwesenheit ein');
|
||||||
|
$pdo->prepare('UPDATE staff SET is_active = 0 WHERE id = ?')->execute([$cara]);
|
||||||
|
$check($bad(vertretung_create($pdo, $actor($anna), $anna, $cara, $day(1), $day(2), '')), 'inaktive Vertretung wird abgelehnt');
|
||||||
|
$pdo->prepare('UPDATE staff SET is_active = 1 WHERE id = ?')->execute([$cara]);
|
||||||
|
|
||||||
|
// ── Geplant, dann Beginn ───────────────────────────────────────────────────
|
||||||
|
$t1 = $mk($anna); $t2 = $mk($anna, 'pending'); $tDone = $mk($anna, 'resolved'); $tBen = $mk($ben); $tNobody = $mk(0);
|
||||||
|
$plan = vertretung_create($pdo, $actor($anna), $anna, $ben, $day(2), $day(6), 'Urlaub');
|
||||||
|
$check($plan['ok'], 'Anna trägt ihre eigene Abwesenheit ein');
|
||||||
|
$check($owner($t1) === $anna && (int) $flag($anna)['is_on_vacation'] === 0, 'vor dem Beginn ändert sich nichts');
|
||||||
|
$check($bad(vertretung_create($pdo, $actor($anna), $anna, $cara, $day(4), $day(8), '')), 'überschneidende Abwesenheit wird abgelehnt');
|
||||||
|
vertretung_tick($pdo);
|
||||||
|
$check($owner($t1) === $anna, 'Tick vor dem Beginn ändert nichts');
|
||||||
|
|
||||||
|
$GLOBALS['vertretung_today'] = $day(2);
|
||||||
|
vertretung_tick($pdo);
|
||||||
|
$check($owner($t1) === $ben && $owner($t2) === $ben, 'zum Beginn gehen die offenen Tickets an die Vertretung');
|
||||||
|
$check($owner($tDone) === $anna, 'gelöste Tickets bleiben bei Anna');
|
||||||
|
$check($owner($tBen) === $ben && $owner($tNobody) === 0, 'andere Tickets bleiben unberührt');
|
||||||
|
$f = $flag($anna);
|
||||||
|
$check((int) $f['is_on_vacation'] === 1 && (int) $f['vacation_substitute_id'] === $ben, 'Urlaubsstatus und Vertretung im Profil sind gesetzt');
|
||||||
|
$check((int) $pdo->query("SELECT COUNT(*) FROM activity_log WHERE ticket_id = {$t1} AND action LIKE '%Vertretung Ben%'")->fetchColumn() === 1, 'Aktivität im Ticket vermerkt');
|
||||||
|
vertretung_tick($pdo);
|
||||||
|
$check((int) $pdo->query('SELECT COUNT(*) FROM plugin_vertretung_moves')->fetchColumn() === 2, 'zweiter Tick verteilt nicht noch einmal');
|
||||||
|
|
||||||
|
// neue Tickets für Anna
|
||||||
|
$tNew = $mk($anna);
|
||||||
|
webhook_dispatch($pdo, 'ticket.created', $tNew);
|
||||||
|
$check($owner($tNew) === $ben, 'neues Ticket für die Abwesende geht an die Vertretung');
|
||||||
|
$tManual = $mk($anna);
|
||||||
|
webhook_dispatch($pdo, 'ticket.assigned', $tManual);
|
||||||
|
$check($owner($tManual) === $ben, 'von Hand an die Abwesende zugewiesen geht ebenfalls weiter');
|
||||||
|
$tAnyone = $mk($cara);
|
||||||
|
webhook_dispatch($pdo, 'ticket.created', $tAnyone);
|
||||||
|
$check($owner($tAnyone) === $cara, 'Tickets anderer Personen bleiben');
|
||||||
|
|
||||||
|
// automatische Zuweisung der Abteilung berücksichtigt den Urlaubsstatus bereits
|
||||||
|
$pdo->prepare("UPDATE departments SET auto_assign_mode = 'staff', auto_assign_staff_id = ? WHERE id = ?")->execute([$anna, $dept]);
|
||||||
|
$check(department_auto_assignee($pdo, $dept) === $ben, 'automatische Zuweisung der Abteilung nimmt die Vertretung');
|
||||||
|
$pdo->prepare("UPDATE departments SET auto_assign_mode = 'none' WHERE id = ?")->execute([$dept]);
|
||||||
|
|
||||||
|
// Kette: Ben ist selbst abwesend mit Vertretung Cara
|
||||||
|
$chain = vertretung_create($pdo, $actor($adm, 'admin'), $ben, $cara, $day(2), $day(4), 'Kette');
|
||||||
|
$check($chain['ok'], 'Administrator trägt die Abwesenheit eines anderen ein');
|
||||||
|
vertretung_tick($pdo);
|
||||||
|
$check($owner($t1) === $cara && $owner($tBen) === $cara, 'Bens Tickets (auch von Anna übernommene) gehen weiter an Cara');
|
||||||
|
$check(vertretung_target($pdo, $anna) === $cara, 'die Kette führt von Anna über Ben zu Cara');
|
||||||
|
$tChain = $mk($anna);
|
||||||
|
webhook_dispatch($pdo, 'ticket.created', $tChain);
|
||||||
|
$check($owner($tChain) === $cara, 'neues Ticket für Anna landet am Ende der Kette');
|
||||||
|
|
||||||
|
// ── Ende von Ben: seine Tickets kommen zurück (Cara hatte sie nur übernommen) ──
|
||||||
|
$GLOBALS['vertretung_today'] = $day(5);
|
||||||
|
vertretung_tick($pdo);
|
||||||
|
$check((int) $flag($ben)['is_on_vacation'] === 0, 'Bens Urlaubsstatus ist zurückgenommen');
|
||||||
|
$check($owner($tBen) === $ben, 'Bens eigenes Ticket ist wieder bei ihm');
|
||||||
|
$check($owner($t1) === $ben && $owner($tChain) === $ben, 'auch die von Anna übernommenen liegen wieder bei Ben (dort ist die Abwesenheit von Anna noch aktiv)');
|
||||||
|
$st = $pdo->query("SELECT status FROM plugin_vertretung_absences WHERE id = " . (int) $chain['id'])->fetchColumn();
|
||||||
|
$check($st === 'done', 'Abwesenheit steht auf „Beendet“');
|
||||||
|
|
||||||
|
// ── Ende von Anna: nur offene, noch bei der Vertretung liegende Tickets kommen zurück ──
|
||||||
|
$pdo->prepare("UPDATE tickets SET status = 'closed' WHERE id = ?")->execute([$t2]); // erledigt während der Abwesenheit
|
||||||
|
$pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$cara, $tManual]); // von Hand umverteilt
|
||||||
|
$GLOBALS['vertretung_today'] = $day(7);
|
||||||
|
vertretung_tick($pdo);
|
||||||
|
$check((int) $flag($anna)['is_on_vacation'] === 0 && $flag($anna)['vacation_substitute_id'] === null, 'Annas Urlaubsstatus ist zurückgenommen');
|
||||||
|
$check($owner($t1) === $anna && $owner($tNew) === $anna && $owner($tChain) === $anna, 'offene Tickets sind wieder bei Anna');
|
||||||
|
$check($owner($t2) === $ben, 'geschlossenes Ticket bleibt, wo es war');
|
||||||
|
$check($owner($tManual) === $cara, 'von Hand umverteiltes Ticket bleibt bei der neuen Person');
|
||||||
|
$check((int) $pdo->query('SELECT COUNT(*) FROM plugin_vertretung_moves WHERE returned_at IS NULL')->fetchColumn() === 0, 'keine offenen Rückgaben mehr');
|
||||||
|
$check($pdo->query("SELECT status FROM plugin_vertretung_absences WHERE id = " . (int) $plan['id'])->fetchColumn() === 'done', 'Annas Abwesenheit steht auf „Beendet“');
|
||||||
|
|
||||||
|
// ── Beginn am selben Tag, Abbruch, nicht angetreten, Rechte ────────────────
|
||||||
|
$tX = $mk($cara);
|
||||||
|
$now1 = vertretung_create($pdo, $actor($cara), $cara, $anna, $day(7), $day(20), '');
|
||||||
|
$check($now1['ok'] && $owner($tX) === $anna, 'Beginn heute: Tickets gehen sofort weiter');
|
||||||
|
$check(!vertretung_cancel($pdo, $actor($ben), (int) $now1['id']), 'fremde Abwesenheit kann nicht beendet werden');
|
||||||
|
$check(vertretung_cancel($pdo, $actor($cara), (int) $now1['id']) && $owner($tX) === $cara && (int) $flag($cara)['is_on_vacation'] === 0, 'Abbruch gibt die Tickets zurück und nimmt den Status zurück');
|
||||||
|
$later = vertretung_create($pdo, $actor($adm, 'admin'), $ben, $anna, $day(10), $day(12), '');
|
||||||
|
$check($later['ok'] && vertretung_cancel($pdo, $actor($adm, 'admin'), (int) $later['id']) && $pdo->query('SELECT status FROM plugin_vertretung_absences WHERE id = ' . (int) $later['id'])->fetchColumn() === 'cancelled', 'geplante Abwesenheit lässt sich löschen');
|
||||||
|
$ghost = vertretung_create($pdo, $actor($adm, 'admin'), $ben, $anna, $day(8), $day(9), '');
|
||||||
|
$GLOBALS['vertretung_today'] = $day(15);
|
||||||
|
vertretung_tick($pdo);
|
||||||
|
$check($pdo->query('SELECT status FROM plugin_vertretung_absences WHERE id = ' . (int) $ghost['id'])->fetchColumn() === 'missed' && (int) $flag($ben)['is_on_vacation'] === 0, 'versäumter Zeitraum wird nicht mehr gestartet');
|
||||||
|
|
||||||
|
// Profil wurde zwischendurch von Hand geändert: Plugin nimmt fremde Einstellungen nicht zurück
|
||||||
|
$GLOBALS['vertretung_today'] = $day(30);
|
||||||
|
$pdo->exec('UPDATE plugin_vertretung_absences SET status = \'cancelled\' WHERE status IN (\'planned\',\'active\')');
|
||||||
|
$c = vertretung_create($pdo, $actor($adm, 'admin'), $cara, $ben, $day(30), $day(31), '');
|
||||||
|
$pdo->prepare('UPDATE staff SET vacation_substitute_id = ? WHERE id = ?')->execute([$anna, $cara]);
|
||||||
|
$GLOBALS['vertretung_today'] = $day(32);
|
||||||
|
vertretung_tick($pdo);
|
||||||
|
$check((int) $flag($cara)['is_on_vacation'] === 1 && (int) $flag($cara)['vacation_substitute_id'] === $anna, 'von Hand geänderter Urlaubsstatus bleibt bestehen');
|
||||||
|
|
||||||
|
unset($GLOBALS['vertretung_today']);
|
||||||
|
foreach (scandir($pluginDir) ?: [] as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
|
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
|
||||||
|
}
|
||||||
|
@rmdir($pluginDir);
|
||||||
|
@unlink($zipPath);
|
||||||
|
|
||||||
|
if ($failures) {
|
||||||
|
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
echo 'Vertretung plugin checks passed.' . "\n";
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft das Plugin "Anhänge-Virenscan": Installation, Prüfung über die eingebaute Testprüfung (EICAR), Sperren und Quarantäne,
|
||||||
|
* Freigeben und Löschen, Fehler- und Größenbehandlung, Auswertung der ClamAV-Ausgabe, Zeitlimit des Prüfprogramms, Auslösung über die Ticket-Ereignisse.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$pluginDir = sys_get_temp_dir() . '/deskhub-vs-' . bin2hex(random_bytes(4));
|
||||||
|
mkdir($pluginDir, 0755, true);
|
||||||
|
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
|
||||||
|
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';
|
||||||
|
|
||||||
|
$failures = [];
|
||||||
|
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||||
|
if (!$ok) $failures[] = $what;
|
||||||
|
};
|
||||||
|
$pdo = get_db();
|
||||||
|
|
||||||
|
require_once __DIR__ . '/fixtures/paths.php';
|
||||||
|
$src = plugin_source_dir_or_skip('virenscan', 'plugin_virenscan');
|
||||||
|
$zipPath = sys_get_temp_dir() . '/vs-' . bin2hex(random_bytes(3)) . '.zip';
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
$zip->open($zipPath, ZipArchive::CREATE);
|
||||||
|
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) {
|
||||||
|
$zip->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1)));
|
||||||
|
}
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
$r = plugin_install_zip($pdo, $zipPath);
|
||||||
|
$check($r['ok'] && $r['slug'] === 'virenscan', 'Plugin lässt sich installieren: ' . json_encode($r));
|
||||||
|
$check(!plugin_set_active($pdo, 'virenscan', true)['ok'], 'Aktivierung ohne Bestätigung der Berechtigungen wird abgelehnt');
|
||||||
|
$act = plugin_set_active($pdo, 'virenscan', true, ['hooks.tickets', 'hooks.cron', 'ui.menu', 'db.migrations']);
|
||||||
|
$check($act['ok'], 'Plugin lässt sich aktivieren: ' . json_encode($act));
|
||||||
|
$state = &plugin_state();
|
||||||
|
$state['booted'] = false;
|
||||||
|
plugins_boot();
|
||||||
|
$check(isset(plugin_state()['loaded']['virenscan']), 'Plugin wird geladen');
|
||||||
|
$check(count(plugin_admin_menu_items(['id' => 1, 'role' => 'admin'])) === 1, 'Menüpunkt steht im Adminportal');
|
||||||
|
$check(count(plugin_menu_items(['id' => 1, 'role' => 'admin'])) === 0, 'Menüpunkt steht nicht im Hauptmenü');
|
||||||
|
$check(count(plugin_admin_menu_items(['id' => 2, 'role' => 'agent'])) === 0 && count(plugin_menu_items(['id' => 2, 'role' => 'agent'])) === 0, 'Mitarbeiter sehen den Menüpunkt nicht');
|
||||||
|
$check((int) $pdo->query('SELECT COUNT(*) FROM plugin_virenscan_files')->fetchColumn() === 0, 'Tabelle wurde angelegt');
|
||||||
|
|
||||||
|
// Testprüfung erzwingen (ClamAV muss für den Test nicht installiert sein)
|
||||||
|
plugin_set_setting('virenscan', 'mode', 'builtin');
|
||||||
|
$st = virenscan_status();
|
||||||
|
$check($st['mode'] === 'builtin' && $st['ok'] === false && str_contains($st['message'], 'ClamAV'), 'ohne ClamAV: Hinweis, dass nur die Testprüfung läuft');
|
||||||
|
|
||||||
|
// ── Daten ──────────────────────────────────────────────────────────────────
|
||||||
|
$uploads = virenscan_uploads_dir();
|
||||||
|
$created = [];
|
||||||
|
$now = db_now();
|
||||||
|
$dept = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
|
||||||
|
$prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn();
|
||||||
|
$pdo->prepare("INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, created_at, updated_at) VALUES ('VS-1','Test','K','k@example.test',?,?, 'open','web',?,?)")->execute([$dept, $prio, $now, $now]);
|
||||||
|
$ticketId = (int) $pdo->lastInsertId();
|
||||||
|
$pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'customer', 'Anhang anbei', 0, ?)")->execute([$ticketId, $now]);
|
||||||
|
$threadId = (int) $pdo->lastInsertId();
|
||||||
|
$attach = function (string $name, string $content) use ($pdo, $threadId, $uploads, &$created, $now): int {
|
||||||
|
$stored = 'vstest-' . bin2hex(random_bytes(4)) . '.bin';
|
||||||
|
file_put_contents($uploads . '/' . $stored, $content);
|
||||||
|
$created[] = $stored;
|
||||||
|
$pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)')->execute([$threadId, $name, $stored, strlen($content), $now]);
|
||||||
|
return (int) $pdo->lastInsertId();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Erster Lauf legt die Startmarke fest: schon vorhandene Anhänge werden nicht nachgeprüft
|
||||||
|
$old = $attach('alt.bin', virenscan_eicar());
|
||||||
|
$check(virenscan_tick($pdo) === 0, 'Bestand vor dem Einschalten wird nicht geprüft');
|
||||||
|
$check(virenscan_start_id($pdo) === $old, 'Startmarke steht auf dem letzten vorhandenen Anhang');
|
||||||
|
|
||||||
|
// ── Prüfung und Quarantäne ─────────────────────────────────────────────────
|
||||||
|
$clean = $attach('rechnung.pdf', "%PDF-1.4 harmlos\n");
|
||||||
|
$bad = $attach('lebenslauf.pdf.exe', 'MZ' . str_repeat('x', 50) . virenscan_eicar());
|
||||||
|
$n = virenscan_scan_ticket($pdo, $ticketId);
|
||||||
|
$check($n === 2, 'Beide neuen Anhänge werden geprüft (' . $n . ')');
|
||||||
|
$rows = $pdo->query('SELECT attachment_id, status, quarantined, signature FROM plugin_virenscan_files ORDER BY attachment_id')->fetchAll(PDO::FETCH_UNIQUE | PDO::FETCH_ASSOC);
|
||||||
|
$check(($rows[$clean]['status'] ?? '') === 'clean' && (int) $rows[$clean]['quarantined'] === 0, 'saubere Datei bleibt unverändert');
|
||||||
|
$check(($rows[$bad]['status'] ?? '') === 'infected' && (int) $rows[$bad]['quarantined'] === 1 && ($rows[$bad]['signature'] ?? '') === 'Eicar-Test-Signature', 'verseuchte Datei wird erkannt und gesperrt');
|
||||||
|
$badRow = $pdo->query("SELECT * FROM ticket_attachments WHERE id = {$bad}")->fetch();
|
||||||
|
$check($badRow['stored_name'] === 'gesperrt' && str_starts_with($badRow['original_name'], '[GESPERRT] '), 'Anhang ist umbenannt und nicht mehr herunterladbar');
|
||||||
|
$cleanRow = $pdo->query("SELECT * FROM ticket_attachments WHERE id = {$clean}")->fetch();
|
||||||
|
$check($cleanRow['original_name'] === 'rechnung.pdf' && is_file($uploads . '/' . $cleanRow['stored_name']), 'saubere Datei liegt unverändert da');
|
||||||
|
$badStored = $created[array_search(true, array_map(fn($c) => !is_file($uploads . '/' . $c), $created), true)] ?? '';
|
||||||
|
$check($badStored !== '' && is_file($uploads . '/quarantaene/' . $badStored), 'verseuchte Datei liegt in der Quarantäne');
|
||||||
|
$check(!is_file($uploads . '/' . $badStored), 'verseuchte Datei ist aus dem Upload-Ordner verschwunden');
|
||||||
|
$check(is_file($uploads . '/quarantaene/.htaccess'), 'Quarantäne-Ordner ist vor Web-Zugriff geschützt');
|
||||||
|
$note = $pdo->query("SELECT COUNT(*) FROM ticket_threads WHERE ticket_id = {$ticketId} AND author_type = 'system' AND is_internal_note = 1 AND message LIKE '%Virenscan gesperrt%'")->fetchColumn();
|
||||||
|
$check((int) $note === 1, 'Ticket bekommt eine interne Notiz');
|
||||||
|
$act = $pdo->query("SELECT COUNT(*) FROM activity_log WHERE ticket_id = {$ticketId} AND action LIKE 'Anhang vom Virenscan gesperrt%'")->fetchColumn();
|
||||||
|
$check((int) $act === 1, 'Aktivitätsverlauf nennt die Sperre');
|
||||||
|
$tag = $pdo->query("SELECT COUNT(*) FROM ticket_tags tt JOIN tags g ON g.id = tt.tag_id WHERE tt.ticket_id = {$ticketId} AND g.name = 'Virus-Verdacht'")->fetchColumn();
|
||||||
|
$check((int) $tag === 1, 'Ticket trägt den Tag „Virus-Verdacht“');
|
||||||
|
$check(virenscan_scan_ticket($pdo, $ticketId) === 0, 'nichts wird doppelt geprüft');
|
||||||
|
|
||||||
|
// ── Freigeben und Löschen ──────────────────────────────────────────────────
|
||||||
|
$q = (int) $pdo->query("SELECT id FROM plugin_virenscan_files WHERE attachment_id = {$bad}")->fetchColumn();
|
||||||
|
$check(virenscan_release($pdo, $q), 'Freigeben klappt');
|
||||||
|
$after = $pdo->query("SELECT * FROM ticket_attachments WHERE id = {$bad}")->fetch();
|
||||||
|
$check($after['original_name'] === 'lebenslauf.pdf.exe' && $after['stored_name'] === $badStored && is_file($uploads . '/' . $badStored), 'freigegebene Datei ist wieder da, Name zurückgesetzt');
|
||||||
|
$check(virenscan_stats($pdo)['released'] === 1 && virenscan_stats($pdo)['quarantined'] === 0, 'Zähler zeigen „freigegeben“');
|
||||||
|
$check(!virenscan_release($pdo, $q), 'zweites Freigeben wird abgelehnt');
|
||||||
|
$bad2 = $attach('zweite.exe', virenscan_eicar());
|
||||||
|
virenscan_tick($pdo);
|
||||||
|
$q2 = (int) $pdo->query("SELECT id FROM plugin_virenscan_files WHERE attachment_id = {$bad2}")->fetchColumn();
|
||||||
|
$stored2 = $pdo->query("SELECT stored_name FROM plugin_virenscan_files WHERE id = {$q2}")->fetchColumn();
|
||||||
|
$check(is_file($uploads . '/quarantaene/' . $stored2), 'Hintergrundlauf findet und sperrt weitere Dateien');
|
||||||
|
$check(virenscan_delete($pdo, $q2) && !is_file($uploads . '/quarantaene/' . $stored2), 'Löschen entfernt die Datei endgültig');
|
||||||
|
$check(str_starts_with((string) $pdo->query("SELECT original_name FROM ticket_attachments WHERE id = {$bad2}")->fetchColumn(), '[GELÖSCHT] '), 'Anhang im Ticket ist als gelöscht vermerkt');
|
||||||
|
|
||||||
|
// ── Sonderfälle ────────────────────────────────────────────────────────────
|
||||||
|
$missing = $attach('weg.txt', 'x');
|
||||||
|
@unlink($uploads . '/' . $pdo->query("SELECT stored_name FROM ticket_attachments WHERE id = {$missing}")->fetchColumn());
|
||||||
|
virenscan_tick($pdo);
|
||||||
|
$check($pdo->query("SELECT status FROM plugin_virenscan_files WHERE attachment_id = {$missing}")->fetchColumn() === 'error', 'fehlende Datei: Fehler, nichts gesperrt');
|
||||||
|
plugin_set_setting('virenscan', 'max_mb', '1');
|
||||||
|
$big = $attach('gross.bin', str_repeat('A', 1200000));
|
||||||
|
virenscan_tick($pdo);
|
||||||
|
$check($pdo->query("SELECT status FROM plugin_virenscan_files WHERE attachment_id = {$big}")->fetchColumn() === 'skipped', 'zu große Datei wird übersprungen');
|
||||||
|
plugin_set_setting('virenscan', 'block_on_error', '1');
|
||||||
|
$missing2 = $attach('weg2.txt', 'x');
|
||||||
|
@unlink($uploads . '/' . $pdo->query("SELECT stored_name FROM ticket_attachments WHERE id = {$missing2}")->fetchColumn());
|
||||||
|
virenscan_tick($pdo);
|
||||||
|
$check($pdo->query("SELECT status FROM plugin_virenscan_files WHERE attachment_id = {$missing2}")->fetchColumn() === 'error', 'fehlende Datei wird auch bei „bei Fehler sperren“ nur als Fehler vermerkt (nichts zum Sperren)');
|
||||||
|
|
||||||
|
// ── ClamAV-Ausgabe und Programmaufruf ──────────────────────────────────────
|
||||||
|
$check(virenscan_interpret(0, '/tmp/a: OK', '')['status'] === 'clean', 'ClamAV Exit 0 = sauber');
|
||||||
|
$inf = virenscan_interpret(1, "/tmp/a.exe: Win.Test.EICAR_HDB-1 FOUND\n", '');
|
||||||
|
$check($inf['status'] === 'infected' && $inf['signature'] === 'Win.Test.EICAR_HDB-1', 'ClamAV Exit 1 = Fund mit Signaturname');
|
||||||
|
$errR = virenscan_interpret(2, '', 'ERROR: Can\'t connect to clamd');
|
||||||
|
$check($errR['status'] === 'error' && str_contains((string) $errR['message'], 'clamd'), 'ClamAV Exit 2 = Fehler mit Meldung');
|
||||||
|
[$code, $out] = virenscan_run([PHP_BINARY, '-r', 'echo "hallo"; exit(3);'], 10);
|
||||||
|
$check($code === 3 && $out === 'hallo', 'Programmaufruf liefert Exit-Code und Ausgabe');
|
||||||
|
$t0 = microtime(true);
|
||||||
|
[$code2, , $e2] = virenscan_run([PHP_BINARY, '-r', 'sleep(6);'], 1);
|
||||||
|
$check($code2 === -1 && str_contains($e2, 'Zeitüberschreitung') && microtime(true) - $t0 < 4, 'Zeitlimit beendet hängende Prüfprogramme');
|
||||||
|
|
||||||
|
// ── Auslösung über die Ticket-Ereignisse ───────────────────────────────────
|
||||||
|
plugin_set_setting('virenscan', 'max_mb', '25');
|
||||||
|
$viaHook = $attach('per-hook.exe', virenscan_eicar());
|
||||||
|
webhook_dispatch($pdo, 'ticket.replied', $ticketId, ['author' => 'customer', 'internal' => false]);
|
||||||
|
$check($pdo->query("SELECT quarantined FROM plugin_virenscan_files WHERE attachment_id = {$viaHook}")->fetchColumn() == 1, 'Ereignis „Antwort“ löst die Prüfung aus');
|
||||||
|
$viaHook2 = $attach('neu.exe', virenscan_eicar());
|
||||||
|
webhook_dispatch($pdo, 'ticket.created', $ticketId);
|
||||||
|
$check($pdo->query("SELECT quarantined FROM plugin_virenscan_files WHERE attachment_id = {$viaHook2}")->fetchColumn() == 1, 'Ereignis „Ticket erstellt“ löst die Prüfung aus');
|
||||||
|
$check(is_string(virenscan_eicar()) && strlen(virenscan_eicar()) === 68, 'EICAR-Zeichenfolge ist die Standardzeichenfolge (68 Zeichen)');
|
||||||
|
|
||||||
|
// ── Eingebaute Dateianalyse ────────────────────────────────────────────────
|
||||||
|
$tmpDir = sys_get_temp_dir() . '/vs-an-' . bin2hex(random_bytes(3));
|
||||||
|
mkdir($tmpDir);
|
||||||
|
$mk = function (string $name, string $content) use ($tmpDir): string {
|
||||||
|
$p = $tmpDir . '/' . bin2hex(random_bytes(3));
|
||||||
|
file_put_contents($p, $content);
|
||||||
|
return $p;
|
||||||
|
};
|
||||||
|
$zipOf = function (array $entries, ?string $password = null) use ($tmpDir): string {
|
||||||
|
$p = $tmpDir . '/' . bin2hex(random_bytes(3)) . '.zip';
|
||||||
|
$z = new ZipArchive();
|
||||||
|
$z->open($p, ZipArchive::CREATE);
|
||||||
|
foreach ($entries as $n => $c) {
|
||||||
|
$z->addFromString($n, $c);
|
||||||
|
if ($password !== null) { $z->setEncryptionName($n, ZipArchive::EM_AES_256, $password); }
|
||||||
|
}
|
||||||
|
$z->close();
|
||||||
|
return $p;
|
||||||
|
};
|
||||||
|
$verdict = function (string $path, string $name) use ($pdo): array {
|
||||||
|
$r = virenscan_scan_file($path, $name);
|
||||||
|
return [$r['status'], (string) $r['signature']];
|
||||||
|
};
|
||||||
|
plugin_set_setting('virenscan', 'mode', 'builtin');
|
||||||
|
plugin_set_setting('virenscan', 'analysis', '1');
|
||||||
|
foreach (['block_macros' => '1', 'block_encrypted' => '1', 'block_unscannable' => '0'] as $k => $v) plugin_set_setting('virenscan', $k, $v);
|
||||||
|
$clean = fn(string $path, string $name) => $verdict($path, $name)[0] === 'clean';
|
||||||
|
$infected = fn(string $path, string $name) => $verdict($path, $name)[0] === 'infected';
|
||||||
|
|
||||||
|
$check($clean($mk('a', 'Hallo, anbei die Rechnung.'), 'rechnung.txt'), 'Textdatei ist sauber');
|
||||||
|
$check($clean($mk('a', "%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\n%%EOF"), 'rechnung.pdf'), 'normales PDF ist sauber');
|
||||||
|
$check($clean($mk('a', "\x89PNG\r\n\x1a\n" . str_repeat('x', 100)), 'foto.png'), 'Bild ist sauber');
|
||||||
|
$check($clean($zipOf(['bericht.docx' => 'PK', 'notiz.txt' => 'hallo']), 'daten.zip'), 'harmloses Archiv ist sauber');
|
||||||
|
$check($infected($mk('a', 'echo hi'), 'start.bat'), '.bat wird gesperrt');
|
||||||
|
$check($infected($mk('a', 'x'), 'Rechnung.pdf.exe'), 'doppelte Endung mit .exe wird gesperrt');
|
||||||
|
$check($infected($mk('a', 'x'), 'Rechnung.exe.pdf'), 'versteckte .exe vor der Endung wird erkannt');
|
||||||
|
$check($infected($mk('a', 'x'), "Rechnung\u{202E}fdp.exe"), 'umgekehrte Schreibrichtung im Namen wird erkannt');
|
||||||
|
$check($infected($mk('a', "MZ\x90\x00\x03" . str_repeat("\0", 60)), 'foto.jpg'), 'Programm als Bild getarnt wird erkannt');
|
||||||
|
$check($infected($mk('a', "\x7fELF\x02\x01"), 'dokument.pdf'), 'Linux-Programm als PDF getarnt wird erkannt');
|
||||||
|
$check($infected($zipOf(['readme.txt' => 'hi', 'setup.exe' => 'x']), 'update.zip'), 'ZIP mit .exe wird gesperrt');
|
||||||
|
$check($infected($zipOf(['bilder/foto.jpg' => "MZ\x90\x00" . str_repeat('x', 30)]), 'fotos.zip'), 'ZIP mit getarntem Programm wird gesperrt');
|
||||||
|
$check($infected($zipOf(['a.txt' => 'geheim'], 'pw123'), 'rechnung.zip'), 'passwortgeschütztes ZIP wird gesperrt');
|
||||||
|
plugin_set_setting('virenscan', 'block_encrypted', '0');
|
||||||
|
$check($clean($zipOf(['a.txt' => 'geheim'], 'pw123'), 'rechnung.zip'), 'passwortgeschütztes ZIP lässt sich erlauben');
|
||||||
|
plugin_set_setting('virenscan', 'block_encrypted', '1');
|
||||||
|
$check($infected($zipOf(['word/vbaProject.bin' => 'x', '[Content_Types].xml' => '<x/>']), 'angebot.docx'), 'Office-Datei mit vbaProject.bin wird gesperrt');
|
||||||
|
$check($infected($mk('a', 'x'), 'angebot.docm'), 'makrofähige Endung wird gesperrt');
|
||||||
|
$check($infected($mk('a', "\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1" . str_repeat("\0", 100) . '_VBA_PROJECT' . str_repeat("\0", 50)), 'tabelle.xls'), 'altes Office-Format mit VBA wird gesperrt');
|
||||||
|
plugin_set_setting('virenscan', 'block_macros', '0');
|
||||||
|
$check($clean($mk('a', 'x'), 'angebot.docm'), 'Makros lassen sich erlauben');
|
||||||
|
plugin_set_setting('virenscan', 'block_macros', '1');
|
||||||
|
$check($infected($mk('a', "%PDF-1.7\n<< /OpenAction << /S /JavaScript /JS (app.alert(1)) >> >>"), 'info.pdf'), 'PDF mit automatischem JavaScript wird gesperrt');
|
||||||
|
$check($infected($mk('a', "%PDF-1.7\n<< /S /Launch /F (cmd.exe) >>"), 'info.pdf'), 'PDF mit /Launch wird gesperrt');
|
||||||
|
$check($infected($mk('a', '<html><script>location="http://x"</script></html>'), 'zahlung.html'), 'HTML mit Skript wird gesperrt');
|
||||||
|
$check($clean($mk('a', '<html><body>Hallo</body></html>'), 'seite.html'), 'HTML ohne Skript ist sauber');
|
||||||
|
$check($infected($mk('a', '<svg onload="alert(1)"></svg>'), 'logo.svg'), 'SVG mit Skript wird gesperrt');
|
||||||
|
$check($infected($mk('a', '{\\rtf1 {\\object {\\*\\objdata 0105}}}'), 'brief.rtf'), 'RTF mit eingebettetem Objekt wird gesperrt');
|
||||||
|
$check($clean($mk('a', 'Rar!'), 'archiv.rar'), 'RAR ist standardmäßig erlaubt');
|
||||||
|
plugin_set_setting('virenscan', 'block_unscannable', '1');
|
||||||
|
$check($infected($mk('a', 'Rar!'), 'archiv.rar'), 'RAR lässt sich sperren');
|
||||||
|
plugin_set_setting('virenscan', 'block_unscannable', '0');
|
||||||
|
$check($verdict($mk('a', virenscan_eicar()), 'test.txt')[1] === 'Eicar-Test-Signature', 'EICAR wird mit Namen erkannt');
|
||||||
|
plugin_set_setting('virenscan', 'analysis', '0');
|
||||||
|
$check($clean($mk('a', 'x'), 'start.bat') && $infected($mk('a', virenscan_eicar()), 'test.txt'), 'ohne Dateianalyse bleibt nur die Testdatei');
|
||||||
|
plugin_set_setting('virenscan', 'analysis', '1');
|
||||||
|
|
||||||
|
// ── Hash-Abfrage (VirusTotal), HTTP ersetzt ────────────────────────────────
|
||||||
|
$vtCalls = [];
|
||||||
|
$GLOBALS['virenscan_http'] = function (string $url, array $headers) use (&$vtCalls, &$vtAnswer): array {
|
||||||
|
$vtCalls[] = ['url' => $url, 'headers' => $headers];
|
||||||
|
return $vtAnswer;
|
||||||
|
};
|
||||||
|
$sha = str_repeat('a', 64);
|
||||||
|
$vtAnswer = ['status' => 200, 'body' => json_encode(['data' => ['attributes' => ['last_analysis_stats' => ['malicious' => 12, 'suspicious' => 0], 'popular_threat_classification' => ['suggested_threat_label' => 'trojan.agent']]]])];
|
||||||
|
$r1 = virenscan_vt_lookup($sha, 'k', 3);
|
||||||
|
$check($r1['result'] === 'bad' && str_contains((string) $r1['detail'], '12 Scanner') && str_contains((string) $r1['detail'], 'trojan.agent'), 'VirusTotal: bekannte Schadsoftware wird erkannt');
|
||||||
|
$check(str_ends_with($vtCalls[0]['url'], '/files/' . $sha) && in_array('x-apikey: k', $vtCalls[0]['headers'], true), 'Abfrage enthält nur den Hash und den Schlüssel');
|
||||||
|
$vtAnswer = ['status' => 200, 'body' => json_encode(['data' => ['attributes' => ['last_analysis_stats' => ['malicious' => 1]]]])];
|
||||||
|
$check(virenscan_vt_lookup($sha, 'k', 3)['result'] === 'clean', 'unter der Schwelle gilt als sauber');
|
||||||
|
$vtAnswer = ['status' => 404, 'body' => ''];
|
||||||
|
$check(virenscan_vt_lookup($sha, 'k', 3)['result'] === 'unknown', '404 = Datei unbekannt');
|
||||||
|
$vtAnswer = ['status' => 429, 'body' => ''];
|
||||||
|
$check(virenscan_vt_lookup($sha, 'k', 3)['result'] === 'limit', '429 = Abfragegrenze');
|
||||||
|
$vtAnswer = ['status' => 401, 'body' => ''];
|
||||||
|
$check(virenscan_vt_lookup($sha, 'k', 3)['result'] === 'key', '401 = Schlüssel ungültig');
|
||||||
|
|
||||||
|
// Hintergrundlauf: sauberer Anhang wird nachträglich gesperrt, wenn VirusTotal ihn kennt
|
||||||
|
plugin_set_setting('virenscan', 'vt_key', encrypt_secret(str_repeat('b', 64)));
|
||||||
|
$known = $attach('rechnung.pdf', "%PDF-1.4\nBekannte Schadsoftware " . bin2hex(random_bytes(4)));
|
||||||
|
$vtAnswer = ['status' => 200, 'body' => json_encode(['data' => ['attributes' => ['last_analysis_stats' => ['malicious' => 20]]]])];
|
||||||
|
$vtCalls = [];
|
||||||
|
virenscan_tick($pdo);
|
||||||
|
$row = $pdo->query("SELECT status, quarantined, rep, sha256 FROM plugin_virenscan_files WHERE attachment_id = {$known}")->fetch();
|
||||||
|
$check($row && $row['status'] === 'infected' && (int) $row['quarantined'] === 1 && $row['rep'] === 'bad' && strlen((string) $row['sha256']) === 64, 'Hintergrundlauf sperrt eine bei VirusTotal bekannte Datei: ' . json_encode($row));
|
||||||
|
$check($pdo->query("SELECT stored_name FROM ticket_attachments WHERE id = {$known}")->fetchColumn() === 'gesperrt', 'Anhang ist im Ticket gesperrt');
|
||||||
|
$unknownAtt = $attach('neu.pdf', "%PDF-1.4\nUnbekannt " . bin2hex(random_bytes(4)));
|
||||||
|
$vtAnswer = ['status' => 404, 'body' => ''];
|
||||||
|
virenscan_tick($pdo);
|
||||||
|
$check($pdo->query("SELECT rep FROM plugin_virenscan_files WHERE attachment_id = {$unknownAtt}")->fetchColumn() === 'unknown' && $pdo->query("SELECT quarantined FROM plugin_virenscan_files WHERE attachment_id = {$unknownAtt}")->fetchColumn() == 0, 'unbekannte Datei bleibt freigegeben und ist als geprüft vermerkt');
|
||||||
|
$before = count($vtCalls);
|
||||||
|
virenscan_tick($pdo);
|
||||||
|
$check(count($vtCalls) === $before, 'bereits abgefragte Dateien werden nicht noch einmal abgefragt');
|
||||||
|
$lim = $attach('limit.pdf', "%PDF-1.4\nLimit " . bin2hex(random_bytes(4)));
|
||||||
|
$vtAnswer = ['status' => 429, 'body' => ''];
|
||||||
|
virenscan_tick($pdo);
|
||||||
|
$check($pdo->query("SELECT rep FROM plugin_virenscan_files WHERE attachment_id = {$lim}")->fetchColumn() === null, 'bei Abfragegrenze bleibt die Datei für den nächsten Lauf offen');
|
||||||
|
$vtAnswer = ['status' => 401, 'body' => ''];
|
||||||
|
virenscan_tick($pdo);
|
||||||
|
$check(str_contains((string) plugin_setting('virenscan', 'vt_error', ''), 'abgelehnt'), 'ungültiger Schlüssel wird auf der Seite gemeldet');
|
||||||
|
plugin_set_setting('virenscan', 'vt_key', '');
|
||||||
|
$vtCalls = [];
|
||||||
|
virenscan_reputation_tick($pdo);
|
||||||
|
$check($vtCalls === [], 'ohne Schlüssel wird nichts abgefragt');
|
||||||
|
unset($GLOBALS['virenscan_http']);
|
||||||
|
foreach (glob($tmpDir . '/*') ?: [] as $f) @unlink($f);
|
||||||
|
@rmdir($tmpDir);
|
||||||
|
|
||||||
|
// Selbstheilung: fehlen nach einem Update die Spalten (ältere DeskHub-Dateien), holt das Plugin die Änderung selbst nach
|
||||||
|
$pdo->exec('ALTER TABLE plugin_virenscan_files DROP COLUMN rep');
|
||||||
|
$pdo->exec('ALTER TABLE plugin_virenscan_files DROP COLUMN sha256');
|
||||||
|
$pdo->exec("DELETE FROM schema_migrations WHERE migration LIKE 'plugin_virenscan_002%'");
|
||||||
|
virenscan_ensure_schema(true);
|
||||||
|
$vsCols = array_column($pdo->query('PRAGMA table_info(plugin_virenscan_files)')->fetchAll(), 'name');
|
||||||
|
$check(in_array('rep', $vsCols, true) && in_array('sha256', $vsCols, true), 'Selbstheilung: die fehlenden Spalten werden nachgeholt');
|
||||||
|
// ── Reiter der Seite: Übersicht, Quarantäne, Geprüft, Einstellungen ───────
|
||||||
|
$pagePath = $pluginDir . '/virenscan/pages/virenscan.php';
|
||||||
|
$adminStaff = ['id' => 1, 'role' => 'admin', 'name' => 'Chef'];
|
||||||
|
$render = function (string $tab, array $get = [], ?array $who = null) use ($pdo, $adminStaff, $pagePath): string {
|
||||||
|
$_GET = ($tab === '' ? [] : ['tab' => $tab]) + $get;
|
||||||
|
$_SERVER['REQUEST_METHOD'] = 'GET';
|
||||||
|
$staff = $who ?? $adminStaff;
|
||||||
|
ob_start();
|
||||||
|
include $pagePath;
|
||||||
|
return (string) ob_get_clean();
|
||||||
|
};
|
||||||
|
$pdo->exec('DELETE FROM plugin_virenscan_files');
|
||||||
|
$ins = $pdo->prepare('INSERT INTO plugin_virenscan_files (attachment_id, ticket_id, original_name, stored_name, status, signature, message, quarantined, scanned_at) VALUES (?,?,?,?,?,?,?,?,?)');
|
||||||
|
for ($i = 1; $i <= 120; $i++) $ins->execute([9000 + $i, null, "datei-{$i}.pdf", "s{$i}", 'clean', null, 'Dateianalyse', 0, date('Y-m-d H:i:s', time() - $i * 60)]);
|
||||||
|
$ins->execute([9500, null, 'Rechnung.pdf.exe', 'q1', 'infected', 'Dateianalyse: Gefährlicher Dateityp (.exe)', null, 1, db_now()]);
|
||||||
|
$ins->execute([9501, null, 'fehler.zip', 'q2', 'error', null, 'Zeitüberschreitung', 0, db_now()]);
|
||||||
|
|
||||||
|
$home = $render('');
|
||||||
|
$check(str_contains($home, '<h3>Zustand</h3>') && str_contains($home, 'Prüfung testen (EICAR-Testdatei)') && !str_contains($home, 'Geprüfte Dateien') && !str_contains($home, 'Dateianalyse (braucht nichts auf dem Server)'), 'Standard-Reiter „Übersicht“: Zustand und Testknöpfe, aber weder Liste noch Einstellungen');
|
||||||
|
$check(str_contains($home, 'tab=quarantaene') && str_contains($home, 'tab=geprueft') && str_contains($home, 'tab=einstellungen') && str_contains($home, 'tab=uebersicht'), 'Reiterleiste mit allen vier Reitern');
|
||||||
|
$check(preg_match('#Quarantäne <span[^>]*>1</span>#', $home) === 1, 'der Reiter „Quarantäne“ zeigt die Anzahl (1)');
|
||||||
|
$check(str_contains($home, '1 Datei(en) in Quarantäne prüfen'), 'die Übersicht verweist auf die Quarantäne');
|
||||||
|
$quar = $render('quarantaene');
|
||||||
|
$check(str_contains($quar, 'Rechnung.pdf.exe') && str_contains($quar, 'Freigeben') && str_contains($quar, 'Löschen') && !str_contains($quar, 'datei-1.pdf') && !str_contains($quar, '<h3>Zustand</h3>'), 'Reiter „Quarantäne“: nur gesperrte Dateien mit Freigeben und Löschen');
|
||||||
|
$pdo->exec('UPDATE plugin_virenscan_files SET quarantined = 0');
|
||||||
|
$check(str_contains($render('quarantaene'), 'Keine gesperrten Dateien'), 'leere Quarantäne zeigt einen ruhigen Hinweis');
|
||||||
|
$pdo->exec("UPDATE plugin_virenscan_files SET quarantined = 1 WHERE attachment_id = 9500");
|
||||||
|
$p1 = $render('geprueft');
|
||||||
|
$check(str_contains($p1, 'Geprüfte Dateien') && substr_count($p1, 'datei-') === 48 && substr_count($p1, '<tr>') === 51 && str_contains($p1, 'Seite 1 von 3') && str_contains($p1, 'Ältere →') && !str_contains($p1, '← Neuere'), 'Reiter „Geprüft“: 50 Einträge pro Seite, Seite 1 von 3');
|
||||||
|
$p3 = $render('geprueft', ['seite' => '3']);
|
||||||
|
$check(str_contains($p3, 'Seite 3 von 3') && str_contains($p3, '← Neuere') && !str_contains($p3, 'Ältere →'), 'letzte Seite hat nur „Neuere“');
|
||||||
|
$check(str_contains($render('geprueft', ['seite' => '99']), 'Seite 3 von 3'), 'zu große Seitenzahl wird auf die letzte begrenzt');
|
||||||
|
$inf = $render('geprueft', ['status' => 'infected']);
|
||||||
|
$check(str_contains($inf, 'Rechnung.pdf.exe') && !str_contains($inf, 'datei-1.pdf') && !str_contains($inf, 'fehler.zip') && str_contains($inf, 'mit Ergebnis „Verseucht“'), 'Filter nach Ergebnis (Verseucht)');
|
||||||
|
$check(str_contains($render('geprueft', ['status' => 'unsinn']), '(122)'), 'unbekannter Filter wird ignoriert (alle 122)');
|
||||||
|
$set = $render('einstellungen');
|
||||||
|
$check(str_contains($set, 'Dateianalyse (braucht nichts auf dem Server)') && str_contains($set, 'Hash-Abfrage bei VirusTotal') && str_contains($set, 'name="action" value="save"') && !str_contains($set, 'Geprüfte Dateien'), 'Reiter „Einstellungen“: nur das Formular');
|
||||||
|
$check(str_contains($render('gibt-es-nicht'), '<h3>Zustand</h3>'), 'unbekannter Reiter fällt auf die Übersicht zurück');
|
||||||
|
$check(str_contains($render('einstellungen', [], ['id' => 2, 'role' => 'agent', 'name' => 'Agent']), 'nur für Administratoren') && !str_contains($render('einstellungen', [], ['id' => 2, 'role' => 'agent', 'name' => 'Agent']), 'name="action" value="save"'), 'Mitarbeiter ohne Adminrecht sehen weder Reiter noch Formular');
|
||||||
|
$check(str_contains(virenscan_tab_url('geprueft', ['status' => 'clean', 'seite' => 2]), 'tab=geprueft&status=clean&seite=2') && !str_contains(virenscan_tab_url('geprueft', ['seite' => 1]), 'seite'), 'Adressen der Reiter: Seite 1 und leere Filter werden weggelassen');
|
||||||
|
|
||||||
|
// ── Aufräumen ──────────────────────────────────────────────────────────────
|
||||||
|
foreach ($created as $c) { @unlink($uploads . '/' . $c); @unlink($uploads . '/quarantaene/' . $c); }
|
||||||
|
foreach (['.htaccess', 'index.html'] as $x) @unlink($uploads . '/quarantaene/' . $x);
|
||||||
|
@rmdir($uploads . '/quarantaene');
|
||||||
|
foreach (scandir($pluginDir) ?: [] as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
|
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
|
||||||
|
}
|
||||||
|
@rmdir($pluginDir);
|
||||||
|
@unlink($zipPath);
|
||||||
|
|
||||||
|
if ($failures) {
|
||||||
|
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
echo 'Virenscan plugin checks passed.' . "\n";
|
||||||
@@ -75,6 +75,8 @@ $check(site_publish('theme', $themeZip('1.1.0'), ['slug' => 'nordlicht', 'name'
|
|||||||
|
|
||||||
$bad = site_publish('plugin', $makeZip(['plugin.json' => '{"slug":"x1","name":"X","version":"1.0.0","permissions":["alles"]}', 'plugin.php' => '<?php']));
|
$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(!$bad['ok'] && str_contains($bad['error'], 'unbekannte Berechtigung'), 'unbekannte Berechtigung wird abgelehnt');
|
||||||
|
$future = site_inspect_package('plugin', $makeZip(['plugin.json' => '{"slug":"x2","name":"X","version":"1.0.0","permissions":["api.public","neu.spaeter"]}', 'plugin.php' => '<?php']));
|
||||||
|
$check($future['ok'], 'Berechtigungen in der Form "bereich.name" blockieren die Webseite nicht: ' . json_encode($future));
|
||||||
$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":"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":"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(['plugin.json' => '{"slug":"exe1","name":"X","version":"1.0.0"}', 'a.exe' => 'MZ']))['ok'], 'unzulässiger Dateityp im Plugin wird abgelehnt');
|
||||||
@@ -90,6 +92,13 @@ $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($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');
|
$check(count($themeCatalog['themes']) === 1 && $themeCatalog['themes'][0]['version'] === '1.1.0', 'Theme-Katalog enthält nur die neueste Version');
|
||||||
|
|
||||||
|
// Empfehlung: im Katalog markiert, in DeskHub zuerst
|
||||||
|
$check(!isset($pluginCatalog['plugins'][0]['featured']), 'ohne Markierung kein Feld „featured“');
|
||||||
|
$firstId = (int) site_item('plugin', $pluginCatalog['plugins'][0]['slug'])['id'];
|
||||||
|
$check(site_set_featured($firstId, true) && site_catalog('plugin')['plugins'][0]['featured'] === true, 'Empfehlung steht im Katalog');
|
||||||
|
$check(!site_set_featured(99999, true), 'unbekannter Eintrag');
|
||||||
|
$pluginCatalog = site_catalog('plugin');
|
||||||
|
|
||||||
$files = [];
|
$files = [];
|
||||||
foreach (['plugin' => $pluginCatalog['plugins'], 'theme' => $themeCatalog['themes']] as $t => $list) {
|
foreach (['plugin' => $pluginCatalog['plugins'], 'theme' => $themeCatalog['themes']] as $t => $list) {
|
||||||
foreach ($list as $e) {
|
foreach ($list as $e) {
|
||||||
@@ -110,6 +119,7 @@ set_setting('plugin_require_signature', '1');
|
|||||||
|
|
||||||
$cat = plugin_catalog_fetch();
|
$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');
|
$check($cat['ok'] && count($cat['plugins']) === 1 && $cat['plugins'][0]['permissions'] === ['hooks.cron', 'ui.menu'], 'DeskHub liest den Plugin-Katalog der Webseite');
|
||||||
|
$check($cat['plugins'][0]['featured'] === true, 'DeskHub übernimmt die Empfehlung aus dem Katalog');
|
||||||
$r = plugin_catalog_install($pdo, 'tagesbericht');
|
$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($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');
|
$check(is_file($tmp . '/plugins/tagesbericht/plugin.php'), 'Plugin-Dateien liegen im Plugin-Ordner');
|
||||||
@@ -230,6 +240,34 @@ clearstatcache();
|
|||||||
$check(isset($deletedPackage) && !is_file($deletedPackage), 'die Paketdatei ist nach dem Löschen entfernt');
|
$check(isset($deletedPackage) && !is_file($deletedPackage), 'die Paketdatei ist nach dem Löschen entfernt');
|
||||||
|
|
||||||
// Aufräumen
|
// Aufräumen
|
||||||
|
// ── Sprachpakete im Katalog ─────────────────────────────────────────────────
|
||||||
|
$langPkg = fn(array $lang) => $makeZip([
|
||||||
|
'plugin.json' => json_encode(['slug' => 'lang-xx', 'name' => 'Testsprache', 'version' => '1.0.0', 'entry' => 'plugin.php', 'permissions' => [], 'language' => $lang]),
|
||||||
|
'plugin.php' => "<?php\n",
|
||||||
|
'flag.svg' => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2"><rect width="3" height="2"/></svg>',
|
||||||
|
'strings.json' => '{"Neues Ticket":"New ticket"}',
|
||||||
|
]);
|
||||||
|
$bad = site_publish('plugin', $langPkg(['code' => 'Englisch!', 'name' => 'X']));
|
||||||
|
$check(!$bad['ok'] && str_contains($bad['error'], 'language.code'), 'Sprachpaket mit ungültigem Sprachcode wird abgelehnt');
|
||||||
|
$okl = site_publish('plugin', $langPkg(['code' => 'xx', 'name' => 'Testsprache']));
|
||||||
|
$check($okl['ok'] && $okl['signed'] === true, 'Sprachpaket lässt sich veröffentlichen (Flagge, Texte, keine Berechtigungen)');
|
||||||
|
$entry = null;
|
||||||
|
foreach (site_catalog('plugin')['plugins'] as $e) if ($e['slug'] === 'lang-xx') $entry = $e;
|
||||||
|
$check($entry && $entry['language'] === ['code' => 'xx', 'name' => 'Testsprache'] && $entry['permissions'] === [], 'Katalog nennt Sprachcode und Namen');
|
||||||
|
$others = array_filter(site_catalog('plugin')['plugins'], fn($e) => $e['slug'] !== 'lang-xx');
|
||||||
|
$check(count($others) >= 1 && !array_filter($others, fn($e) => isset($e['language'])), 'normale Plugins haben kein Feld „language“');
|
||||||
|
$GLOBALS['deskhub_http_get'] = function (string $url, int $max) use ($pluginCatalog, $entry): array {
|
||||||
|
$c = site_catalog('plugin');
|
||||||
|
return str_contains($url, 'type=plugins') ? [200, json_encode($c), null] : [404, null, 'HTTP 404'];
|
||||||
|
};
|
||||||
|
$cat2 = plugin_catalog_fetch();
|
||||||
|
$lx = null;
|
||||||
|
foreach ($cat2['plugins'] ?? [] as $e) if ($e['slug'] === 'lang-xx') $lx = $e;
|
||||||
|
$check($lx && $lx['language'] === ['code' => 'xx', 'name' => 'Testsprache'], 'DeskHub übernimmt die Sprachangabe aus dem Katalog');
|
||||||
|
$nl = null;
|
||||||
|
foreach ($cat2['plugins'] ?? [] as $e) if ($e['slug'] !== 'lang-xx') $nl = $e;
|
||||||
|
$check($nl && $nl['language'] === null, 'DeskHub: normale Plugins ohne Sprachangabe');
|
||||||
|
|
||||||
$rm = function (string $d) use (&$rm) {
|
$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"); }
|
foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); }
|
||||||
@rmdir($d);
|
@rmdir($d);
|
||||||
|
|||||||
@@ -272,6 +272,38 @@ $c2 = plugin_catalog_cached();
|
|||||||
$check($c1['ok'] && $c2['ok'] && $fetches === 1, 'Katalog wird eine Stunde zwischengespeichert (ein Abruf für zwei Aufrufe)');
|
$check($c1['ok'] && $c2['ok'] && $fetches === 1, 'Katalog wird eine Stunde zwischengespeichert (ein Abruf für zwei Aufrufe)');
|
||||||
plugin_catalog_cached(true);
|
plugin_catalog_cached(true);
|
||||||
$check($fetches === 2, 'Aktualisieren erzwingt einen neuen Abruf');
|
$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');
|
$r = plugin_catalog_install($pdo, 'offiziell');
|
||||||
$check($r['ok'] && $r['signed'], 'Installation aus dem offiziellen Katalog gelingt: ' . json_encode($r));
|
$check($r['ok'] && $r['signed'], 'Installation aus dem offiziellen Katalog gelingt: ' . json_encode($r));
|
||||||
plugin_uninstall($pdo, 'offiziell');
|
plugin_uninstall($pdo, 'offiziell');
|
||||||
|
|||||||
@@ -171,6 +171,53 @@ try {
|
|||||||
$gc = (int) $gcBody;
|
$gc = (int) $gcBody;
|
||||||
@unlink($pj);
|
@unlink($pj);
|
||||||
$check($gc >= 30 * 60 + 600, 'PHP räumt Sitzungsdaten erst nach der Inaktivitätszeit plus Reserve auf (gc_maxlifetime ' . $gc . ' s)');
|
$check($gc >= 30 * 60 + 600, 'PHP räumt Sitzungsdaten erst nach der Inaktivitätszeit plus Reserve auf (gc_maxlifetime ' . $gc . ' s)');
|
||||||
|
|
||||||
|
// G: Anmeldeseite, deren Sitzung serverseitig aufgeräumt wurde (zu lange offen): freundliche Meldung statt „CSRF-Token ungültig“, danach klappt die Anmeldung
|
||||||
|
foreach ([['/staff/login.php', 'chef@test.de', 302], ['/login.php', 'kunde@test.de', 200]] as [$loginPath, $mail, $okStatus]) {
|
||||||
|
$gj = tempnam(sys_get_temp_dir(), 'ses');
|
||||||
|
[, $page] = $call('GET', $loginPath, $gj, $USER_NAV);
|
||||||
|
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $page, $tk);
|
||||||
|
@unlink($sessionFile($gj)); // der Server hat die Sitzung inzwischen gelöscht
|
||||||
|
[$s, $body] = $call('POST', $loginPath, $gj, $USER_NAV, ['csrf_token' => $tk[1] ?? '', 'email' => $mail, 'password' => 'Sehr-Sicher-1234']);
|
||||||
|
$check($s === 200 && str_contains($body, 'zu lange geöffnet') && !str_contains($body, 'CSRF-Token fehlt'), "{$loginPath}: nach aufgeräumter Sitzung kommt eine freundliche Meldung statt der CSRF-Fehlerseite (HTTP {$s})");
|
||||||
|
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $body, $tk2);
|
||||||
|
$check(($tk2[1] ?? '') !== '' && ($tk2[1] ?? '') !== ($tk[1] ?? ''), "{$loginPath}: die Meldung bringt ein neues, gültiges Formular mit");
|
||||||
|
if ($loginPath === '/staff/login.php') {
|
||||||
|
[$s2, , $loc2] = $call('POST', $loginPath, $gj, $USER_NAV, ['csrf_token' => $tk2[1] ?? '', 'email' => $mail, 'password' => 'Sehr-Sicher-1234']);
|
||||||
|
$check($s2 === 302 && str_contains($loc2, 'reports'), 'zweiter Versuch mit dem neuen Formular meldet an (HTTP ' . $s2 . ')');
|
||||||
|
}
|
||||||
|
@unlink($gj);
|
||||||
|
}
|
||||||
|
// G2: Bei anderen Formularen bleibt die Prüfung streng (kein Umgehen des Schutzes)
|
||||||
|
$gj = tempnam(sys_get_temp_dir(), 'ses');
|
||||||
|
[$s, $body] = $call('POST', '/staff/login.php', $gj, $USER_NAV, ['csrf_token' => 'erfunden', 'email' => 'chef@test.de', 'password' => 'Sehr-Sicher-1234']);
|
||||||
|
$check($s === 200 && str_contains($body, 'zu lange geöffnet'), 'ein falscher Token meldet nie an, auch nicht mit richtigem Passwort (HTTP ' . $s . ')');
|
||||||
|
[$s, $body] = $call('POST', '/staff/profile.php', $gj, $USER_NAV, ['csrf_token' => 'erfunden']);
|
||||||
|
$check(in_array($s, [302, 403], true), 'geschützte Formulare ohne gültigen Token werden weiter abgewiesen (HTTP ' . $s . ')');
|
||||||
|
@unlink($gj);
|
||||||
|
// H: Die Anmeldeseiten halten ihre Sitzung am Leben
|
||||||
|
foreach (['/staff/login.php', '/login.php'] as $loginPath) {
|
||||||
|
$gj = tempnam(sys_get_temp_dir(), 'ses');
|
||||||
|
[, $page] = $call('GET', $loginPath, $gj, $USER_NAV);
|
||||||
|
$check(str_contains($page, 'session_ping.php?check=1') && str_contains($page, '8 * 60 * 1000'), "{$loginPath}: ein kurzer Abruf alle 8 Minuten hält die Sitzung am Leben");
|
||||||
|
@unlink($gj);
|
||||||
|
}
|
||||||
|
// I: Die Live-Anzeige neuer Tickets verlängert die Sitzung nie und endet mit ihr
|
||||||
|
$jar = $login();
|
||||||
|
[$s, $body] = $call('GET', '/staff/live_tickets.php?since=0', $jar, $POLL);
|
||||||
|
$d = json_decode($body, true);
|
||||||
|
$check($s === 200 && isset($d['counts']['open']), 'Live-Anzeige liefert bei angemeldeter Sitzung die Zahlen (HTTP ' . $s . ')');
|
||||||
|
$setAge($jar, 29 * 60);
|
||||||
|
$call('GET', '/staff/live_tickets.php?since=0', $jar, $POLL);
|
||||||
|
$age = time() - (int) $lastActivity($jar);
|
||||||
|
$check($age >= 29 * 60 - 3, 'Live-Anzeige verlängert die Sitzung nicht (Alter ' . $age . ' s)');
|
||||||
|
$setAge($jar, 31 * 60);
|
||||||
|
[$s, $body] = $call('GET', '/staff/live_tickets.php?since=0', $jar, $POLL);
|
||||||
|
$check($s === 401 && str_contains($body, 'sessionExpired'), 'nach Ablauf der Sitzung antwortet die Live-Anzeige mit 401 (HTTP ' . $s . ')');
|
||||||
|
@unlink($jar);
|
||||||
|
[$s] = $call('GET', '/staff/live_tickets.php?since=0', $pj = tempnam(sys_get_temp_dir(), 'ses'), $POLL);
|
||||||
|
$check($s === 401, 'ohne Anmeldung antwortet die Live-Anzeige mit 401');
|
||||||
|
@unlink($pj);
|
||||||
} finally {
|
} finally {
|
||||||
if (isset($webProc) && is_resource($webProc)) { proc_terminate($webProc); proc_close($webProc); }
|
if (isset($webProc) && is_resource($webProc)) { proc_terminate($webProc); proc_close($webProc); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, dass die Anmeldeseite der Mitarbeiter themefähig ist: Kopf-, Fußzeile und Hintergrund kommen aus dem Stylesheet (Klassen und
|
||||||
|
* --staff-login-*-Variablen) statt aus fest eingebauten Farben, das bisherige Aussehen bleibt der Standard, und Kendo-Center nutzt es.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$root = dirname(__DIR__);
|
||||||
|
$failures = [];
|
||||||
|
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||||
|
if (!$ok) $failures[] = $what;
|
||||||
|
};
|
||||||
|
$php = (string) file_get_contents($root . '/staff/login.php');
|
||||||
|
$css = (string) file_get_contents($root . '/assets/css/style.css');
|
||||||
|
|
||||||
|
$check(str_contains($php, '<header class="staff-login-header">') && str_contains($php, '<footer class="staff-login-footer">') && str_contains($php, 'staff-login-title') && str_contains($php, 'staff-login-back'), 'Kopfzeile, Fußzeile, Titel und Link nutzen Klassen');
|
||||||
|
$check(!preg_match('/<(header|footer|body)[^>]*style="[^"]*(background:\s*(rgba|#)|color:)/', $php), 'keine fest eingebauten Farben mehr in Kopfzeile, Fußzeile und Body');
|
||||||
|
$check(preg_match('/<body class="login-page staff-login-page"(<\?php if \(\$banner\): \?> style="background-image:[^"]*"<\?php endif; \?>)?>/', $php) === 1, 'am Body bleibt nur das (veränderliche) Bannerbild als Inline-Stil');
|
||||||
|
$check(str_contains($css, '.staff-login-header') && str_contains($css, 'var(--staff-login-chrome, rgba(14,26,34,.95))') && str_contains($css, 'var(--staff-login-bg, #16202a)'), 'Stylesheet: Standardwerte entsprechen dem bisherigen Aussehen (Kopfzeile rgba(14,26,34,.95), Hintergrund #16202a)');
|
||||||
|
foreach (['--staff-login-chrome', '--staff-login-line', '--staff-login-chrome-ink', '--staff-login-chrome-muted', '--staff-login-chrome-faint', '--staff-login-bg'] as $v) {
|
||||||
|
$check(str_contains($css, 'var(' . $v), "Stylesheet nutzt die Variable {$v}");
|
||||||
|
}
|
||||||
|
// Das Staff-Layout der übrigen Seiten ist nicht betroffen
|
||||||
|
$check(!str_contains((string) file_get_contents($root . '/staff/_header.php'), 'staff-login'), 'der Mitarbeiter-Bereich nach der Anmeldung bleibt unverändert');
|
||||||
|
|
||||||
|
// Theme „Kendo-Center“ (liegt im Projektordner neben DeskHub-Webseite)
|
||||||
|
$zip = dirname($root) . '/theme/Kendo-Center.zip';
|
||||||
|
if (is_file($zip) && class_exists('ZipArchive')) {
|
||||||
|
$z = new ZipArchive();
|
||||||
|
$z->open($zip);
|
||||||
|
$theme = (string) $z->getFromName('theme.css');
|
||||||
|
$z->close();
|
||||||
|
$check(preg_match('/body\.staff-login-page\s*\{[^}]*--staff-login-chrome:\s*#0c2130[^}]*--staff-login-bg:|body\.staff-login-page\s*\{[^}]*--staff-login-bg:[^}]*--staff-login-chrome:\s*#0c2130/s', $theme) === 1, 'Kendo-Center: Kopf- und Fußzeile in der Farbe der Kopfzeile des Kundenportals (#0c2130)');
|
||||||
|
$check(str_contains($theme, '--staff-login-line: 3px solid var(--primary)') && str_contains($theme, '--staff-login-bg: #f0f5f5'), 'Kendo-Center: türkise Linie und Hintergrund passend zum Anmeldebereich');
|
||||||
|
$check(preg_match('/Version:\s*1\.0\.[2-9]/', $theme) === 1, 'Kendo-Center: Versionsnummer wurde erhöht (damit das Update erkannt wird)');
|
||||||
|
$check(substr_count($theme, '{') === substr_count($theme, '}'), 'Kendo-Center: Klammern im Stylesheet stimmen');
|
||||||
|
$check(!str_contains($theme, '8px 8px 0 rgba(22,43,58,.14)'), 'Kendo-Center: weicher Schatten an der Anmeldekarte');
|
||||||
|
}
|
||||||
|
if ($failures) {
|
||||||
|
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
echo 'Staff login theme checks passed.' . "\n";
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft die Dateinamen-Regeln beim Upload: gefährliche Endungen bleiben gesperrt, harmlose Namen mit Punkten
|
||||||
|
* (zum Beispiel „www.example.com.pdf“) werden nicht mehr abgelehnt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
putenv('DESKHUB_TESTING=1');
|
||||||
|
putenv('DESKHUB_SQLITE_PATH=:memory:');
|
||||||
|
require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
||||||
|
|
||||||
|
$failures = [];
|
||||||
|
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||||
|
if (!$ok) $failures[] = $what;
|
||||||
|
};
|
||||||
|
get_db();
|
||||||
|
set_setting('upload_allowed_extensions', 'png,jpg,jpeg,gif,pdf,txt,log,docx,xlsx,zip,doc,exe,php');
|
||||||
|
|
||||||
|
$tmp = tempnam(sys_get_temp_dir(), 'up');
|
||||||
|
file_put_contents($tmp, 'harmlos');
|
||||||
|
$try = fn(string $name): string => (string) (handle_ticket_upload(['name' => $name, 'type' => '', 'tmp_name' => $tmp, 'error' => UPLOAD_ERR_OK, 'size' => 7])['error'] ?? '');
|
||||||
|
// Weiter als bis zum Verschieben kommt der Test nicht (move_uploaded_file geht nur bei echten Uploads): diese Meldung heißt „alle Prüfungen bestanden“
|
||||||
|
$passed = 'Die Datei konnte nicht gespeichert werden.';
|
||||||
|
|
||||||
|
foreach (['www.example.com.pdf', 'scan.js.2024.jpg', 'Bericht v1.2.final.pdf', 'foto.cmd.png', 'IMG_2024.07.01.JPG', 'notizen.xml.txt'] as $ok) {
|
||||||
|
$check($try($ok) === $passed, 'harmloser Name wird akzeptiert: ' . $ok . ' → ' . $try($ok));
|
||||||
|
}
|
||||||
|
foreach (['shell.php', 'start.exe', 'seite.html', 'bild.svg', 'x.php.exe', 'a.sh'] as $bad) {
|
||||||
|
$check(str_contains($try($bad), 'aus Sicherheitsgründen nicht erlaubt'), 'gefährliche Endung bleibt gesperrt: ' . $bad);
|
||||||
|
}
|
||||||
|
$check(str_contains($try('shell.php'), 'shell.php'), 'die Meldung nennt die Datei');
|
||||||
|
$check(str_contains($try('.htaccess'), 'Dateiname'), '.htaccess bleibt gesperrt');
|
||||||
|
$check(str_contains($try('datei.dwg'), 'nicht erlaubt'), 'nicht freigegebene Endung bleibt abgelehnt');
|
||||||
|
@unlink($tmp);
|
||||||
|
|
||||||
|
if ($failures) {
|
||||||
|
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
echo 'Upload name checks passed.' . "\n";
|
||||||
@@ -108,7 +108,7 @@ try {
|
|||||||
// ── Welche Seiten sieht wer? ───────────────────────────────────────────
|
// ── Welche Seiten sieht wer? ───────────────────────────────────────────
|
||||||
$keys = fn(string $t): array => array_column(($api('GET', '/api/staff/v1/web_pages.php', null, $t)[1]['pages'] ?? []), 'key');
|
$keys = fn(string $t): array => array_column(($api('GET', '/api/staff/v1/web_pages.php', null, $t)[1]['pages'] ?? []), 'key');
|
||||||
$kc = $keys($chef); $ka = $keys($agent); $kp = $keys($pfleger);
|
$kc = $keys($chef); $ka = $keys($agent); $kp = $keys($pfleger);
|
||||||
$check(count($kc) === 17 && in_array('settings', $kc, true) && in_array('backup', $kc, true) && in_array('assistant', $kc, true), 'Administrator sieht alle 17 Seiten');
|
$check(count($kc) === 19 && in_array('settings', $kc, true) && in_array('backup', $kc, true) && in_array('assistant', $kc, true), 'Administrator sieht alle 19 Seiten');
|
||||||
$check($ka === ['mentions', 'agent_directory'], 'Mitarbeiter ohne Rechte sehen nur Erwähnungen und Verzeichnis ' . json_encode($ka));
|
$check($ka === ['mentions', 'agent_directory'], 'Mitarbeiter ohne Rechte sehen nur Erwähnungen und Verzeichnis ' . json_encode($ka));
|
||||||
$check(in_array('assistant', $kp, true) && !in_array('settings', $kp, true) && !in_array('backup', $kp, true) && !in_array('plugins', $kp, true), 'Mit dem Recht „Assistent“ kommt genau diese Seite dazu, aber keine Systemseiten ' . json_encode($kp));
|
$check(in_array('assistant', $kp, true) && !in_array('settings', $kp, true) && !in_array('backup', $kp, true) && !in_array('plugins', $kp, true), 'Mit dem Recht „Assistent“ kommt genau diese Seite dazu, aber keine Systemseiten ' . json_encode($kp));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user