diff --git a/DeskHub-Webseite/tests/i18n.php b/DeskHub-Webseite/tests/i18n.php
new file mode 100644
index 00000000..e5ef8db8
--- /dev/null
+++ b/DeskHub-Webseite/tests/i18n.php
@@ -0,0 +1,159 @@
+open($path, ZipArchive::CREATE);
+ foreach ($files as $name => $content) $z->addFromString($name, $content);
+ $z->close();
+ return $path;
+};
+$flag = '';
+$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 Test' => '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' => " $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('
Neues Ticket
') === 'New ticket
', 'Text zwischen Tags');
+$check($t("\n Neues Ticket \n
") === "\n New ticket \n
", 'Leerraum um den Text bleibt, Leerraum im Text zählt nicht');
+$check($t('') === '', 'Attribute placeholder und title');
+$check($t('') === '', 'Beschriftung von Knöpfen');
+$check($t('') === '', 'Eingaben (value) bleiben unberührt');
+$check($t('') === '', 'Optionstext ja, Wert nein');
+$check($t('') === '', 'Inhalt von Textfeldern bleibt');
+$check($t('Unbekannter Text
') === 'Unbekannter Text
', 'ohne Übersetzung bleibt es deutsch');
+$check($t('Willkommen, Erika
') === 'Welcome, Erika
', 'Muster mit {}');
+$check($t('Technik · erstellt am 24.10.2026
') === 'Technik · created 24.10.2026
', 'Muster mit {1} und {2} (Reihenfolge)');
+$check($t('12 Treffer
') === '12 hits
' && $t('viele Treffer
') === 'viele Treffer
', 'Muster mit {#} nur für Zahlen');
+$check($t('Mit & Zeichen
') === 'With & signs
', 'Entities im Schlüssel und im Ergebnis');
+$check($t('Wirklich löschen?
') === 'Really delete?
', 'Fragezeichen');
+$check($t('') === '', 'confirm() in Ereignis-Attributen');
+$check($t('') === '', 'Textliterale in Skripten (nur genaue Treffer)');
+$check($t('') === '', 'Skript-Dateien bleiben');
+$check($t('') === '', 'Stile bleiben');
+$check($t('x') === 'x', 'lang-Attribut passt zur Sprache');
+$check($t('
') === '
', 'Bilder: alt ja, src nein');
+$check($t('Neues Ticket
') === 'New ticket
', 'Kommentare bleiben');
+$check($t('Neues Ticket
') === 'Neues Ticket
', 'Text mit Markup mittendrin wird nicht zerhackt (nur ganze Textstücke)');
+$check(i18n_translate_html('Neues Ticket
', 'de') === 'Neues Ticket
', 'Deutsch verändert nichts');
+
+// ── Sammelmodus ─────────────────────────────────────────────────────────────
+$seen = [];
+i18n_translate_html('Neues Ticket
Völlig unbekannter Satz hier
42
https://beispiel.dex', '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('Neues Ticket
') === 'New ticket
', '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, '
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('Neues Ticket erstellen
Willkommen, Erika
Meine Tickets – Test', 'en') === 'Create new ticket
Welcome, Erika
My tickets – Test', 'English übersetzt Kundenportal-Texte');
+$check(i18n_translate_html('Abteilungen | Abmelden', 'en') === 'Departments | Sign out', '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";
diff --git a/DeskHub-Webseite/tests/i18n_pages.php b/DeskHub-Webseite/tests/i18n_pages.php
new file mode 100644
index 00000000..b267acff
--- /dev/null
+++ b/DeskHub-Webseite/tests/i18n_pages.php
@@ -0,0 +1,172 @@
+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', ' 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, "&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, '') && 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, '') && !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, '') && 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, '') && 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, ''), '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";
diff --git a/DeskHub-Webseite/tests/live_tickets.php b/DeskHub-Webseite/tests/live_tickets.php
new file mode 100644
index 00000000..c984845e
--- /dev/null
+++ b/DeskHub-Webseite/tests/live_tickets.php
@@ -0,0 +1,120 @@
+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, " 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";
diff --git a/DeskHub-Webseite/tests/plugin_community_bot.php b/DeskHub-Webseite/tests/plugin_community_bot.php
new file mode 100644
index 00000000..6fa45815
--- /dev/null
+++ b/DeskHub-Webseite/tests/plugin_community_bot.php
@@ -0,0 +1,182 @@
+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, 'Bitte setzen Sie das Passwort zurück.
', 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'], '') && 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";
diff --git a/DeskHub-Webseite/tests/plugin_cron_job.php b/DeskHub-Webseite/tests/plugin_cron_job.php
new file mode 100644
index 00000000..0687d2b4
--- /dev/null
+++ b/DeskHub-Webseite/tests/plugin_cron_job.php
@@ -0,0 +1,93 @@
+ $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', "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";
diff --git a/DeskHub-Webseite/tests/plugin_datenschutz.php b/DeskHub-Webseite/tests/plugin_datenschutz.php
new file mode 100644
index 00000000..a5ef9f4f
--- /dev/null
+++ b/DeskHub-Webseite/tests/plugin_datenschutz.php
@@ -0,0 +1,274 @@
+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, 'Regeln
') && !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, 'Regeln
'), '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, 'Regeln
'), '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'), 'Regeln
'), '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";
diff --git a/DeskHub-Webseite/tests/plugin_error_mail.php b/DeskHub-Webseite/tests/plugin_error_mail.php
new file mode 100644
index 00000000..484279e2
--- /dev/null
+++ b/DeskHub-Webseite/tests/plugin_error_mail.php
@@ -0,0 +1,78 @@
+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', "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";
diff --git a/DeskHub-Webseite/tests/plugin_rundmail.php b/DeskHub-Webseite/tests/plugin_rundmail.php
new file mode 100644
index 00000000..8e455127
--- /dev/null
+++ b/DeskHub-Webseite/tests/plugin_rundmail.php
@@ -0,0 +1,187 @@
+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},\nfett"], 'Erika ', null, true);
+$html = plugin_filter('ticket.view.sidebar', '', ['ticketId' => 1, 'ticket' => [], 'staff' => $ben]);
+$check(str_contains($html, '<script>') && !str_contains($html, '', '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, ''), 'zahlung.html'), 'HTML mit Skript wird gesperrt');
+$check($clean($mk('a', 'Hallo'), 'seite.html'), 'HTML ohne Skript ist sauber');
+$check($infected($mk('a', ''), '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, 'Zustand
') && 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 ]*>1#', $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, 'Zustand
'), '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, '') === 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'), 'Zustand
'), '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";
diff --git a/DeskHub-Webseite/tests/plugin_website.php b/DeskHub-Webseite/tests/plugin_website.php
index 5da40d39..0bfdfcf7 100644
--- a/DeskHub-Webseite/tests/plugin_website.php
+++ b/DeskHub-Webseite/tests/plugin_website.php
@@ -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' => ' '{"slug":"x2","name":"X","version":"1.0.0","permissions":["api.public","neu.spaeter"]}', 'plugin.php' => ' '{"slug":"core","name":"X","version":"1.0.0"}', 'plugin.php' => ' '{"slug":"evil","name":"X","version":"1.0.0"}', '../evil.php' => ' '{"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($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 = [];
foreach (['plugin' => $pluginCatalog['plugins'], 'theme' => $themeCatalog['themes']] as $t => $list) {
foreach ($list as $e) {
@@ -110,6 +119,7 @@ set_setting('plugin_require_signature', '1');
$cat = plugin_catalog_fetch();
$check($cat['ok'] && count($cat['plugins']) === 1 && $cat['plugins'][0]['permissions'] === ['hooks.cron', 'ui.menu'], 'DeskHub liest den Plugin-Katalog der Webseite');
+$check($cat['plugins'][0]['featured'] === true, 'DeskHub übernimmt die Empfehlung aus dem Katalog');
$r = plugin_catalog_install($pdo, 'tagesbericht');
$check($r['ok'] && $r['signed'] === true, 'DeskHub installiert das Plugin mit geprüfter Signatur („Signatur erforderlich“ an): ' . json_encode($r));
$check(is_file($tmp . '/plugins/tagesbericht/plugin.php'), 'Plugin-Dateien liegen im Plugin-Ordner');
@@ -230,6 +240,34 @@ clearstatcache();
$check(isset($deletedPackage) && !is_file($deletedPackage), 'die Paketdatei ist nach dem Löschen entfernt');
// 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' => " '',
+ '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) {
foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); }
@rmdir($d);
diff --git a/DeskHub-Webseite/tests/plugins.php b/DeskHub-Webseite/tests/plugins.php
index b3be060b..76672137 100644
--- a/DeskHub-Webseite/tests/plugins.php
+++ b/DeskHub-Webseite/tests/plugins.php
@@ -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)');
plugin_catalog_cached(true);
$check($fetches === 2, 'Aktualisieren erzwingt einen neuen Abruf');
+// Zwischenspeicher ist kurz (5 Minuten), die Plugin-Seite verlangt höchstens eine Minute Alter, Serverausfall zeigt den letzten Stand
+$setAge = function (int $seconds): void {
+ $c = json_decode((string) get_setting('plugin_catalog_cache', ''), true);
+ $c['at'] = time() - $seconds;
+ set_setting('plugin_catalog_cache', json_encode($c));
+};
+$fetches0 = $fetches;
+$setAge(200);
+plugin_catalog_cached();
+$check($fetches === $fetches0, 'Katalog ist nach 3 Minuten noch frisch genug (kein Abruf)');
+plugin_catalog_cached(false, 60);
+$check($fetches === $fetches0 + 1, 'die Plugin-Seite verlangt höchstens eine Minute Alter: neuer Abruf beim Öffnen');
+$setAge(400);
+plugin_catalog_cached();
+$check($fetches === $fetches0 + 2, 'nach über 5 Minuten wird der Katalog von selbst neu geladen (kein Klick auf „Katalog aktualisieren“ nötig)');
+$setAge(400);
+$GLOBALS['deskhub_http_get'] = function (string $url, int $max) use (&$fetches): array { $fetches++; return [0, null, 'Verbindung fehlgeschlagen']; };
+$stale = plugin_catalog_cached();
+$check($stale['ok'] && !empty($stale['stale']) && count($stale['plugins']) === 2 && $fetches === $fetches0 + 3, 'Server nicht erreichbar: die Seite zeigt den letzten Stand (stale)');
+plugin_catalog_cached();
+plugin_catalog_cached(false, 60);
+$check($fetches === $fetches0 + 3, 'danach wird zwei Minuten lang nicht erneut gefragt (die Seite wartet nicht bei jedem Aufruf)');
+$btn = plugin_catalog_cached(true);
+$check(!$btn['ok'] && $fetches === $fetches0 + 4, 'der Knopf „Katalog aktualisieren“ fragt trotzdem und meldet den Fehler');
+$GLOBALS['deskhub_http_get'] = function (string $url, int $max) use (&$offServed, $offPkg, &$fetches): array {
+ if (str_contains($url, 'catalog.php')) { $fetches++; return [200, json_encode($offServed), null]; }
+ return [200, file_get_contents($offPkg), null];
+};
+$setAge(400);
+set_setting('plugin_catalog_fail_at', '0');
+$ok = plugin_catalog_cached();
+$check($ok['ok'] && empty($ok['stale']), 'nach der Erholung des Servers wieder ein frischer Katalog');
$r = plugin_catalog_install($pdo, 'offiziell');
$check($r['ok'] && $r['signed'], 'Installation aus dem offiziellen Katalog gelingt: ' . json_encode($r));
plugin_uninstall($pdo, 'offiziell');
diff --git a/DeskHub-Webseite/tests/session_timeout.php b/DeskHub-Webseite/tests/session_timeout.php
index 0f949b49..97680909 100644
--- a/DeskHub-Webseite/tests/session_timeout.php
+++ b/DeskHub-Webseite/tests/session_timeout.php
@@ -171,6 +171,53 @@ try {
$gc = (int) $gcBody;
@unlink($pj);
$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 {
if (isset($webProc) && is_resource($webProc)) { proc_terminate($webProc); proc_close($webProc); }
}
diff --git a/DeskHub-Webseite/tests/staff_login_theme.php b/DeskHub-Webseite/tests/staff_login_theme.php
new file mode 100644
index 00000000..9091caa8
--- /dev/null
+++ b/DeskHub-Webseite/tests/staff_login_theme.php
@@ -0,0 +1,44 @@
+') && str_contains($php, '