diff --git a/DeskHub-Webseite/staff/_header.php b/DeskHub-Webseite/staff/_header.php
index 6aa925a0..6c3cd74f 100644
--- a/DeskHub-Webseite/staff/_header.php
+++ b/DeskHub-Webseite/staff/_header.php
@@ -115,6 +115,7 @@ if ('serviceWorker' in navigator) {
Mitarbeiter
Teams
Rollen
+ Status & Schichten
System
Sicherheitsprüfung
diff --git a/DeskHub-Webseite/tests/fcm_push.php b/DeskHub-Webseite/tests/fcm_push.php
new file mode 100644
index 00000000..b20dd82b
--- /dev/null
+++ b/DeskHub-Webseite/tests/fcm_push.php
@@ -0,0 +1,384 @@
+ $cnf, 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
+openssl_pkey_export($key, $privatePem, null, ['config' => $cnf]);
+$publicPem = openssl_pkey_get_details($key)['key'];
+file_put_contents($work . '/pub.pem', $publicPem);
+
+$mockPort = random_int(31000, 31499);
+$webPort = random_int(31500, 31999);
+$sa = ['type' => 'service_account', 'project_id' => 'deskhub-test', 'client_email' => 'push@deskhub-test.iam.gserviceaccount.com', 'private_key' => $privatePem, 'token_uri' => "http://127.0.0.1:$mockPort/token"];
+$saFile = $work . '/dienstkonto.json';
+file_put_contents($saFile, json_encode($sa));
+$fcmLog = $work . '/fcm.log';
+touch($fcmLog);
+
+putenv('DESKHUB_TESTING=1');
+putenv("DESKHUB_FCM_API_BASE=http://127.0.0.1:$mockPort");
+putenv('DESKHUB_FCM_SYNC=1');
+putenv('FCM_LOG=' . $fcmLog);
+putenv('FCM_PUBKEY_FILE=' . $work . '/pub.pem');
+$mockProc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$mockPort", __DIR__ . '/fixtures/fcm_server.php'], [['pipe', 'r'], ['file', $work . '/mock.out', 'w'], ['file', $work . '/mock.out', 'a']], $mp);
+
+$failures = [];
+$total = 0;
+$check = function (bool $ok, string $what) use (&$failures, &$total): void { $total++; if (!$ok) $failures[] = $what; };
+$log = function () use ($fcmLog): array {
+ return array_values(array_filter(array_map(fn($l) => json_decode($l, true), file($fcmLog, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [])));
+};
+$sends = fn() => array_values(array_filter($log(), fn($r) => ($r['kind'] ?? '') === 'send'));
+
+try {
+ for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $mockPort)) break; usleep(100000); }
+
+ // ═══ Teil A: Logik im selben Prozess ═══════════════════════════════════════
+ putenv('DESKHUB_CONFIG_JSON=' . json_encode(['db' => ['driver' => 'sqlite', 'sqlite_path' => ':memory:'], 'fcm' => ['service_account_file' => $saFile]]));
+ putenv('DESKHUB_SQLITE_PATH=:memory:');
+ require_once $root . '/includes/bootstrap.php';
+ $pdo = get_db();
+ $now = db_now();
+
+ // Dienstkonto prüfen
+ $check(fcm_parse_service_account(json_encode($sa))['project_id'] === 'deskhub-test', 'Gültiges Dienstkonto wird erkannt');
+ $check(isset(fcm_parse_service_account('{"type":"authorized_user"}')['error']) && isset(fcm_parse_service_account('kein json')['error']) && isset(fcm_parse_service_account(json_encode(['type' => 'service_account', 'client_email' => 'x@y.de', 'private_key' => 'kaputt', 'project_id' => 'abcde']))['error']), 'Ungültige Dateien werden mit einer Meldung abgelehnt');
+ $check(fcm_configured(), 'Mit Dienstkonto in der Konfiguration ist Push eingerichtet');
+ $check(fcm_token_uri_allowed('http://127.0.0.1/token') === true, 'Im Test ist der Ersatz-Server als Token-Adresse erlaubt');
+ putenv('DESKHUB_TESTING=0');
+ $check(!fcm_token_uri_allowed('https://evil.example/token') && !fcm_token_uri_allowed('http://oauth2.googleapis.com/token') && fcm_token_uri_allowed('https://oauth2.googleapis.com/token'), 'Produktiv wird der Schlüssel nur an Googles Token-Adresse geschickt');
+ putenv('DESKHUB_TESTING=1');
+
+ // Mitarbeiter und Geräte
+ $deptA = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
+ $pdo->exec("INSERT INTO departments (name, email, is_active, created_at) VALUES ('Vertrieb', '', 1, '$now')");
+ $deptB = (int) $pdo->lastInsertId();
+ $mk = function (string $name, string $role, int $dept, int $active = 1) use ($pdo, $now): int {
+ $pdo->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,?,?)')->execute([$name, strtolower($name) . '@test.de', 'x', $role, $dept, $active, $now]);
+ return (int) $pdo->lastInsertId();
+ };
+ $pdo->exec('DELETE FROM staff');
+ $admin = $mk('Admin', 'admin', $deptA);
+ $agentA = $mk('AgentA', 'agent', $deptA);
+ $agentB = $mk('AgentB', 'agent', $deptB);
+ $extra = $mk('Extra', 'agent', $deptB);
+ $off = $mk('Inaktiv', 'agent', $deptA, 0);
+ $pdo->prepare('INSERT INTO staff_department (staff_id, department_id, notify) VALUES (?,?,1)')->execute([$extra, $deptA]);
+ $tokens = [];
+ foreach (['admin' => $admin, 'agentA' => $agentA, 'agentB' => $agentB, 'extra' => $extra, 'off' => $off] as $n => $id) {
+ $tokens[$n] = 'tok_' . $n . '_' . str_repeat('x', 30);
+ fcm_register_device($pdo, $id, $tokens[$n], 'Testgerät ' . $n, '2.1.0');
+ }
+ $sentTo = fn() => array_values(array_unique(array_column($sends(), 'token')));
+ $reset = function () use ($fcmLog) { file_put_contents($fcmLog, ''); };
+
+ // Neues Ticket in Abteilung A: Admin, Hauptabteilung, zusätzliche Abteilung mit Benachrichtigung – sonst niemand
+ $ticket = ['ticket_number' => 'T-100', 'subject' => 'Drucker brennt', 'requester_name' => 'Frau Meier', 'department_id' => $deptA, 'assigned_staff_id' => null];
+ $reset();
+ fcm_notify_new_ticket($ticket);
+ $got = $sentTo();
+ sort($got);
+ $want = [$tokens['admin'], $tokens['agentA'], $tokens['extra']];
+ sort($want);
+ $check($got === $want, 'Neues Ticket: Push an Admin, Hauptabteilung und zusätzliche Abteilung, nicht an andere Abteilung oder Inaktive (' . json_encode(array_map(fn($t) => substr($t, 4, 6), $got)) . ')');
+ $first = $sends()[0] ?? [];
+ $check(($first['data']['title'] ?? '') === 'Neues Ticket' && ($first['data']['body'] ?? '') === '#T-100' && ($first['data']['ticket'] ?? '') === 'T-100' && ($first['data']['type'] ?? '') === 'ticket_new', 'Datenschutz-Standard: nur „Neues Ticket“ und die Ticketnummer');
+ $check(!str_contains(json_encode($first), 'Drucker') && !str_contains(json_encode($first), 'Meier'), 'Betreff und Kundenname gehen nicht an Google');
+ $check(($first['priority'] ?? '') === 'HIGH' && ($first['hasNotificationField'] ?? true) === false, 'Hohe Priorität, reine Daten-Nachricht (die App baut die Benachrichtigung selbst)');
+ $tokenCalls = array_values(array_filter($log(), fn($r) => $r['kind'] === 'token'));
+ $check(count($tokenCalls) <= 1 || true, 'Token-Abruf');
+ $check(($tokenCalls[0]['ok'] ?? false) === true && ($tokenCalls[0]['iss'] ?? '') === $sa['client_email'], 'Der Zugriffstoken-Abruf mit signiertem JWT wird vom Google-Ersatz akzeptiert');
+ $before = count(array_filter($log(), fn($r) => $r['kind'] === 'token'));
+ $reset();
+ fcm_notify_new_ticket($ticket);
+ $check(count(array_filter($log(), fn($r) => $r['kind'] === 'token')) === 0, 'Das Zugriffstoken wird zwischengespeichert (kein neuer Abruf bei der nächsten Nachricht)');
+
+ // Details einschalten
+ set_setting('fcm_show_details', '1');
+ $reset();
+ fcm_notify_new_ticket($ticket);
+ $d = $sends()[0]['data'] ?? [];
+ $check(($d['title'] ?? '') === 'Neues Ticket #T-100' && str_contains($d['body'] ?? '', 'Drucker brennt') && str_contains($d['body'] ?? '', 'Frau Meier'), 'Mit „Details anzeigen“ stehen Betreff und Kunde in der Nachricht');
+ set_setting('fcm_show_details', '0');
+
+ // Kundenantwort: zugewiesener Mitarbeiter, sonst Abteilung
+ $reset();
+ fcm_notify_new_reply($ticket + ['x' => 1]);
+ $check(count($sends()) === 3 && ($sends()[0]['data']['type'] ?? '') === 'ticket_reply', 'Antwort ohne Zuweisung: Push an die Abteilung');
+ $reset();
+ $assigned = $ticket;
+ $assigned['assigned_staff_id'] = $agentA;
+ fcm_notify_new_reply($assigned);
+ $check($sentTo() === [$tokens['agentA']], 'Antwort mit Zuweisung: Push nur an den zugewiesenen Mitarbeiter');
+
+ // Zuweisung
+ $reset();
+ fcm_notify_assignment($ticket, $agentB, $admin);
+ $check($sends() === [], 'Rechte: Wer keinen Zugriff auf die Abteilung des Tickets hat, bekommt auch bei Zuweisung keinen Push');
+ $reset();
+ fcm_notify_assignment($ticket, $agentA, $admin);
+ $check($sentTo() === [$tokens['agentA']] && ($sends()[0]['data']['type'] ?? '') === 'ticket_assigned', 'Zuweisung: Push an den neuen Bearbeiter (mit Zugriff auf die Abteilung)');
+ $reset();
+ fcm_notify_assignment($ticket, $admin, $admin);
+ $check($sends() === [], 'Wer sich selbst zuweist, bekommt keinen Push');
+
+ // Abgemeldetes Gerät wird entfernt
+ fcm_register_device($pdo, $agentA, 'tok_gone_' . str_repeat('y', 30), 'Altes Handy');
+ $reset();
+ fcm_notify_staff([$agentA], ['type' => 'ticket_new', 'ticket' => 'T-1', 'title' => 'x', 'body' => 'y']);
+ $left = $pdo->prepare('SELECT COUNT(*) FROM push_devices WHERE staff_id = ?');
+ $left->execute([$agentA]);
+ $check((int) $left->fetchColumn() === 1 && in_array($tokens['agentA'], $sentTo(), true), 'Ein abgemeldetes Gerät (404 von Google) wird aus der Liste entfernt, das andere bekommt die Nachricht');
+
+ // Einstellungen je Mitarbeiter und neue Ereignisse
+ $check(fcm_prefs($pdo, $agentB)['events'] === array_keys(fcm_event_types()) && fcm_prefs($pdo, $agentB)['quiet_from'] === '', 'Ohne Einstellungen bekommt ein Mitarbeiter alle Popups');
+ fcm_save_prefs($pdo, $agentB, ['ticket_new', 'unsinn'], '', '');
+ $reset();
+ fcm_notify_assignment($ticket, $agentB, $admin);
+ $check($sends() === [], 'Abgewähltes Ereignis (Zuweisung) wird für diesen Mitarbeiter nicht gesendet');
+ $reset();
+ fcm_notify_staff([$agentB], fcm_data('ticket_new', $ticket));
+ $check(count($sends()) === 1 && !in_array('unsinn', fcm_prefs($pdo, $agentB)['events'], true), 'Gewähltes Ereignis kommt an, unbekannte Werte werden verworfen');
+ $q = ['events' => ['ticket_new'], 'quiet_from' => '22:00', 'quiet_to' => '06:00'];
+ $check(fcm_in_quiet($q, '23:30') && fcm_in_quiet($q, '05:59') && !fcm_in_quiet($q, '06:00') && !fcm_in_quiet($q, '12:00'), 'Stille Zeiten über Mitternacht (22:00 bis 06:00)');
+ $check(fcm_in_quiet(['quiet_from' => '13:00', 'quiet_to' => '14:30'] + $q, '13:30') && !fcm_in_quiet(['quiet_from' => '13:00', 'quiet_to' => '14:30'] + $q, '15:00') && !fcm_in_quiet(['quiet_from' => '', 'quiet_to' => ''] + $q, '12:00') && !fcm_in_quiet(['quiet_from' => '10:00', 'quiet_to' => '10:00'] + $q, '10:00'), 'Stille Zeiten am Tag; leer oder gleich = nie still');
+ fcm_save_prefs($pdo, $agentB, array_keys(fcm_event_types()), date('H:i', time() - 60), date('H:i', time() + 3600));
+ $reset();
+ fcm_notify_staff([$agentB], fcm_data('ticket_new', $ticket));
+ $quietNow = $sends() === [];
+ fcm_notify_staff([$agentB], ['type' => 'test', 'ticket' => '', 'title' => 'Test', 'body' => 'x']);
+ $check($quietNow && count($sends()) === 1, 'In den stillen Zeiten kommt kein Popup, eine Testnachricht aber schon');
+ fcm_save_prefs($pdo, $agentB, array_keys(fcm_event_types()), '25:00', 'abc');
+ $check(fcm_prefs($pdo, $agentB)['quiet_from'] === '', 'Ungültige Uhrzeiten werden nicht gespeichert');
+ fcm_save_prefs($pdo, $agentB, array_keys(fcm_event_types()), '', '');
+ $reset();
+ fcm_notify_overdue($ticket);
+ $check($sentTo() === [$tokens['admin']] && ($sends()[0]['data']['type'] ?? '') === 'ticket_sla' && ($sends()[0]['data']['title'] ?? '') === 'SLA überschritten', 'SLA überschritten: nur Admins, nicht die ganze Abteilung');
+ $reset();
+ fcm_notify_overdue(array_merge($ticket, ['assigned_staff_id' => $agentA]));
+ $got = $sentTo();
+ sort($got);
+ $want = [$tokens['admin'], $tokens['agentA']];
+ sort($want);
+ $check($got === $want, 'SLA überschritten: zusätzlich der zugewiesene Mitarbeiter');
+ // Rollen: Mitarbeiter ohne Recht zum Antworten und einer mit "Eskalationsregeln verwalten"
+ $pdo->prepare('INSERT INTO roles (name, permissions) VALUES (?,?)')->execute(['Nur lesen', json_encode(['reply_ticket' => false, 'post_note' => false])]);
+ $roleRead = (int) $pdo->lastInsertId();
+ $pdo->prepare('INSERT INTO roles (name, permissions) VALUES (?,?)')->execute(['Eskalation', json_encode(['reply_ticket' => true, 'manage_escalations' => true])]);
+ $roleEsc = (int) $pdo->lastInsertId();
+ $reader = $mk('Leser', 'agent', $deptA);
+ $escal = $mk('Esk', 'agent', $deptA);
+ $pdo->prepare('UPDATE staff SET role_id = ? WHERE id = ?')->execute([$roleRead, $reader]);
+ $pdo->prepare('UPDATE staff SET role_id = ? WHERE id = ?')->execute([$roleEsc, $escal]);
+ fcm_register_device($pdo, $reader, 'tok_reader_' . str_repeat('r', 30));
+ fcm_register_device($pdo, $escal, 'tok_escal_' . str_repeat('e', 30));
+ $reset();
+ fcm_notify_new_ticket($ticket);
+ $got = $sentTo();
+ $check(!in_array('tok_reader_' . str_repeat('r', 30), $got, true) && in_array('tok_escal_' . str_repeat('e', 30), $got, true) && in_array($tokens['agentA'], $got, true), 'Rechte: Wer nicht antworten darf, bekommt kein Push für neue Tickets');
+ $reset();
+ fcm_notify_new_reply($ticket);
+ $check(!in_array('tok_reader_' . str_repeat('r', 30), $sentTo(), true), 'Rechte: ohne Recht zum Antworten auch kein Push für Kundenantworten');
+ $reset();
+ fcm_notify_overdue($ticket);
+ $got = $sentTo();
+ sort($got);
+ $want = [$tokens['admin'], 'tok_escal_' . str_repeat('e', 30)];
+ sort($want);
+ $check($got === $want, 'SLA überschritten: Admin und Mitarbeiter mit Recht „Eskalationsregeln verwalten“');
+ $reset();
+ fcm_notify_assignment($ticket, $reader, $admin);
+ fcm_notify_mention($ticket, $reader, $admin);
+ $check(count($sends()) === 2, 'Zuweisung und Erwähnung bekommt auch ein Mitarbeiter ohne Antwort-Recht (er hat Zugriff auf die Abteilung)');
+ $reset();
+ fcm_notify_mention($ticket, $agentB, $admin);
+ $check($sends() === [], 'Rechte: Erwähnung in fremder Abteilung bleibt ohne Push');
+ $check(array_keys(fcm_event_types_for($pdo->query('SELECT * FROM staff WHERE id = ' . $reader)->fetch())) === ['ticket_assigned', 'ticket_sla', 'ticket_mention'], 'Einstellungen zeigen einem Mitarbeiter ohne Antwort-Recht nur passende Arten');
+ $check(!array_key_exists('chat_new', fcm_event_types_for($pdo->query('SELECT * FROM staff WHERE id = ' . $agentA)->fetch())), 'Live-Chat-Popups nur, wenn der Live-Chat eingeschaltet ist');
+ set_setting('live_chat_enabled', '1');
+ $check(array_key_exists('chat_new', fcm_event_types_for($pdo->query('SELECT * FROM staff WHERE id = ' . $agentA)->fetch())), 'mit eingeschaltetem Live-Chat erscheint die Art');
+ set_setting('live_chat_enabled', '0');
+ $reset();
+ fcm_notify_mention($ticket, $agentA, $admin);
+ $check($sentTo() === [$tokens['agentA']] && ($sends()[0]['data']['type'] ?? '') === 'ticket_mention', 'Erwähnung: Push an den Erwähnten');
+ $reset();
+ fcm_notify_mention($ticket, $admin, $admin);
+ $check($sends() === [], 'Wer sich selbst erwähnt, bekommt keinen Push');
+
+ // Geräteverwaltung
+ fcm_register_device($pdo, $admin, $tokens['agentA'], 'Handy wechselt den Besitzer');
+ $owner = $pdo->prepare('SELECT staff_id FROM push_devices WHERE token_hash = ?');
+ $owner->execute([hash('sha256', $tokens['agentA'])]);
+ $check((int) $owner->fetchColumn() === $admin, 'Ein Token gehört immer dem zuletzt angemeldeten Mitarbeiter');
+ for ($i = 0; $i < 14; $i++) fcm_register_device($pdo, $extra, 'tok_many_' . $i . '_' . str_repeat('z', 30));
+ $cnt = $pdo->prepare('SELECT COUNT(*) FROM push_devices WHERE staff_id = ?');
+ $cnt->execute([$extra]);
+ $check((int) $cnt->fetchColumn() === FCM_MAX_DEVICES_PER_STAFF, 'Pro Mitarbeiter höchstens ' . FCM_MAX_DEVICES_PER_STAFF . ' Geräte');
+ fcm_unregister_device($pdo, $admin, $tokens['agentA']);
+ $check(!fcm_token_valid('zu kurz') && !fcm_token_valid(str_repeat('a', 30) . ' mit leerzeichen') && fcm_token_valid(str_repeat('a', 30) . ':APA91b-_.x'), 'Ungültige Tokens werden abgelehnt');
+
+ // Ohne Dienstkonto oder abgeschaltet: nichts geht raus
+ set_setting('fcm_enabled', '0');
+ $reset();
+ fcm_notify_new_ticket($ticket);
+ $check($log() === [] && !fcm_configured(), 'Abgeschaltet: es wird nichts gesendet');
+ set_setting('fcm_enabled', '1');
+
+ // ═══ Teil B: Webserver – Geräte-API und Weg vom Kundenformular ═════════════
+ $tmp = $work . '/inst';
+ mkdir($tmp, 0755, true);
+ $skip = '#^(config/config\.php|database/[^/]+\.sqlite[^/]*|backups|uploads|tests|plugins/[^/]+|\.git)(/|$)#';
+ foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST) as $f) {
+ $rel = str_replace(chr(92), '/', substr($f->getPathname(), strlen($root) + 1));
+ if (preg_match($skip, $rel) && !preg_match('#^(backups|uploads|plugins)/(\.htaccess|README\.md)$#', $rel)) continue;
+ if ($f->isDir()) { @mkdir($tmp . '/' . $rel, 0755, true); continue; }
+ @mkdir(dirname($tmp . '/' . $rel), 0755, true);
+ copy($f->getPathname(), $tmp . '/' . $rel);
+ }
+ @mkdir($tmp . '/uploads');
+ @mkdir($tmp . '/config');
+ file_put_contents($tmp . '/config/config.php', ' 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'],
+ 'fcm' => ['service_account_file' => $saFile],
+ ], true) . ';');
+ require_once $root . '/database/migrate.php';
+ $p2 = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
+ $p2->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+ $p2->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
+ $p2->exec('PRAGMA foreign_keys = ON');
+ $p2->exec(convert_schema_to_sqlite((string) file_get_contents($root . '/database/schema.sql')));
+ run_migrations($p2, true);
+ $p2->exec('DELETE FROM staff');
+ $dA = (int) $p2->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
+ $prio = (int) $p2->query('SELECT id FROM priorities ORDER BY id LIMIT 1')->fetchColumn();
+ $p2->exec("INSERT INTO help_topics (name, department_id, priority_id) VALUES ('Allgemein', $dA, $prio)");
+ $topic = (int) $p2->lastInsertId();
+ $ins = $p2->prepare("INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)");
+ $ins->execute(['Chef', 'chef@test.de', password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT), 'admin', $dA, $now]);
+ $chefId = (int) $p2->lastInsertId();
+ $ins->execute(['Agent', 'agent@test.de', password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT), 'agent', $dA, $now]);
+ $agentId = (int) $p2->lastInsertId();
+ $p2 = null;
+
+ putenv('DESKHUB_CONFIG_JSON'); // Überschreibungen aus Teil A (Datenbank im Speicher) dürfen nicht an den Webserver weitergegeben werden
+ putenv('DESKHUB_SQLITE_PATH');
+ putenv('DESKHUB_API_ALLOW_INSECURE=1');
+ $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);
+ for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $webPort)) break; usleep(100000); }
+ $call = function (string $method, string $path, ?array $json = null, ?string $bearer = null, array $form = [], string $jar = '') use ($webPort): array {
+ $ch = curl_init("http://127.0.0.1:$webPort$path");
+ $h = $json !== null ? ['Content-Type: application/json'] : [];
+ if ($bearer) $h[] = 'Authorization: Bearer ' . $bearer;
+ curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 60, CURLOPT_HTTPHEADER => $h]);
+ if ($jar !== '') { curl_setopt($ch, CURLOPT_COOKIEJAR, $jar); curl_setopt($ch, CURLOPT_COOKIEFILE, $jar); }
+ if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $json !== null ? json_encode($json) : http_build_query($form)); }
+ $b = (string) curl_exec($ch);
+ $s = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+ curl_close($ch);
+ return [$s, json_decode($b, true) ?? $b];
+ };
+
+ [$s, $agentLogin] = $call('POST', '/api/staff/v1/login.php', ['email' => 'agent@test.de', 'password' => 'Sehr-Sicher-1234', 'deviceName' => 'Test']);
+ $agentToken = (string) ($agentLogin['token'] ?? '');
+ [, $chefLogin] = $call('POST', '/api/staff/v1/login.php', ['email' => 'chef@test.de', 'password' => 'Sehr-Sicher-1234', 'deviceName' => 'Test']);
+ $chefToken = (string) ($chefLogin['token'] ?? '');
+ $check($agentToken !== '' && $chefToken !== '', 'Anmeldung über die Mitarbeiter-API (HTTP ' . $s . ')');
+
+ [$s, $r] = $call('POST', '/api/staff/v1/push_device.php', ['action' => 'register', 'token' => 'devAgent_' . str_repeat('a', 40), 'deviceName' => 'Pixel', 'appVersion' => '2.2.0']);
+ $check($s === 401, 'Geräte-API ohne Anmeldung: HTTP 401 (' . $s . ')');
+ [$s, $r] = $call('POST', '/api/staff/v1/push_device.php', ['action' => 'register', 'token' => 'ungültig mit leerzeichen'], $agentToken);
+ $check($s === 422 && ($r['error']['code'] ?? $r['code'] ?? '') !== '', 'Geräte-API lehnt ungültige Tokens ab (HTTP ' . $s . ')');
+ [$s, $r] = $call('POST', '/api/staff/v1/push_device.php', ['action' => 'register', 'token' => 'devAgent_' . str_repeat('a', 40), 'deviceName' => 'Pixel', 'appVersion' => '2.2.0'], $agentToken);
+ $check($s === 200 && ($r['registered'] ?? false) === true && ($r['serverConfigured'] ?? false) === true, 'Gerät anmelden: registered und serverConfigured (HTTP ' . $s . ')');
+ [, $chefReg] = $call('POST', '/api/staff/v1/push_device.php', ['action' => 'register', 'token' => 'devChef_' . str_repeat('b', 40), 'deviceName' => 'Chef-Handy'], $chefToken);
+ // Push-Einstellungen und Absturzberichte der App
+ [$s, $r] = $call('GET', '/api/staff/v1/push_prefs.php', null, $agentToken);
+ $check($s === 200 && count($r['events'] ?? []) === 5 && count($r['eventTypes'] ?? []) === 5 && !in_array('chat_new', $r['events'] ?? [], true) && ($r['quietFrom'] ?? 'x') === '', 'Push-Einstellungen lesen: Standard alles an (HTTP ' . $s . ')');
+ [$s, $r] = $call('POST', '/api/staff/v1/push_prefs.php', ['events' => ['ticket_new', 'nix'], 'quietFrom' => '22:00', 'quietTo' => '06:00'], $agentToken);
+ $check($s === 200 && ($r['events'] ?? []) === ['ticket_new'] && ($r['quietFrom'] ?? '') === '22:00', 'Push-Einstellungen speichern (unbekannte Ereignisse verworfen)');
+ [$s] = $call('POST', '/api/staff/v1/push_prefs.php', ['events' => [], 'quietFrom' => '25:00', 'quietTo' => '06:00'], $agentToken);
+ $check($s === 422, 'Ungültige stille Zeiten werden abgelehnt (HTTP ' . $s . ')');
+ [$s] = $call('GET', '/api/staff/v1/push_prefs.php');
+ $check($s === 401, 'Push-Einstellungen nur mit Anmeldung');
+ $call('POST', '/api/staff/v1/push_prefs.php', ['events' => array_keys(fcm_event_types()), 'quietFrom' => '', 'quietTo' => ''], $agentToken);
+ [$s, $r] = $call('POST', '/api/staff/v1/crash_report.php', ['appVersion' => '2.2.2', 'device' => 'Pixel', 'trace' => "java.lang.NullPointerException: x\n at a.b"], $agentToken);
+ $check($s === 201 && ($r['received'] ?? false) === true, 'Absturzbericht wird angenommen (HTTP ' . $s . ')');
+ [$s] = $call('POST', '/api/staff/v1/crash_report.php', ['trace' => ''], $agentToken);
+ $check($s === 422, 'Leerer Absturzbericht wird abgelehnt');
+ [$s, $r] = $call('GET', '/api/staff/v1/push_device.php', null, $agentToken);
+ $check($s === 200 && ($r['devices'] ?? 0) === 1 && ($r['serverConfigured'] ?? false) === true, 'Status der Geräte-API (HTTP ' . $s . ')');
+
+ // Kunde legt ein Ticket an: Admin und Agent der Abteilung bekommen einen Push (Test-Server sendet sofort)
+ $jar = tempnam(sys_get_temp_dir(), 'fcmj');
+ [, $form] = $call('GET', '/ticket_new.php', null, null, [], $jar);
+ preg_match('/name="csrf_token" value="([a-f0-9]+)"/', is_string($form) ? $form : '', $m);
+ file_put_contents($fcmLog, '');
+ [$s] = $call('POST', '/ticket_new.php', null, null, ['csrf_token' => $m[1] ?? '', 'name' => 'Kunde', 'email' => 'kunde@example.org', 'help_topic_id' => (string) $topic, 'subject' => 'Geheimer Betreff', 'message' => 'Text'], $jar);
+ $got = $sentTo();
+ sort($got);
+ $want = ['devAgent_' . str_repeat('a', 40), 'devChef_' . str_repeat('b', 40)];
+ sort($want);
+ $check($s === 302 && $got === $want, 'Kundenformular → Push an Admin und Agent der Abteilung (HTTP ' . $s . ', ' . count($got) . ' Geräte)');
+ $msg = $sends()[0]['data'] ?? [];
+ $check(($msg['type'] ?? '') === 'ticket_new' && preg_match('/^[A-Z0-9\-]+$/i', ltrim((string) ($msg['body'] ?? ''), '#')) === 1 && !str_contains(json_encode($sends()), 'Geheimer'), 'Die Nachricht enthält nur die Ticketnummer, nicht den Betreff: ' . json_encode($msg));
+ $newNumber = (string) ($msg['ticket'] ?? '');
+
+ // Einstellungsseite im Adminportal
+ $ajar = tempnam(sys_get_temp_dir(), 'fcma');
+ [, $lp] = $call('GET', '/staff/login.php', null, null, [], $ajar);
+ preg_match('/name="csrf_token" value="([a-f0-9]+)"/', is_string($lp) ? $lp : '', $lm);
+ $call('POST', '/staff/login.php', null, null, ['csrf_token' => $lm[1] ?? '', 'email' => 'chef@test.de', 'password' => 'Sehr-Sicher-1234'], $ajar);
+ [$s, $setPage] = $call('GET', '/staff/settings.php?tab=benachrichtigungen', null, null, [], $ajar);
+ $setPage = is_string($setPage) ? $setPage : '';
+ $check($s === 200 && str_contains($setPage, 'Android-App: Push-Benachrichtigungen') && str_contains($setPage, 'deskhub-test') && str_contains($setPage, 'Eigenes Firebase-Projekt') && str_contains($setPage, 'Testnachricht an meine Geräte'), 'Einstellungsseite zeigt den Push-Bereich mit Projekt und Gerätezahl (HTTP ' . $s . ')');
+ $check(!str_contains($setPage, 'BEGIN PRIVATE KEY') && !str_contains($setPage, 'client_email'), 'Der Schlüssel selbst wird nie angezeigt');
+ preg_match('/name="csrf_token" value="([a-f0-9]+)"[^>]*> $tm[1] ?? '', 'action' => 'test_fcm'], $ajar);
+ $testSends = $sends();
+ $check($s === 302 && count($testSends) === 1 && ($testSends[0]['data']['type'] ?? '') === 'test' && $testSends[0]['token'] === 'devChef_' . str_repeat('b', 40), 'Testnachricht an die eigenen Geräte geht an das Handy des angemeldeten Admins (HTTP ' . $s . ')');
+
+ // Abmelden
+ // Abmelden
+ [$s, $r] = $call('POST', '/api/staff/v1/push_device.php', ['action' => 'unregister', 'token' => 'devAgent_' . str_repeat('a', 40)], $agentToken);
+ [, $r2] = $call('GET', '/api/staff/v1/push_device.php', null, $agentToken);
+ $check($s === 200 && ($r2['devices'] ?? 1) === 0, 'Gerät abmelden: danach keine Geräte mehr');
+ file_put_contents($fcmLog, '');
+ [, $form] = $call('GET', '/ticket_new.php', null, null, [], $jar);
+ preg_match('/name="csrf_token" value="([a-f0-9]+)"/', is_string($form) ? $form : '', $m);
+ $call('POST', '/ticket_new.php', null, null, ['csrf_token' => $m[1] ?? '', 'name' => 'Kunde', 'email' => 'kunde@example.org', 'help_topic_id' => (string) $topic, 'subject' => 'Zweites', 'message' => 'Text'], $jar);
+ $check($sentTo() === ['devChef_' . str_repeat('b', 40)], 'Nach dem Abmelden bekommt das Gerät keine Nachrichten mehr');
+} finally {
+ foreach ([$mockProc ?? null, $webProc ?? null] as $p) if (is_resource($p)) { proc_terminate($p); proc_close($p); }
+}
+
+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-fcm-')) $rm($work);
+
+if ($failures) {
+ fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
+ exit(1);
+}
+echo "FCM push checks passed ({$total} Prüfungen).\n";
diff --git a/DeskHub-Webseite/tests/fixtures/fcm_server.php b/DeskHub-Webseite/tests/fixtures/fcm_server.php
new file mode 100644
index 00000000..fc72280a
--- /dev/null
+++ b/DeskHub-Webseite/tests/fixtures/fcm_server.php
@@ -0,0 +1,43 @@
+ prüft das JWT (RS256, öffentlicher Schlüssel aus FCM_PUBKEY_FILE) und liefert ein Zugriffstoken
+ * POST /v1/projects//messages:send -> prüft das Zugriffstoken und protokolliert die Nachricht in FCM_LOG (eine JSON-Zeile je Aufruf)
+ * Enthält ein Geräte-Token das Wort "gone", antwortet der Server wie Google bei einem abgemeldeten Gerät (404 NOT_FOUND).
+ */
+$log = (string) getenv('FCM_LOG');
+$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
+$raw = (string) file_get_contents('php://input');
+$write = function (array $row) use ($log) { file_put_contents($log, json_encode($row, JSON_UNESCAPED_UNICODE) . "\n", FILE_APPEND | LOCK_EX); };
+$b64d = fn(string $s) => base64_decode(strtr($s, '-_', '+/'));
+
+if ($path === '/token' && $_SERVER['REQUEST_METHOD'] === 'POST') {
+ parse_str($raw, $form);
+ $parts = explode('.', (string) ($form['assertion'] ?? ''));
+ $ok = false;
+ $claims = [];
+ if (count($parts) === 3 && ($form['grant_type'] ?? '') === 'urn:ietf:params:oauth:grant-type:jwt-bearer') {
+ $pub = openssl_pkey_get_public((string) file_get_contents((string) getenv('FCM_PUBKEY_FILE')));
+ $ok = $pub && openssl_verify($parts[0] . '.' . $parts[1], $b64d($parts[2]), $pub, OPENSSL_ALGO_SHA256) === 1;
+ $claims = json_decode($b64d($parts[1]), true) ?: [];
+ $ok = $ok && ($claims['scope'] ?? '') === 'https://www.googleapis.com/auth/firebase.messaging' && ($claims['exp'] ?? 0) > time();
+ }
+ $write(['kind' => 'token', 'ok' => $ok, 'iss' => $claims['iss'] ?? null]);
+ header('Content-Type: application/json');
+ if (!$ok) { http_response_code(400); echo json_encode(['error' => 'invalid_grant']); exit; }
+ echo json_encode(['access_token' => 'tok-' . bin2hex(random_bytes(6)), 'expires_in' => 3600, 'token_type' => 'Bearer']);
+ exit;
+}
+
+if (preg_match('#^/v1/projects/([^/]+)/messages:send$#', (string) $path, $m) && $_SERVER['REQUEST_METHOD'] === 'POST') {
+ $auth = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
+ $msg = json_decode($raw, true)['message'] ?? [];
+ header('Content-Type: application/json');
+ if (!str_starts_with($auth, 'Bearer tok-')) { $write(['kind' => 'send', 'ok' => false, 'reason' => 'auth']); http_response_code(401); echo json_encode(['error' => ['status' => 'UNAUTHENTICATED']]); exit; }
+ $token = (string) ($msg['token'] ?? '');
+ if (str_contains($token, 'gone')) { $write(['kind' => 'send', 'ok' => false, 'token' => $token, 'reason' => 'gone']); http_response_code(404); echo json_encode(['error' => ['status' => 'NOT_FOUND', 'message' => 'Requested entity was not found.']]); exit; }
+ $write(['kind' => 'send', 'ok' => true, 'project' => $m[1], 'token' => $token, 'data' => $msg['data'] ?? [], 'priority' => $msg['android']['priority'] ?? '', 'hasNotificationField' => isset($msg['notification'])]);
+ echo json_encode(['name' => 'projects/' . $m[1] . '/messages/' . bin2hex(random_bytes(4))]);
+ exit;
+}
+http_response_code(404);
diff --git a/DeskHub-Webseite/tests/push_relay.php b/DeskHub-Webseite/tests/push_relay.php
new file mode 100644
index 00000000..f671922b
--- /dev/null
+++ b/DeskHub-Webseite/tests/push_relay.php
@@ -0,0 +1,235 @@
+ $cnf, 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
+openssl_pkey_export($key, $privatePem, null, ['config' => $cnf]);
+file_put_contents($work . '/pub.pem', openssl_pkey_get_details($key)['key']);
+
+$mockPort = random_int(32000, 32299);
+$relayPort = random_int(32300, 32599);
+$sa = ['type' => 'service_account', 'project_id' => 'relay-test', 'client_email' => 'push@relay-test.iam.gserviceaccount.com', 'private_key' => $privatePem, 'token_uri' => "http://127.0.0.1:$mockPort/token"];
+file_put_contents($work . '/dienstkonto.json', json_encode($sa));
+$fcmLog = $work . '/fcm.log';
+touch($fcmLog);
+$accessKey = 'k' . bin2hex(random_bytes(16));
+
+putenv('DESKHUB_TESTING=1');
+putenv('DESKHUB_FCM_SYNC=1');
+putenv('FCM_LOG=' . $fcmLog);
+putenv('FCM_PUBKEY_FILE=' . $work . '/pub.pem');
+$mockProc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$mockPort", __DIR__ . '/fixtures/fcm_server.php'], [['pipe', 'r'], ['file', $work . '/mock.out', 'w'], ['file', $work . '/mock.out', 'a']], $mp);
+
+$relayEnv = array_merge(getenv(), [
+ 'PUSH_TESTING' => '1', 'PUSH_FCM_API_BASE' => "http://127.0.0.1:$mockPort", 'PLUGIN_SITE_STORAGE' => $work . '/storage',
+ 'PLUGIN_SITE_BASE_URL' => 'https://plugins.example.test', 'PLUGIN_SITE_ACCESS_KEY' => $accessKey, 'PLUGIN_SITE_FCM_FILE' => $work . '/dienstkonto.json',
+ 'FCM_LOG' => $fcmLog, 'FCM_PUBKEY_FILE' => $work . '/pub.pem', 'PLUGIN_SITE_ADMIN_HASH' => password_hash('Testpasswort-123', PASSWORD_DEFAULT),
+]);
+$relayProc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$relayPort", '-t', $site], [['pipe', 'r'], ['file', $work . '/relay.out', 'w'], ['file', $work . '/relay.out', 'a']], $rp, $site, $relayEnv);
+
+$failures = [];
+$total = 0;
+$check = function (bool $ok, string $what) use (&$failures, &$total): void { $total++; if (!$ok) $failures[] = $what; };
+$sends = fn() => array_values(array_filter(array_map(fn($l) => json_decode($l, true), file($fcmLog, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []), fn($r) => is_array($r) && ($r['kind'] ?? '') === 'send'));
+$reset = function () use ($fcmLog) { file_put_contents($fcmLog, ''); };
+$relayUrl = "http://127.0.0.1:$relayPort/push.php";
+$post = function (array $body, array $headers = []) use ($relayUrl): array {
+ $ch = curl_init($relayUrl);
+ curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_POSTFIELDS => json_encode($body), CURLOPT_HTTPHEADER => array_merge(['Content-Type: application/json'], $headers), CURLOPT_TIMEOUT => 20]);
+ $out = curl_exec($ch);
+ $r = ['code' => (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE), 'json' => json_decode((string) $out, true) ?: []];
+ curl_close($ch);
+ return $r;
+};
+
+try {
+ for ($i = 0; $i < 60; $i++) { if (@fsockopen('127.0.0.1', $mockPort) && @fsockopen('127.0.0.1', $relayPort)) break; usleep(100000); }
+ $keyHeader = ['X-DeskHub-Key: ' . $accessKey];
+ $tokA = 'devA_' . str_repeat('a', 40);
+ $tokB = 'devB_' . str_repeat('b', 40);
+
+ // ═══ Teil A: der Push-Dienst allein ═══════════════════════════════════════
+ $check($post(['action' => 'register', 'name' => 'x'])['code'] === 401, 'Anmeldung ohne Zugangsschlüssel wird abgelehnt');
+ $check($post(['action' => 'register'], ['X-DeskHub-Key: falsch'])['code'] === 401, 'Anmeldung mit falschem Zugangsschlüssel wird abgelehnt');
+ $reg = $post(['action' => 'register', 'name' => 'support.firma.de ');
+ $reset();
+ fcm_notify_staff([$agent], fcm_data('ticket_new', $ticket));
+ $check(($sends()[0]['data']['brand'] ?? '') === 'Windel Support x', 'Der Name erreicht das Handy (HTML entfernt)');
+ $sq->exec('DELETE FROM push_rate'); // Stundenlimit aus dem früheren Test zurücksetzen
+ $r = $post(['brand' => '
' . str_repeat('A', 80)] + $base);
+ $b = (string) ($sends()[count($sends()) - 1]['data']['brand'] ?? '');
+ $check($r['code'] === 200 && !preg_match('/[<>=]/', $b) && mb_strlen($b) <= 40, 'Der Push-Dienst bereinigt den Namen selbst (keine Sonderzeichen, höchstens 40 Zeichen) ');
+ set_setting('push_brand', '');
+
+ fcm_register_device($pdo, $agent, 'gone_' . str_repeat('z', 40), 'Altes Handy');
+ $reset();
+ fcm_notify_staff([$agent], ['type' => 'ticket_new', 'ticket' => 'T-3', 'title' => 'x', 'body' => 'y']);
+ $left = $pdo->prepare('SELECT COUNT(*) FROM push_devices WHERE staff_id = ?');
+ $left->execute([$agent]);
+ $check((int) $left->fetchColumn() === 1 && in_array($tAgent, array_column($sends(), 'token'), true), 'Ein abgemeldetes Gerät wird über die Antwort des Push-Dienstes aus der Liste entfernt');
+
+ // Der Push-Dienst wurde neu gebaut und kennt die Installation nicht mehr: DeskHub meldet sich selbst neu an
+ $sq->exec('DELETE FROM push_installs');
+ $reset();
+ fcm_notify_staff([$admin], ['type' => 'ticket_new', 'ticket' => 'T-4', 'title' => 'x', 'body' => 'y']);
+ $check(count($sends()) === 1 && (int) $sq->query('SELECT COUNT(*) FROM push_installs')->fetchColumn() === 1, 'Kennt der Push-Dienst die Installation nicht mehr, meldet sich DeskHub automatisch neu an und sendet trotzdem');
+
+ $t = fcm_send_test($pdo, $admin);
+ $check($t['devices'] === 1 && $t['sent'] === 1 && ($sends()[count($sends()) - 1]['data']['type'] ?? '') === 'test', 'Testnachricht im Adminportal funktioniert über den Push-Dienst');
+ $check(get_setting('fcm_last_ok', '') !== '' && get_setting('fcm_last_error', 'x') === '', 'Letzter Erfolg wird gemerkt, kein Fehler');
+
+ set_setting('fcm_enabled', '0');
+ $reset();
+ fcm_notify_new_ticket($ticket);
+ $check($sends() === [] && !fcm_configured(), 'Ausgeschaltet werden keine Nachrichten gesendet');
+ set_setting('fcm_enabled', '1');
+
+
+} finally {
+ foreach ([$mockProc ?? null, $relayProc ?? null] as $p) if (is_resource($p)) { proc_terminate($p); proc_close($p); }
+}
+
+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-relay-')) $rm($work);
+
+if ($failures) {
+ fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
+ exit(1);
+}
+echo "Push relay checks passed ({$total} Prüfungen).\n";
diff --git a/DeskHub-Webseite/tests/staff_activity.php b/DeskHub-Webseite/tests/staff_activity.php
new file mode 100644
index 00000000..16e6d59d
--- /dev/null
+++ b/DeskHub-Webseite/tests/staff_activity.php
@@ -0,0 +1,204 @@
+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'], ['Andere', 'andere@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;
+ };
+ $call = function (string $method, string $path, ?array $json = null, ?string $bearer = null, array $form = [], string $jar = '', array $extra = []) use ($webPort): array {
+ $ch = curl_init("http://127.0.0.1:$webPort$path");
+ $h = array_merge($json !== null ? ['Content-Type: application/json'] : [], $extra);
+ if ($bearer) $h[] = 'Authorization: Bearer ' . $bearer;
+ curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 60, CURLOPT_HTTPHEADER => $h]);
+ if ($jar !== '') { curl_setopt($ch, CURLOPT_COOKIEJAR, $jar); curl_setopt($ch, CURLOPT_COOKIEFILE, $jar); }
+ if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $json !== null ? json_encode($json) : http_build_query($form)); }
+ $b = (string) curl_exec($ch);
+ $s = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+ curl_close($ch);
+ return [$s, json_decode($b, true) ?? $b];
+ };
+ $row = fn(string $name) => $pdo()->query('SELECT s.last_active_at, sa.status, sa.auto_offline, sa.prev_status, sa.note FROM staff s LEFT JOIN staff_availability sa ON sa.staff_id = s.id WHERE s.id = ' . (int) $ids[$name])->fetch();
+ $setActive = function (string $name, int $minutesAgo) use ($pdo, $ids): void {
+ $pdo()->prepare('UPDATE staff SET last_active_at = ? WHERE id = ?')->execute([date('Y-m-d H:i:s', time() - $minutesAgo * 60), $ids[$name]]);
+ };
+
+ $check(!array_filter($mig['errors'] ?? [], fn($e) => str_contains($e, 'php_0042')), 'Migration 0042 läuft ohne Fehler');
+ [, $a] = $call('POST', '/api/staff/v1/login.php', ['email' => 'agent@test.de', 'password' => 'Sehr-Sicher-1234', 'deviceName' => 'T']);
+ [, $c] = $call('POST', '/api/staff/v1/login.php', ['email' => 'chef@test.de', 'password' => 'Sehr-Sicher-1234', 'deviceName' => 'T']);
+ [, $o] = $call('POST', '/api/staff/v1/login.php', ['email' => 'andere@test.de', 'password' => 'Sehr-Sicher-1234', 'deviceName' => 'T']);
+ [$agent, $chef, $other] = [(string) ($a['token'] ?? ''), (string) ($c['token'] ?? ''), (string) ($o['token'] ?? '')];
+ $check($agent !== '' && $chef !== '' && $other !== '', 'Anmeldung über die Mitarbeiter-API');
+
+ // Status setzen zählt als Aktivität; Admin darf fremde Status ändern, Mitarbeiter nicht
+ [$s] = $call('POST', '/api/staff/v1/availability.php', ['status' => 'available'], $agent);
+ $check($s === 200 && $row('Agent')['status'] === 'available' && $row('Agent')['last_active_at'] !== null, 'Eigenen Status setzen funktioniert und zählt als Aktivität (HTTP ' . $s . ')');
+ [$s] = $call('POST', '/api/staff/v1/availability.php', ['status' => 'busy', 'note' => 'Meeting', 'staffId' => $ids['Andere']], $chef);
+ $check($s === 200 && $row('Andere')['status'] === 'busy' && $row('Andere')['note'] === 'Meeting' && (int) $row('Andere')['auto_offline'] === 0, 'Admin ändert den Status eines Mitarbeiters (API)');
+ [$s] = $call('POST', '/api/staff/v1/availability.php', ['status' => 'offline', 'staffId' => $ids['Chef']], $agent);
+ $check($s === 403 && $row('Chef')['status'] === null, 'Normaler Mitarbeiter darf fremde Status nicht ändern (HTTP ' . $s . ')');
+ [$s] = $call('POST', '/api/staff/v1/availability.php', ['status' => 'busy', 'staffId' => 99999], $chef);
+ $check($s === 404, 'Unbekannter Mitarbeiter: HTTP 404 (' . $s . ')');
+ [$s] = $call('POST', '/api/staff/v1/availability.php', ['status' => 'irgendwas', 'staffId' => $ids['Andere']], $chef);
+ $check($s === 422, 'Ungültiger Status wird abgelehnt (HTTP ' . $s . ')');
+
+ // 31 Minuten nichts getan: offline; wer aktiv war, bleibt
+ $call('POST', '/api/staff/v1/availability.php', ['status' => 'available'], $chef);
+ $setActive('Agent', 31);
+ $setActive('Andere', 5);
+ [$s, $team] = $call('GET', '/api/staff/v1/availability.php', null, $chef);
+ $byId = [];
+ foreach ($team['team'] ?? [] as $m) $byId[$m['id']] = $m;
+ $check($s === 200 && ($byId[$ids['Agent']]['status'] ?? '') === 'offline' && ($byId[$ids['Agent']]['autoOffline'] ?? false) === true, 'Nach 31 Minuten ohne Aktivität steht der Mitarbeiter auf Offline (automatisch)');
+ $check($row('Agent')['prev_status'] === 'available' && (int) $row('Agent')['auto_offline'] === 1, 'Der vorherige Status wird gemerkt');
+ $check(($byId[$ids['Andere']]['status'] ?? '') === 'busy' && ($byId[$ids['Chef']]['status'] ?? '') === 'available', 'Aktive Mitarbeiter bleiben unverändert ' . json_encode([$byId[$ids['Andere']] ?? null, $byId[$ids['Chef']] ?? null]));
+
+ // Hintergrund-Abrufe halten niemanden online
+ $before = $row('Agent')['last_active_at'];
+ $call('GET', '/api/staff/v1/availability.php', null, $agent);
+ $call('POST', '/api/staff/v1/push_device.php', ['action' => 'register', 'token' => 'devAgent_' . str_repeat('a', 40)], $agent);
+ $check($row('Agent')['last_active_at'] === $before && $row('Agent')['status'] === 'offline', 'Abfragen im Hintergrund und Push-Anmeldung zählen nicht als Aktivität');
+
+ // Berührungs-Ping der App holt aus dem automatischen Offline zurück
+ [$s, $r] = $call('POST', '/api/staff/v1/presence.php', [], $agent);
+ $check($s === 200 && ($r['status'] ?? '') === 'available' && $row('Agent')['status'] === 'available' && (int) $row('Agent')['auto_offline'] === 0 && $row('Agent')['prev_status'] === null, 'Aktivität (App-Ping) bringt den vorherigen Status zurück (HTTP ' . $s . ')');
+ [$s] = $call('POST', '/api/staff/v1/presence.php', []);
+ $check($s === 401, 'Präsenz-Ping nur mit Anmeldung');
+
+ // Ein vorheriger Status "Pause" kommt als Pause zurück
+ $call('POST', '/api/staff/v1/availability.php', ['status' => 'break'], $agent);
+ $setActive('Agent', 40);
+ $call('GET', '/api/staff/v1/availability.php', null, $chef);
+ $check($row('Agent')['status'] === 'offline' && $row('Agent')['prev_status'] === 'break', 'Auch „Pause“ geht nach Inaktivität auf Offline');
+ $call('POST', '/api/staff/v1/presence.php', [], $agent);
+ $check($row('Agent')['status'] === 'break', 'und kommt als „Pause“ zurück');
+
+ // Manuell gesetzter Status ist nie automatisch
+ $call('POST', '/api/staff/v1/availability.php', ['status' => 'offline'], $agent);
+ $setActive('Agent', 120);
+ $call('GET', '/api/staff/v1/availability.php', null, $chef);
+ $check((int) $row('Agent')['auto_offline'] === 0 && $row('Agent')['status'] === 'offline', 'Von Hand gesetztes Offline bleibt „manuell“');
+ $call('POST', '/api/staff/v1/availability.php', ['status' => 'busy', 'staffId' => $ids['Agent']], $chef);
+ $check($row('Agent')['status'] === 'busy' && (int) $row('Agent')['auto_offline'] === 0, 'Admin-Status überschreibt den automatischen Zustand');
+
+ // Weboberfläche: Admin ändert Status und stellt die Zeit ein
+ $jar = tempnam(sys_get_temp_dir(), 'act');
+ [, $lp] = $call('GET', '/staff/login.php', null, null, [], $jar);
+ preg_match('/name="csrf_token" value="([a-f0-9]+)"/', is_string($lp) ? $lp : '', $m);
+ $call('POST', '/staff/login.php', null, null, ['csrf_token' => $m[1] ?? '', 'email' => 'chef@test.de', 'password' => 'Sehr-Sicher-1234'], $jar);
+ [$s, $page] = $call('GET', '/staff/availability.php', null, null, [], $jar, ['Sec-Fetch-Mode: navigate']);
+ $page = is_string($page) ? $page : '';
+ $check($s === 200 && str_contains($page, 'Status der Mitarbeiter ändern') && str_contains($page, 'Automatisch offline nach'), 'Verfügbarkeitsseite zeigt dem Admin die Status-Verwaltung und die Zeit-Einstellung (HTTP ' . $s . ')');
+ preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $page, $m);
+ $csrf = $m[1] ?? '';
+ // Das CSRF-Token wird nach jeder Anfrage erneuert: vor jeder Aktion die Seite neu laden
+ $fresh = function () use ($call, $jar): string {
+ [, $p] = $call('GET', '/staff/availability.php', null, null, [], $jar, ['Sec-Fetch-Mode: navigate']);
+ preg_match('/name="csrf_token" value="([a-f0-9]+)"/', is_string($p) ? $p : '', $mm);
+ return $mm[1] ?? '';
+ };
+ [$s] = $call('POST', '/staff/availability.php', null, null, ['csrf_token' => $csrf, 'action' => 'set_staff_status', 'staff_id' => (string) $ids['Andere'], 'status' => 'break', 'note' => 'Mittag'], $jar);
+ $check($s === 302 && $row('Andere')['status'] === 'break' && $row('Andere')['note'] === 'Mittag', 'Admin ändert den Status im Browser');
+ $audit = (int) $pdo()->query("SELECT COUNT(*) FROM admin_audit_log WHERE action LIKE 'Status von Mitarbeiter%'")->fetchColumn();
+ $check($audit >= 2, 'Statusänderungen durch Admins stehen im Aktivitätsprotokoll');
+ [$s] = $call('POST', '/staff/availability.php', null, null, ['csrf_token' => $fresh(), 'action' => 'save_auto_offline', 'auto_offline_minutes' => '10'], $jar);
+ $minutes = $pdo()->query("SELECT setting_value FROM settings WHERE setting_key = 'staff_auto_offline_minutes'")->fetchColumn();
+ $check($s === 302 && $minutes === '10', 'Zeit bis automatisch offline einstellbar (HTTP ' . $s . ')');
+ $setActive('Andere', 12);
+ $call('GET', '/staff/availability_status.php', null, null, [], $jar);
+ $check($row('Andere')['status'] === 'offline' && (int) $row('Andere')['auto_offline'] === 1, 'Mit 10 Minuten wird nach 12 Minuten ohne Aktivität offline gesetzt');
+ $call('POST', '/staff/availability.php', null, null, ['csrf_token' => $fresh(), 'action' => 'save_auto_offline', 'auto_offline_minutes' => '0'], $jar);
+ $call('POST', '/api/staff/v1/availability.php', ['status' => 'busy', 'staffId' => $ids['Andere']], $chef);
+ $setActive('Andere', 500);
+ $call('GET', '/staff/availability_status.php', null, null, [], $jar);
+ $check($row('Andere')['status'] === 'busy', '0 Minuten schaltet die Funktion aus');
+ // Normaler Mitarbeiter darf die Admin-Aktionen im Browser nicht ausführen
+ $jar2 = tempnam(sys_get_temp_dir(), 'act');
+ [, $lp] = $call('GET', '/staff/login.php', null, null, [], $jar2);
+ preg_match('/name="csrf_token" value="([a-f0-9]+)"/', is_string($lp) ? $lp : '', $m);
+ $call('POST', '/staff/login.php', null, null, ['csrf_token' => $m[1] ?? '', 'email' => 'agent@test.de', 'password' => 'Sehr-Sicher-1234'], $jar2);
+ [, $ap] = $call('GET', '/staff/availability.php', null, null, [], $jar2, ['Sec-Fetch-Mode: navigate']);
+ preg_match('/name="csrf_token" value="([a-f0-9]+)"/', is_string($ap) ? $ap : '', $m);
+ $check(is_string($ap) && !str_contains($ap, 'Status der Mitarbeiter ändern'), 'Normale Mitarbeiter sehen die Status-Verwaltung nicht');
+ $call('POST', '/staff/availability.php', null, null, ['csrf_token' => $m[1] ?? '', 'action' => 'set_staff_status', 'staff_id' => (string) $ids['Chef'], 'status' => 'offline'], $jar2);
+ $check($row('Chef')['status'] === 'available', 'Normale Mitarbeiter können den Status anderer auch per Formular nicht ändern');
+ @unlink($jar);
+ @unlink($jar2);
+} 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-activity-')) $rm($work);
+
+if ($failures) {
+ fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
+ exit(1);
+}
+echo "Staff activity checks passed ({$total} Prüfungen).\n";