Files
deskhub/DeskHub-Webseite/tests/fcm_push.php
T
2026-10-23 17:22:24 +00:00

385 lines
28 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* Prüft die Push-Benachrichtigungen für die Android-App (includes/fcm.php, api/staff/v1/push_device.php) gegen einen Test-Ersatz für Googles
* Schnittstellen (tests/fixtures/fcm_server.php): Dienstkonto prüfen, Token-Abruf mit signiertem JWT (und Zwischenspeicher), Empfänger je
* Ereignis (neues Ticket, Kundenantwort, Zuweisung), Datenschutz-Standard, Aufräumen abgemeldeter Geräte, Geräte-API und der Weg
* vom Kundenformular bis zur Nachricht.
*/
if (!extension_loaded('pdo_sqlite') || !function_exists('curl_init') || !extension_loaded('openssl')) { echo "Push-Test übersprungen: pdo_sqlite, curl oder openssl fehlt.\n"; exit(0); }
$root = dirname(__DIR__);
$work = sys_get_temp_dir() . '/deskhub-fcm-' . bin2hex(random_bytes(4));
mkdir($work, 0755, true);
// ── Schlüsselpaar und Test-Dienstkonto ───────────────────────────────────────
$cnf = $work . '/cnf';
file_put_contents($cnf, "[req]\ndistinguished_name=d\n[d]\n");
$key = openssl_pkey_new(['config' => $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', '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', '<?php return ' . var_export([
'app_key' => bin2hex(random_bytes(32)),
'db' => ['driver' => 'sqlite', 'sqlite_path' => $tmp . '/database/deskhub.sqlite'],
'site' => ['name' => 'Test', 'url' => 'http://127.0.0.1', 'admin_email' => 'a@test.de', 'timezone' => 'Europe/Berlin'],
'uploads' => ['max_size_bytes' => 10485760, 'allowed_extensions' => ['png'], 'path' => $tmp . '/uploads'],
'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'] ?? []) === 7 && count($r['eventTypes'] ?? []) === 7 && !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]+)"[^>]*><input type="hidden" name="action" value="test_fcm"/', $setPage, $tm);
file_put_contents($fcmLog, '');
[$s] = $call('POST', '/staff/settings.php?tab=benachrichtigungen', null, null, ['csrf_token' => $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";