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";