$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(); set_setting('sla_notify_mode', 'assignee'); // diese Prüfungen betreffen den Modus „Bearbeiter bzw. Abteilung“ (Standard ist „nur Administratoren“) $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', 'ticket_snooze', 'ticket_follow'], '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'] ?? []) === 8 && count($r['eventTypes'] ?? []) === 8 && !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";