Upload via GUI (278 Dateien)
This commit is contained in:
@@ -115,6 +115,7 @@ if ('serviceWorker' in navigator) {
|
||||
<?php if (has_permission($staff, 'manage_staff')): ?><a href="/staff/staff_manage.php" class="<?= $activePage==='staff'?'active':'' ?>">Mitarbeiter</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_teams')): ?><a href="/staff/teams.php" class="<?= $activePage==='teams'?'active':'' ?>">Teams</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_roles')): ?><a href="/staff/roles.php" class="<?= $activePage==='roles'?'active':'' ?>">Rollen</a><?php endif; ?>
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/availability.php" class="<?= $activePage==='availability'?'active':'' ?>">Status & Schichten</a><?php endif; ?>
|
||||
|
||||
<span class="nav-group-label">System</span>
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/security_check.php" class="<?= $activePage==='security_check'?'active':'' ?>">Sicherheitsprüfung</a><?php endif; ?>
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
<?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'], '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'] ?? []) === 5 && count($r['eventTypes'] ?? []) === 5 && !in_array('chat_new', $r['events'] ?? [], true) && ($r['quietFrom'] ?? 'x') === '', 'Push-Einstellungen lesen: Standard alles an (HTTP ' . $s . ')');
|
||||
[$s, $r] = $call('POST', '/api/staff/v1/push_prefs.php', ['events' => ['ticket_new', 'nix'], 'quietFrom' => '22:00', 'quietTo' => '06:00'], $agentToken);
|
||||
$check($s === 200 && ($r['events'] ?? []) === ['ticket_new'] && ($r['quietFrom'] ?? '') === '22:00', 'Push-Einstellungen speichern (unbekannte Ereignisse verworfen)');
|
||||
[$s] = $call('POST', '/api/staff/v1/push_prefs.php', ['events' => [], 'quietFrom' => '25:00', 'quietTo' => '06:00'], $agentToken);
|
||||
$check($s === 422, 'Ungültige stille Zeiten werden abgelehnt (HTTP ' . $s . ')');
|
||||
[$s] = $call('GET', '/api/staff/v1/push_prefs.php');
|
||||
$check($s === 401, 'Push-Einstellungen nur mit Anmeldung');
|
||||
$call('POST', '/api/staff/v1/push_prefs.php', ['events' => array_keys(fcm_event_types()), 'quietFrom' => '', 'quietTo' => ''], $agentToken);
|
||||
[$s, $r] = $call('POST', '/api/staff/v1/crash_report.php', ['appVersion' => '2.2.2', 'device' => 'Pixel', 'trace' => "java.lang.NullPointerException: x\n at a.b"], $agentToken);
|
||||
$check($s === 201 && ($r['received'] ?? false) === true, 'Absturzbericht wird angenommen (HTTP ' . $s . ')');
|
||||
[$s] = $call('POST', '/api/staff/v1/crash_report.php', ['trace' => ''], $agentToken);
|
||||
$check($s === 422, 'Leerer Absturzbericht wird abgelehnt');
|
||||
[$s, $r] = $call('GET', '/api/staff/v1/push_device.php', null, $agentToken);
|
||||
$check($s === 200 && ($r['devices'] ?? 0) === 1 && ($r['serverConfigured'] ?? false) === true, 'Status der Geräte-API (HTTP ' . $s . ')');
|
||||
|
||||
// Kunde legt ein Ticket an: Admin und Agent der Abteilung bekommen einen Push (Test-Server sendet sofort)
|
||||
$jar = tempnam(sys_get_temp_dir(), 'fcmj');
|
||||
[, $form] = $call('GET', '/ticket_new.php', null, null, [], $jar);
|
||||
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', is_string($form) ? $form : '', $m);
|
||||
file_put_contents($fcmLog, '');
|
||||
[$s] = $call('POST', '/ticket_new.php', null, null, ['csrf_token' => $m[1] ?? '', 'name' => 'Kunde', 'email' => 'kunde@example.org', 'help_topic_id' => (string) $topic, 'subject' => 'Geheimer Betreff', 'message' => 'Text'], $jar);
|
||||
$got = $sentTo();
|
||||
sort($got);
|
||||
$want = ['devAgent_' . str_repeat('a', 40), 'devChef_' . str_repeat('b', 40)];
|
||||
sort($want);
|
||||
$check($s === 302 && $got === $want, 'Kundenformular → Push an Admin und Agent der Abteilung (HTTP ' . $s . ', ' . count($got) . ' Geräte)');
|
||||
$msg = $sends()[0]['data'] ?? [];
|
||||
$check(($msg['type'] ?? '') === 'ticket_new' && preg_match('/^[A-Z0-9\-]+$/i', ltrim((string) ($msg['body'] ?? ''), '#')) === 1 && !str_contains(json_encode($sends()), 'Geheimer'), 'Die Nachricht enthält nur die Ticketnummer, nicht den Betreff: ' . json_encode($msg));
|
||||
$newNumber = (string) ($msg['ticket'] ?? '');
|
||||
|
||||
// Einstellungsseite im Adminportal
|
||||
$ajar = tempnam(sys_get_temp_dir(), 'fcma');
|
||||
[, $lp] = $call('GET', '/staff/login.php', null, null, [], $ajar);
|
||||
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', is_string($lp) ? $lp : '', $lm);
|
||||
$call('POST', '/staff/login.php', null, null, ['csrf_token' => $lm[1] ?? '', 'email' => 'chef@test.de', 'password' => 'Sehr-Sicher-1234'], $ajar);
|
||||
[$s, $setPage] = $call('GET', '/staff/settings.php?tab=benachrichtigungen', null, null, [], $ajar);
|
||||
$setPage = is_string($setPage) ? $setPage : '';
|
||||
$check($s === 200 && str_contains($setPage, 'Android-App: Push-Benachrichtigungen') && str_contains($setPage, 'deskhub-test') && str_contains($setPage, 'Eigenes Firebase-Projekt') && str_contains($setPage, 'Testnachricht an meine Geräte'), 'Einstellungsseite zeigt den Push-Bereich mit Projekt und Gerätezahl (HTTP ' . $s . ')');
|
||||
$check(!str_contains($setPage, 'BEGIN PRIVATE KEY') && !str_contains($setPage, 'client_email'), 'Der Schlüssel selbst wird nie angezeigt');
|
||||
preg_match('/name="csrf_token" value="([a-f0-9]+)"[^>]*><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";
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
/**
|
||||
* Minimaler Ersatz für Googles Token- und FCM-Schnittstelle (Router für "php -S"), nur für Tests.
|
||||
* POST /token -> prüft das JWT (RS256, öffentlicher Schlüssel aus FCM_PUBKEY_FILE) und liefert ein Zugriffstoken
|
||||
* POST /v1/projects/<projekt>/messages:send -> prüft das Zugriffstoken und protokolliert die Nachricht in FCM_LOG (eine JSON-Zeile je Aufruf)
|
||||
* Enthält ein Geräte-Token das Wort "gone", antwortet der Server wie Google bei einem abgemeldeten Gerät (404 NOT_FOUND).
|
||||
*/
|
||||
$log = (string) getenv('FCM_LOG');
|
||||
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
$raw = (string) file_get_contents('php://input');
|
||||
$write = function (array $row) use ($log) { file_put_contents($log, json_encode($row, JSON_UNESCAPED_UNICODE) . "\n", FILE_APPEND | LOCK_EX); };
|
||||
$b64d = fn(string $s) => base64_decode(strtr($s, '-_', '+/'));
|
||||
|
||||
if ($path === '/token' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
parse_str($raw, $form);
|
||||
$parts = explode('.', (string) ($form['assertion'] ?? ''));
|
||||
$ok = false;
|
||||
$claims = [];
|
||||
if (count($parts) === 3 && ($form['grant_type'] ?? '') === 'urn:ietf:params:oauth:grant-type:jwt-bearer') {
|
||||
$pub = openssl_pkey_get_public((string) file_get_contents((string) getenv('FCM_PUBKEY_FILE')));
|
||||
$ok = $pub && openssl_verify($parts[0] . '.' . $parts[1], $b64d($parts[2]), $pub, OPENSSL_ALGO_SHA256) === 1;
|
||||
$claims = json_decode($b64d($parts[1]), true) ?: [];
|
||||
$ok = $ok && ($claims['scope'] ?? '') === 'https://www.googleapis.com/auth/firebase.messaging' && ($claims['exp'] ?? 0) > time();
|
||||
}
|
||||
$write(['kind' => 'token', 'ok' => $ok, 'iss' => $claims['iss'] ?? null]);
|
||||
header('Content-Type: application/json');
|
||||
if (!$ok) { http_response_code(400); echo json_encode(['error' => 'invalid_grant']); exit; }
|
||||
echo json_encode(['access_token' => 'tok-' . bin2hex(random_bytes(6)), 'expires_in' => 3600, 'token_type' => 'Bearer']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (preg_match('#^/v1/projects/([^/]+)/messages:send$#', (string) $path, $m) && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
||||
$msg = json_decode($raw, true)['message'] ?? [];
|
||||
header('Content-Type: application/json');
|
||||
if (!str_starts_with($auth, 'Bearer tok-')) { $write(['kind' => 'send', 'ok' => false, 'reason' => 'auth']); http_response_code(401); echo json_encode(['error' => ['status' => 'UNAUTHENTICATED']]); exit; }
|
||||
$token = (string) ($msg['token'] ?? '');
|
||||
if (str_contains($token, 'gone')) { $write(['kind' => 'send', 'ok' => false, 'token' => $token, 'reason' => 'gone']); http_response_code(404); echo json_encode(['error' => ['status' => 'NOT_FOUND', 'message' => 'Requested entity was not found.']]); exit; }
|
||||
$write(['kind' => 'send', 'ok' => true, 'project' => $m[1], 'token' => $token, 'data' => $msg['data'] ?? [], 'priority' => $msg['android']['priority'] ?? '', 'hasNotificationField' => isset($msg['notification'])]);
|
||||
echo json_encode(['name' => 'projects/' . $m[1] . '/messages/' . bin2hex(random_bytes(4))]);
|
||||
exit;
|
||||
}
|
||||
http_response_code(404);
|
||||
@@ -0,0 +1,235 @@
|
||||
<?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";
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft den automatischen Offline-Status (includes/staff_activity.php) und die Statusänderung durch Admins gegen eine echte
|
||||
* Installation auf einem Test-Webserver: Inaktive Mitarbeiter gehen offline und kommen bei Aktivität zurück, Hintergrund-Abrufe
|
||||
* zählen nicht als Aktivität, manuelle Status bleiben, Admins dürfen fremde Status ändern (Mitarbeiter nicht), Zeit einstellbar.
|
||||
*/
|
||||
|
||||
if (!extension_loaded('pdo_sqlite') || !function_exists('curl_init')) { echo "Aktivitäts-Test übersprungen: pdo_sqlite oder curl fehlt.\n"; exit(0); }
|
||||
|
||||
date_default_timezone_set('Europe/Berlin'); // wie in der Test-Installation
|
||||
$root = dirname(__DIR__);
|
||||
$work = sys_get_temp_dir() . '/deskhub-activity-' . bin2hex(random_bytes(4));
|
||||
$tmp = $work . '/inst';
|
||||
mkdir($tmp, 0755, true);
|
||||
$webPort = random_int(33000, 33499);
|
||||
$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'],
|
||||
], true) . ';');
|
||||
|
||||
putenv('DESKHUB_CONFIG_JSON');
|
||||
putenv('DESKHUB_SQLITE_PATH');
|
||||
putenv('DESKHUB_TESTING');
|
||||
putenv('DESKHUB_API_ALLOW_INSECURE=1');
|
||||
require_once $root . '/database/sqlite_schema.php';
|
||||
require_once $root . '/database/migrate.php';
|
||||
$db = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
|
||||
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
$db->exec('PRAGMA foreign_keys = ON');
|
||||
$db->exec(convert_schema_to_sqlite((string) file_get_contents($root . '/database/schema.sql')));
|
||||
$mig = run_migrations($db, true);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$dept = (int) $db->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
|
||||
$db->exec('DELETE FROM staff');
|
||||
$ins = $db->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)');
|
||||
$ids = [];
|
||||
foreach ([['Chef', 'chef@test.de', 'admin'], ['Agent', 'agent@test.de', 'agent'], ['Andere', 'andere@test.de', 'agent']] as [$n, $e, $r]) {
|
||||
$ins->execute([$n, $e, password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT), $r, $dept, $now]);
|
||||
$ids[$n] = (int) $db->lastInsertId();
|
||||
}
|
||||
$db = null;
|
||||
|
||||
$failures = [];
|
||||
$total = 0;
|
||||
$check = function (bool $ok, string $what) use (&$failures, &$total): void { $total++; if (!$ok) $failures[] = $what; };
|
||||
$webProc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$webPort", '-t', $tmp], [['pipe', 'r'], ['file', $work . '/web.out', 'w'], ['file', $work . '/web.out', 'a']], $wp, $tmp);
|
||||
|
||||
try {
|
||||
for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $webPort)) break; usleep(100000); }
|
||||
$pdo = function () use ($tmp): PDO {
|
||||
$p = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
|
||||
$p->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$p->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
return $p;
|
||||
};
|
||||
$call = function (string $method, string $path, ?array $json = null, ?string $bearer = null, array $form = [], string $jar = '', array $extra = []) use ($webPort): array {
|
||||
$ch = curl_init("http://127.0.0.1:$webPort$path");
|
||||
$h = array_merge($json !== null ? ['Content-Type: application/json'] : [], $extra);
|
||||
if ($bearer) $h[] = 'Authorization: Bearer ' . $bearer;
|
||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 60, CURLOPT_HTTPHEADER => $h]);
|
||||
if ($jar !== '') { curl_setopt($ch, CURLOPT_COOKIEJAR, $jar); curl_setopt($ch, CURLOPT_COOKIEFILE, $jar); }
|
||||
if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $json !== null ? json_encode($json) : http_build_query($form)); }
|
||||
$b = (string) curl_exec($ch);
|
||||
$s = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
return [$s, json_decode($b, true) ?? $b];
|
||||
};
|
||||
$row = fn(string $name) => $pdo()->query('SELECT s.last_active_at, sa.status, sa.auto_offline, sa.prev_status, sa.note FROM staff s LEFT JOIN staff_availability sa ON sa.staff_id = s.id WHERE s.id = ' . (int) $ids[$name])->fetch();
|
||||
$setActive = function (string $name, int $minutesAgo) use ($pdo, $ids): void {
|
||||
$pdo()->prepare('UPDATE staff SET last_active_at = ? WHERE id = ?')->execute([date('Y-m-d H:i:s', time() - $minutesAgo * 60), $ids[$name]]);
|
||||
};
|
||||
|
||||
$check(!array_filter($mig['errors'] ?? [], fn($e) => str_contains($e, 'php_0042')), 'Migration 0042 läuft ohne Fehler');
|
||||
[, $a] = $call('POST', '/api/staff/v1/login.php', ['email' => 'agent@test.de', 'password' => 'Sehr-Sicher-1234', 'deviceName' => 'T']);
|
||||
[, $c] = $call('POST', '/api/staff/v1/login.php', ['email' => 'chef@test.de', 'password' => 'Sehr-Sicher-1234', 'deviceName' => 'T']);
|
||||
[, $o] = $call('POST', '/api/staff/v1/login.php', ['email' => 'andere@test.de', 'password' => 'Sehr-Sicher-1234', 'deviceName' => 'T']);
|
||||
[$agent, $chef, $other] = [(string) ($a['token'] ?? ''), (string) ($c['token'] ?? ''), (string) ($o['token'] ?? '')];
|
||||
$check($agent !== '' && $chef !== '' && $other !== '', 'Anmeldung über die Mitarbeiter-API');
|
||||
|
||||
// Status setzen zählt als Aktivität; Admin darf fremde Status ändern, Mitarbeiter nicht
|
||||
[$s] = $call('POST', '/api/staff/v1/availability.php', ['status' => 'available'], $agent);
|
||||
$check($s === 200 && $row('Agent')['status'] === 'available' && $row('Agent')['last_active_at'] !== null, 'Eigenen Status setzen funktioniert und zählt als Aktivität (HTTP ' . $s . ')');
|
||||
[$s] = $call('POST', '/api/staff/v1/availability.php', ['status' => 'busy', 'note' => 'Meeting', 'staffId' => $ids['Andere']], $chef);
|
||||
$check($s === 200 && $row('Andere')['status'] === 'busy' && $row('Andere')['note'] === 'Meeting' && (int) $row('Andere')['auto_offline'] === 0, 'Admin ändert den Status eines Mitarbeiters (API)');
|
||||
[$s] = $call('POST', '/api/staff/v1/availability.php', ['status' => 'offline', 'staffId' => $ids['Chef']], $agent);
|
||||
$check($s === 403 && $row('Chef')['status'] === null, 'Normaler Mitarbeiter darf fremde Status nicht ändern (HTTP ' . $s . ')');
|
||||
[$s] = $call('POST', '/api/staff/v1/availability.php', ['status' => 'busy', 'staffId' => 99999], $chef);
|
||||
$check($s === 404, 'Unbekannter Mitarbeiter: HTTP 404 (' . $s . ')');
|
||||
[$s] = $call('POST', '/api/staff/v1/availability.php', ['status' => 'irgendwas', 'staffId' => $ids['Andere']], $chef);
|
||||
$check($s === 422, 'Ungültiger Status wird abgelehnt (HTTP ' . $s . ')');
|
||||
|
||||
// 31 Minuten nichts getan: offline; wer aktiv war, bleibt
|
||||
$call('POST', '/api/staff/v1/availability.php', ['status' => 'available'], $chef);
|
||||
$setActive('Agent', 31);
|
||||
$setActive('Andere', 5);
|
||||
[$s, $team] = $call('GET', '/api/staff/v1/availability.php', null, $chef);
|
||||
$byId = [];
|
||||
foreach ($team['team'] ?? [] as $m) $byId[$m['id']] = $m;
|
||||
$check($s === 200 && ($byId[$ids['Agent']]['status'] ?? '') === 'offline' && ($byId[$ids['Agent']]['autoOffline'] ?? false) === true, 'Nach 31 Minuten ohne Aktivität steht der Mitarbeiter auf Offline (automatisch)');
|
||||
$check($row('Agent')['prev_status'] === 'available' && (int) $row('Agent')['auto_offline'] === 1, 'Der vorherige Status wird gemerkt');
|
||||
$check(($byId[$ids['Andere']]['status'] ?? '') === 'busy' && ($byId[$ids['Chef']]['status'] ?? '') === 'available', 'Aktive Mitarbeiter bleiben unverändert ' . json_encode([$byId[$ids['Andere']] ?? null, $byId[$ids['Chef']] ?? null]));
|
||||
|
||||
// Hintergrund-Abrufe halten niemanden online
|
||||
$before = $row('Agent')['last_active_at'];
|
||||
$call('GET', '/api/staff/v1/availability.php', null, $agent);
|
||||
$call('POST', '/api/staff/v1/push_device.php', ['action' => 'register', 'token' => 'devAgent_' . str_repeat('a', 40)], $agent);
|
||||
$check($row('Agent')['last_active_at'] === $before && $row('Agent')['status'] === 'offline', 'Abfragen im Hintergrund und Push-Anmeldung zählen nicht als Aktivität');
|
||||
|
||||
// Berührungs-Ping der App holt aus dem automatischen Offline zurück
|
||||
[$s, $r] = $call('POST', '/api/staff/v1/presence.php', [], $agent);
|
||||
$check($s === 200 && ($r['status'] ?? '') === 'available' && $row('Agent')['status'] === 'available' && (int) $row('Agent')['auto_offline'] === 0 && $row('Agent')['prev_status'] === null, 'Aktivität (App-Ping) bringt den vorherigen Status zurück (HTTP ' . $s . ')');
|
||||
[$s] = $call('POST', '/api/staff/v1/presence.php', []);
|
||||
$check($s === 401, 'Präsenz-Ping nur mit Anmeldung');
|
||||
|
||||
// Ein vorheriger Status "Pause" kommt als Pause zurück
|
||||
$call('POST', '/api/staff/v1/availability.php', ['status' => 'break'], $agent);
|
||||
$setActive('Agent', 40);
|
||||
$call('GET', '/api/staff/v1/availability.php', null, $chef);
|
||||
$check($row('Agent')['status'] === 'offline' && $row('Agent')['prev_status'] === 'break', 'Auch „Pause“ geht nach Inaktivität auf Offline');
|
||||
$call('POST', '/api/staff/v1/presence.php', [], $agent);
|
||||
$check($row('Agent')['status'] === 'break', 'und kommt als „Pause“ zurück');
|
||||
|
||||
// Manuell gesetzter Status ist nie automatisch
|
||||
$call('POST', '/api/staff/v1/availability.php', ['status' => 'offline'], $agent);
|
||||
$setActive('Agent', 120);
|
||||
$call('GET', '/api/staff/v1/availability.php', null, $chef);
|
||||
$check((int) $row('Agent')['auto_offline'] === 0 && $row('Agent')['status'] === 'offline', 'Von Hand gesetztes Offline bleibt „manuell“');
|
||||
$call('POST', '/api/staff/v1/availability.php', ['status' => 'busy', 'staffId' => $ids['Agent']], $chef);
|
||||
$check($row('Agent')['status'] === 'busy' && (int) $row('Agent')['auto_offline'] === 0, 'Admin-Status überschreibt den automatischen Zustand');
|
||||
|
||||
// Weboberfläche: Admin ändert Status und stellt die Zeit ein
|
||||
$jar = tempnam(sys_get_temp_dir(), 'act');
|
||||
[, $lp] = $call('GET', '/staff/login.php', null, null, [], $jar);
|
||||
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', is_string($lp) ? $lp : '', $m);
|
||||
$call('POST', '/staff/login.php', null, null, ['csrf_token' => $m[1] ?? '', 'email' => 'chef@test.de', 'password' => 'Sehr-Sicher-1234'], $jar);
|
||||
[$s, $page] = $call('GET', '/staff/availability.php', null, null, [], $jar, ['Sec-Fetch-Mode: navigate']);
|
||||
$page = is_string($page) ? $page : '';
|
||||
$check($s === 200 && str_contains($page, 'Status der Mitarbeiter ändern') && str_contains($page, 'Automatisch offline nach'), 'Verfügbarkeitsseite zeigt dem Admin die Status-Verwaltung und die Zeit-Einstellung (HTTP ' . $s . ')');
|
||||
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $page, $m);
|
||||
$csrf = $m[1] ?? '';
|
||||
// Das CSRF-Token wird nach jeder Anfrage erneuert: vor jeder Aktion die Seite neu laden
|
||||
$fresh = function () use ($call, $jar): string {
|
||||
[, $p] = $call('GET', '/staff/availability.php', null, null, [], $jar, ['Sec-Fetch-Mode: navigate']);
|
||||
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', is_string($p) ? $p : '', $mm);
|
||||
return $mm[1] ?? '';
|
||||
};
|
||||
[$s] = $call('POST', '/staff/availability.php', null, null, ['csrf_token' => $csrf, 'action' => 'set_staff_status', 'staff_id' => (string) $ids['Andere'], 'status' => 'break', 'note' => 'Mittag'], $jar);
|
||||
$check($s === 302 && $row('Andere')['status'] === 'break' && $row('Andere')['note'] === 'Mittag', 'Admin ändert den Status im Browser');
|
||||
$audit = (int) $pdo()->query("SELECT COUNT(*) FROM admin_audit_log WHERE action LIKE 'Status von Mitarbeiter%'")->fetchColumn();
|
||||
$check($audit >= 2, 'Statusänderungen durch Admins stehen im Aktivitätsprotokoll');
|
||||
[$s] = $call('POST', '/staff/availability.php', null, null, ['csrf_token' => $fresh(), 'action' => 'save_auto_offline', 'auto_offline_minutes' => '10'], $jar);
|
||||
$minutes = $pdo()->query("SELECT setting_value FROM settings WHERE setting_key = 'staff_auto_offline_minutes'")->fetchColumn();
|
||||
$check($s === 302 && $minutes === '10', 'Zeit bis automatisch offline einstellbar (HTTP ' . $s . ')');
|
||||
$setActive('Andere', 12);
|
||||
$call('GET', '/staff/availability_status.php', null, null, [], $jar);
|
||||
$check($row('Andere')['status'] === 'offline' && (int) $row('Andere')['auto_offline'] === 1, 'Mit 10 Minuten wird nach 12 Minuten ohne Aktivität offline gesetzt');
|
||||
$call('POST', '/staff/availability.php', null, null, ['csrf_token' => $fresh(), 'action' => 'save_auto_offline', 'auto_offline_minutes' => '0'], $jar);
|
||||
$call('POST', '/api/staff/v1/availability.php', ['status' => 'busy', 'staffId' => $ids['Andere']], $chef);
|
||||
$setActive('Andere', 500);
|
||||
$call('GET', '/staff/availability_status.php', null, null, [], $jar);
|
||||
$check($row('Andere')['status'] === 'busy', '0 Minuten schaltet die Funktion aus');
|
||||
// Normaler Mitarbeiter darf die Admin-Aktionen im Browser nicht ausführen
|
||||
$jar2 = tempnam(sys_get_temp_dir(), 'act');
|
||||
[, $lp] = $call('GET', '/staff/login.php', null, null, [], $jar2);
|
||||
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', is_string($lp) ? $lp : '', $m);
|
||||
$call('POST', '/staff/login.php', null, null, ['csrf_token' => $m[1] ?? '', 'email' => 'agent@test.de', 'password' => 'Sehr-Sicher-1234'], $jar2);
|
||||
[, $ap] = $call('GET', '/staff/availability.php', null, null, [], $jar2, ['Sec-Fetch-Mode: navigate']);
|
||||
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', is_string($ap) ? $ap : '', $m);
|
||||
$check(is_string($ap) && !str_contains($ap, 'Status der Mitarbeiter ändern'), 'Normale Mitarbeiter sehen die Status-Verwaltung nicht');
|
||||
$call('POST', '/staff/availability.php', null, null, ['csrf_token' => $m[1] ?? '', 'action' => 'set_staff_status', 'staff_id' => (string) $ids['Chef'], 'status' => 'offline'], $jar2);
|
||||
$check($row('Chef')['status'] === 'available', 'Normale Mitarbeiter können den Status anderer auch per Formular nicht ändern');
|
||||
@unlink($jar);
|
||||
@unlink($jar2);
|
||||
} finally {
|
||||
if (isset($webProc) && is_resource($webProc)) { proc_terminate($webProc); proc_close($webProc); }
|
||||
}
|
||||
|
||||
usleep(300000);
|
||||
$rm = function (string $d) use (&$rm) {
|
||||
foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") && !is_link("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); }
|
||||
@rmdir($d);
|
||||
};
|
||||
if (str_starts_with($work, sys_get_temp_dir() . '/deskhub-activity-')) $rm($work);
|
||||
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo "Staff activity checks passed ({$total} Prüfungen).\n";
|
||||
Reference in New Issue
Block a user