236 lines
16 KiB
PHP
236 lines
16 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Prüft den zentralen Push-Dienst (plugin-webseite/push.php) und die Anbindung in DeskHub (includes/fcm.php, Weg "relay"):
|
|
* Anmeldung mit Zugangsschlüssel, Installationsgeheimnis, Datenschutz-Standard (nur Ticketnummer), Details nur auf Wunsch, Aufräumen
|
|
* abgemeldeter Geräte, automatische Neu-Anmeldung, Limits und Sperre. Google wird durch tests/fixtures/fcm_server.php ersetzt.
|
|
*/
|
|
|
|
if (!extension_loaded('pdo_sqlite') || !function_exists('curl_init') || !extension_loaded('openssl')) { echo "Relay-Test übersprungen: pdo_sqlite, curl oder openssl fehlt.\n"; exit(0); }
|
|
|
|
$root = dirname(__DIR__);
|
|
$site = dirname($root) . '/plugin-webseite';
|
|
if (!is_file($site . '/push.php')) { echo "Relay-Test übersprungen: plugin-webseite/push.php fehlt.\n"; exit(0); }
|
|
$work = sys_get_temp_dir() . '/deskhub-relay-' . bin2hex(random_bytes(4));
|
|
mkdir($work . '/storage', 0755, true);
|
|
|
|
$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]);
|
|
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 <script>'], $keyHeader);
|
|
$id = (string) ($reg['json']['installId'] ?? '');
|
|
$secret = (string) ($reg['json']['secret'] ?? '');
|
|
$check($reg['code'] === 200 && preg_match('/^[a-f0-9]{24}$/', $id) === 1 && strlen($secret) >= 32, 'Anmeldung mit Zugangsschlüssel liefert Installations-ID und Geheimnis');
|
|
$base = ['action' => 'send', 'installId' => $id, 'secret' => $secret, 'type' => 'ticket_new', 'ticket' => 'T-1', 'tokens' => [$tokA]];
|
|
|
|
$check($post(['secret' => 'x'] + $base)['code'] === 401 && ($post(['secret' => 'x'] + $base)['json']['reregister'] ?? false) === true, 'Falsches Geheimnis: 401 mit Hinweis zur Neu-Anmeldung');
|
|
$check($post(['installId' => str_repeat('0', 24)] + $base)['code'] === 401, 'Unbekannte Installation wird abgelehnt');
|
|
$check($post(['type' => 'virus'] + $base)['code'] === 422 && $post(['ticket' => '<b>x</b>'] + $base)['code'] === 422 && $post(['tokens' => []] + $base)['code'] === 422
|
|
&& $post(['tokens' => ['kurz']] + $base)['code'] === 422 && $post(['tokens' => array_fill(0, 51, $tokA)] + $base)['code'] === 422, 'Ungültiger Typ, Ticketnummer oder Geräteliste (leer, ungültig, über 50) wird abgelehnt');
|
|
|
|
$reset();
|
|
$r = $post($base);
|
|
$s = $sends();
|
|
$check($r['code'] === 200 && ($r['json']['sent'] ?? 0) === 1 && count($s) === 1 && $s[0]['project'] === 'relay-test' && $s[0]['token'] === $tokA, 'Senden geht über das fest eingebaute Dienstkonto an Firebase');
|
|
$check(($s[0]['data'] ?? []) === ['type' => 'ticket_new', 'ticket' => 'T-1', 'title' => 'Neues Ticket', 'body' => '#T-1'] && ($s[0]['priority'] ?? '') === 'HIGH' && ($s[0]['hasNotificationField'] ?? true) === false, 'Standardtext: nur „Neues Ticket“ und die Ticketnummer, hohe Priorität, reine Daten-Nachricht');
|
|
$reset();
|
|
$post(['details' => true, 'body' => 'Frau Meier: Drucker brennt'] + $base);
|
|
$d = $sends()[0]['data'] ?? [];
|
|
$check(($d['title'] ?? '') === 'Neues Ticket #T-1' && ($d['body'] ?? '') === 'Frau Meier: Drucker brennt', 'Mit „details“ werden Betreff und Name durchgereicht');
|
|
$reset();
|
|
$post(['body' => 'Geheim: Passwort 1234'] + $base);
|
|
$check(!str_contains(json_encode($sends()), 'Geheim') && !str_contains(json_encode($sends()), 'Passwort'), 'Ohne „details“ wird ein mitgeschickter Text ignoriert und nie weitergegeben');
|
|
$reset();
|
|
$post(['type' => 'test', 'ticket' => ''] + $base);
|
|
$check(($sends()[0]['data']['title'] ?? '') === 'Testnachricht', 'Testnachricht wird ohne Ticketnummer akzeptiert');
|
|
$r = $post(['tokens' => [$tokA, 'devGone_' . str_repeat('g', 30) . 'gone', $tokB]] + $base);
|
|
$check(($r['json']['sent'] ?? 0) === 2 && ($r['json']['failed'] ?? 0) === 1 && count($r['json']['gone'] ?? []) === 1 && str_contains($r['json']['gone'][0], 'gone'), 'Abgemeldetes Gerät wird in „gone“ gemeldet, die anderen bekommen die Nachricht');
|
|
|
|
// Sperre
|
|
$sq = new PDO('sqlite:' . $work . '/storage/katalog.sqlite');
|
|
$sq->exec("UPDATE push_installs SET blocked = 1 WHERE id = '$id'");
|
|
$check($post($base)['code'] === 403, 'Gesperrte Installation bekommt 403');
|
|
$sq->exec("UPDATE push_installs SET blocked = 0 WHERE id = '$id'");
|
|
$check($post($base)['code'] === 200, 'Entsperrte Installation darf wieder senden');
|
|
$hash = $sq->query("SELECT secret_hash FROM push_installs WHERE id = '$id'")->fetchColumn();
|
|
$check($hash === hash('sha256', $secret) && !str_contains((string) file_get_contents($work . '/storage/katalog.sqlite'), $secret), 'Das Geheimnis liegt nur als Hash in der Datenbank');
|
|
|
|
// Verwaltungsseite: Installationen sehen, sperren, entsperren, löschen
|
|
$jar = $work . '/admin.jar';
|
|
$web = function (string $method, array $form = []) use ($relayPort, $jar): string {
|
|
$ch = curl_init("http://127.0.0.1:$relayPort/admin.php");
|
|
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_COOKIEJAR => $jar, CURLOPT_COOKIEFILE => $jar, CURLOPT_TIMEOUT => 20]);
|
|
if ($method === 'POST') curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query($form)]);
|
|
$o = curl_exec($ch);
|
|
curl_close($ch);
|
|
return (string) $o;
|
|
};
|
|
$web('GET');
|
|
$page = $web('POST', ['action' => 'login', 'user' => 'admin', 'password' => 'Testpasswort-123']);
|
|
$page = $web('GET');
|
|
preg_match('/name="csrf" value="([a-f0-9]+)"/', $page, $cm);
|
|
$csrf = $cm[1] ?? '';
|
|
$check(str_contains($page, 'Push-Dienst (Android-App)') && str_contains($page, 'support.firma.de') && !str_contains($page, '<script>') && str_contains($page, 'Firebase-Dienstkonto eingebaut'), 'Verwaltung listet die Installationen (Name bereinigt) und zeigt den Dienstkonto-Status');
|
|
$web('POST', ['action' => 'push_block', 'csrf' => $csrf, 'id' => $id]);
|
|
$check($post($base)['code'] === 403, 'In der Verwaltung gesperrt: Senden wird abgelehnt');
|
|
$web('POST', ['action' => 'push_unblock', 'csrf' => $csrf, 'id' => $id]);
|
|
$check($post($base)['code'] === 200, 'In der Verwaltung entsperrt: Senden geht wieder');
|
|
$web('POST', ['action' => 'push_block', 'csrf' => 'falsch', 'id' => $id]);
|
|
$check($post($base)['code'] === 200, 'Ohne gültiges CSRF-Token wird nichts gesperrt');
|
|
|
|
// Stundenlimit
|
|
$sq->exec("INSERT OR REPLACE INTO push_rate (bucket, hour, n) VALUES ('send:$id', " . intdiv(time(), 3600) . ', 1999)');
|
|
$check($post($base)['code'] === 200 && $post($base)['code'] === 429, 'Stundenlimit je Installation: danach 429');
|
|
|
|
// ═══ Teil B: DeskHub über den Push-Dienst ═════════════════════════════════
|
|
putenv('DESKHUB_CONFIG_JSON=' . json_encode(['db' => ['driver' => 'sqlite', 'sqlite_path' => ':memory:'], 'fcm' => ['relay_url' => $relayUrl]]));
|
|
putenv('DESKHUB_SQLITE_PATH=:memory:');
|
|
require_once $root . '/includes/bootstrap.php';
|
|
$GLOBALS['deskhub_catalog_defaults'] = ['plugins_url' => 'https://plugins.example.test/catalog.php?type=plugins', 'access_key' => $accessKey, 'publisher_key' => 'x'];
|
|
$pdo = get_db();
|
|
$now = db_now();
|
|
$dept = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
|
|
$pdo->exec('DELETE FROM staff');
|
|
$mk = function (string $name, string $role) use ($pdo, $dept, $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, 1, $now]);
|
|
return (int) $pdo->lastInsertId();
|
|
};
|
|
$admin = $mk('Admin', 'admin');
|
|
$agent = $mk('Agent', 'agent');
|
|
$tAdmin = 'adm_' . str_repeat('x', 40);
|
|
$tAgent = 'agt_' . str_repeat('y', 40);
|
|
fcm_register_device($pdo, $admin, $tAdmin, 'Admin-Handy');
|
|
fcm_register_device($pdo, $agent, $tAgent, 'Agent-Handy');
|
|
|
|
$check(fcm_mode() === 'relay' && fcm_configured(), 'Ohne eigenen Schlüssel, aber mit Push-Dienst gilt der Weg „relay“, ohne dass der Kunde etwas einrichtet');
|
|
$check(get_setting('fcm_relay_install', '') === '', 'Vor dem ersten Senden ist noch nichts angemeldet');
|
|
|
|
$ticket = ['ticket_number' => 'T-200', 'subject' => 'Drucker brennt', 'requester_name' => 'Frau Meier', 'department_id' => $dept, 'assigned_staff_id' => null];
|
|
$reset();
|
|
fcm_notify_new_ticket($ticket);
|
|
$s = $sends();
|
|
$got = array_column($s, 'token');
|
|
sort($got);
|
|
$want = [$tAdmin, $tAgent];
|
|
sort($want);
|
|
$check($got === $want, 'Neues Ticket: Push über den Push-Dienst an Admin und Abteilung (' . count($s) . ' Nachrichten)');
|
|
$check(($s[0]['data']['title'] ?? '') === 'Neues Ticket' && ($s[0]['data']['body'] ?? '') === '#T-200' && !str_contains(json_encode($s), 'Drucker') && !str_contains(json_encode($s), 'Meier'), 'Datenschutz-Standard: nur Ticketnummer erreicht den Push-Dienst und Google');
|
|
$stored = json_decode(decrypt_secret((string) get_setting('fcm_relay_install', '')), true);
|
|
$check(is_array($stored) && strlen((string) ($stored['id'] ?? '')) === 24 && $stored['url'] === $relayUrl, 'Die erste Nachricht meldet das DeskHub automatisch an (Zugangsdaten verschlüsselt gespeichert)');
|
|
$check(!str_contains((string) get_setting('fcm_relay_install', ''), (string) $stored['secret']), 'Das Geheimnis steht nicht im Klartext in den Einstellungen');
|
|
$regs = (int) $sq->query('SELECT COUNT(*) FROM push_installs')->fetchColumn();
|
|
$reset();
|
|
fcm_notify_new_reply($ticket);
|
|
$check((int) $sq->query('SELECT COUNT(*) FROM push_installs')->fetchColumn() === $regs && count($sends()) === 2, 'Weitere Nachrichten verwenden die gespeicherte Anmeldung');
|
|
|
|
set_setting('fcm_show_details', '1');
|
|
$reset();
|
|
fcm_notify_staff([$agent], fcm_data('ticket_new', $ticket));
|
|
$d = $sends()[0]['data'] ?? [];
|
|
$check(($d['title'] ?? '') === 'Neues Ticket #T-200' && str_contains($d['body'] ?? '', 'Drucker brennt') && str_contains($d['body'] ?? '', 'Frau Meier'), 'Mit „Details anzeigen“ im Adminportal stehen Betreff und Kunde in der Nachricht');
|
|
set_setting('fcm_show_details', '0');
|
|
|
|
// Frei wählbarer Name: kommt bereinigt in der Nachricht an, ohne Einstellung gibt es keinen
|
|
$reset();
|
|
fcm_notify_staff([$agent], fcm_data('ticket_new', $ticket));
|
|
$check(!isset(($sends()[0]['data'] ?? [])['brand']), 'Ohne eingestellten Namen steht keiner in der Nachricht');
|
|
set_setting('push_brand', 'Windel Support <script>x</script>');
|
|
$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' => '<img src=x onerror=alert(1)>' . 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";
|