386 lines
29 KiB
PHP
386 lines
29 KiB
PHP
<?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();
|
||
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', '<?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'] ?? []) === 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]+)"[^>]*><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";
|