getPathname(), strlen($root) + 1)); if (preg_match($skip, $rel) && !preg_match('#^(backups|uploads|plugins)/(\.htaccess|README\.md)$#', $rel)) continue; if ($f->isDir()) { @mkdir($tmp . '/' . $rel, 0755, true); continue; } @mkdir(dirname($tmp . '/' . $rel), 0755, true); copy($f->getPathname(), $tmp . '/' . $rel); } @mkdir($tmp . '/uploads'); @mkdir($tmp . '/config'); file_put_contents($tmp . '/config/config.php', ' bin2hex(random_bytes(32)), 'db' => ['driver' => 'sqlite', 'sqlite_path' => $tmp . '/database/deskhub.sqlite'], 'site' => ['name' => 'Test', 'url' => 'http://127.0.0.1', 'admin_email' => 'a@test.de', 'timezone' => 'Europe/Berlin'], 'uploads' => ['max_size_bytes' => 10485760, 'allowed_extensions' => ['png'], 'path' => $tmp . '/uploads'], ], true) . ';'); putenv('DESKHUB_CONFIG_JSON'); putenv('DESKHUB_API_ALLOW_INSECURE=1'); putenv('DESKHUB_SQLITE_PATH'); putenv('DESKHUB_TESTING'); 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'))); 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(); $prio = (int) $db->query('SELECT id FROM priorities 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,?)'); $ins->execute(['Chef', 'chef@test.de', password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT), 'admin', $dept, $now]); $chefId = (int) $db->lastInsertId(); $ins->execute(['Agent', 'agent@test.de', password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT), 'agent', $dept, $now]); $mk = $db->prepare('INSERT INTO tickets (ticket_number, subject, user_id, requester_name, requester_email, department_id, priority_id, status, source, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)'); $db->exec("INSERT INTO users (name, email, password_hash, created_at) VALUES ('Erika Muster','erika@example.org','x','{$now}')"); $uid = (int) $db->lastInsertId(); $mk->execute(['W-1', 'Erstes Ticket', $uid, 'Erika Muster', 'erika@example.org', $dept, $prio, 'closed', 'web', $now, $now]); $mk->execute(['W-2', 'Zweites Ticket', null, 'Anderer', 'anderer@example.org', $dept, $prio, 'open', 'web', $now, $now]); $w1 = (int) $db->query("SELECT id FROM tickets WHERE ticket_number = 'W-1'")->fetchColumn(); $db->prepare("INSERT INTO ticket_threads (ticket_id, author_type, user_id, message, is_internal_note, created_at) VALUES (?,?,?,?,0,?)")->execute([$w1, 'customer', $uid, 'Hallo, ich brauche Hilfe', $now]); $tid = (int) $db->lastInsertId(); $db->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)')->execute([$tid, 'foto.png', 'nicht-da.png', 10, $now]); $cfg = ['dimension' => 'department', 'metrics' => ['total'], 'from' => date('Y-m-d', strtotime('-30 days')), 'to' => date('Y-m-d'), 'filters' => [], 'sort' => 'label', 'desc' => false]; $db->prepare('INSERT INTO saved_reports (staff_id, name, config, is_shared, created_at, updated_at) VALUES (?,?,?,?,?,?)')->execute([$chefId, 'Mein Bericht', json_encode($cfg), 0, $now, $now]); $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 $form, string $jar) use ($webPort): array { $ch = curl_init("http://127.0.0.1:$webPort$path"); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 60, CURLOPT_HEADER => true, CURLOPT_HTTPHEADER => ['Sec-Fetch-Mode: navigate'], CURLOPT_COOKIEJAR => $jar, CURLOPT_COOKIEFILE => $jar]); if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($form)); } $raw = (string) curl_exec($ch); $s = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $hs = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE); curl_close($ch); return [$s, substr($raw, $hs), substr($raw, 0, $hs)]; }; $jar = tempnam(sys_get_temp_dir(), 'tfw'); [, $lp] = $call('GET', '/staff/login.php', [], $jar); preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $lp, $m); $call('POST', '/staff/login.php', ['csrf_token' => $m[1] ?? '', 'email' => 'chef@test.de', 'password' => 'Sehr-Sicher-1234'], $jar); $csrf = function (string $path) use ($call, $jar): string { [, $p] = $call('GET', $path, [], $jar); preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $p, $mm); return $mm[1] ?? ''; }; // Seiten und Skripte [$s, $page] = $call('GET', '/staff/tickets.php', [], $jar); $check($s === 200 && str_contains($page, '/assets/js/staff-shortcuts.js'), 'Ticketliste lädt das Tastenkürzel-Skript (HTTP ' . $s . ')'); [$s, $js] = $call('GET', '/assets/js/staff-shortcuts.js', [], $jar); $check($s === 200 && str_contains($js, 'keydown'), 'Tastenkürzel-Skript wird ausgeliefert'); [$s, $js] = $call('GET', '/assets/js/ticket-lightbox.js', [], $jar); $check($s === 200 && str_contains($js, 'data-lightbox'), 'Bildvorschau-Skript wird ausgeliefert'); [$s, $page] = $call('GET', '/staff/ticket_view.php?n=W-1', [], $jar); $check($s === 200 && str_contains($page, 'data-lightbox="1"') && str_contains($page, '/assets/js/ticket-lightbox.js'), 'Ticket: Bildanhang hat die Vorschau-Markierung (HTTP ' . $s . ')'); $check(str_contains($page, 'Verknüpfte Tickets') && str_contains($page, 'Später senden') && str_contains($page, 'name="send_at"'), 'Ticket: Bereiche „Verknüpfte Tickets“ und „Später senden“ sind da'); // Verknüpfen $t = $csrf('/staff/ticket_view.php?n=W-2'); [$s] = $call('POST', '/staff/ticket_view.php?n=W-2', ['csrf_token' => $t, 'action' => 'link_add', 'link_number' => '#W-1', 'link_type' => 'duplicate'], $jar); $check($s === 302 && (int) $pdo()->query('SELECT COUNT(*) FROM ticket_links')->fetchColumn() === 1, 'Web: Tickets verknüpfen (HTTP ' . $s . ')'); [, $page] = $call('GET', '/staff/ticket_view.php?n=W-1', [], $jar); $check(str_contains($page, 'Hat Duplikat') && str_contains($page, 'Zweites Ticket'), 'Web: die Gegenseite zeigt „Hat Duplikat“'); [, $page] = $call('GET', '/staff/ticket_view.php?n=W-2', [], $jar); $check(str_contains($page, 'Doppelt zu') && str_contains($page, 'Erstes Ticket'), 'Web: das Ticket selbst zeigt „Doppelt zu“'); $lid = (int) $pdo()->query('SELECT id FROM ticket_links')->fetchColumn(); $t = $csrf('/staff/ticket_view.php?n=W-2'); [$s] = $call('POST', '/staff/ticket_view.php?n=W-2', ['csrf_token' => $t, 'action' => 'link_remove', 'link_id' => $lid], $jar); $check((int) $pdo()->query('SELECT COUNT(*) FROM ticket_links')->fetchColumn() === 0, 'Web: Verknüpfung entfernen'); // Antwort einplanen $t = $csrf('/staff/ticket_view.php?n=W-2'); $future = date('Y-m-d\TH:i', time() + 7200); [$s] = $call('POST', '/staff/ticket_view.php?n=W-2', ['csrf_token' => $t, 'action' => 'schedule_reply', 'message' => '

Bis später

', 'send_at' => $future, 'set_status' => 'pending'], $jar); $row = $pdo()->query("SELECT * FROM scheduled_replies WHERE state = 'pending'")->fetch(); $check($s === 302 && $row && str_contains($row['message'], 'Bis später') && $row['set_status'] === 'pending', 'Web: Antwort einplanen (HTTP ' . $s . ')'); [, $page] = $call('GET', '/staff/ticket_view.php?n=W-2', [], $jar); $check(str_contains($page, 'Geplante Antworten') && str_contains($page, 'Bis später'), 'Web: geplante Antwort steht im Ticket'); $check((int) $pdo()->query("SELECT COUNT(*) FROM ticket_threads t JOIN tickets k ON k.id = t.ticket_id WHERE k.ticket_number = 'W-2'")->fetchColumn() === 0, 'Web: bis zum Zeitpunkt wird nichts im Verlauf angelegt'); $t = $csrf('/staff/ticket_view.php?n=W-2'); [$s] = $call('POST', '/staff/ticket_view.php?n=W-2', ['csrf_token' => $t, 'action' => 'schedule_reply', 'message' => '

x

', 'send_at' => date('Y-m-d\TH:i', time() - 3600)], $jar); $check((int) $pdo()->query("SELECT COUNT(*) FROM scheduled_replies")->fetchColumn() === 1, 'Web: Zeitpunkt in der Vergangenheit legt nichts an'); $t = $csrf('/staff/ticket_view.php?n=W-2'); $call('POST', '/staff/ticket_view.php?n=W-2', ['csrf_token' => $t, 'action' => 'cancel_scheduled_reply', 'scheduled_id' => (int) $row['id']], $jar); $check($pdo()->query('SELECT state FROM scheduled_replies')->fetchColumn() === 'cancelled', 'Web: Abbrechen'); // Berichte per E-Mail [$s, $page] = $call('GET', '/staff/report_builder.php', [], $jar); $check($s === 200 && str_contains($page, 'Versand per E-Mail (Zeitplan)') && str_contains($page, 'name="recipients"'), 'Bericht-Baukasten zeigt den Versand per E-Mail (HTTP ' . $s . ')'); $t = $csrf('/staff/report_builder.php'); $rid = (int) $pdo()->query('SELECT id FROM saved_reports')->fetchColumn(); [$s] = $call('POST', '/staff/report_builder.php', ['csrf_token' => $t, 'action' => 'schedule_add', 'report_id' => $rid, 'recipients' => 'leitung@example.org', 'frequency' => 'weekly', 'weekday' => 1, 'send_time' => '08:00', 'period' => 'last_week'], $jar); $sch = $pdo()->query('SELECT * FROM report_schedules')->fetch(); $check($s === 302 && $sch && $sch['period'] === 'last_week' && $sch['recipients'] === 'leitung@example.org', 'Web: Zeitplan anlegen (HTTP ' . $s . ')'); $check((int) $sch['only_if_data'] === 1 && (int) $sch['skip_unchanged'] === 1, 'Web: ohne Angabe gelten „nur bei Daten“ und „nur bei Änderung“'); [, $page] = $call('GET', '/staff/report_builder.php', [], $jar); $check(str_contains($page, 'Nur senden, wenn der Bericht Daten enthält') && str_contains($page, 'Nur bei Änderung'), 'Web: die Schalter stehen im Formular und in der Liste'); $t = $csrf('/staff/report_builder.php'); $call('POST', '/staff/report_builder.php', ['csrf_token' => $t, 'action' => 'schedule_flag', 'schedule_id' => (int) $sch['id'], 'flag' => 'only_if_data'], $jar); $check((int) $pdo()->query('SELECT only_if_data FROM report_schedules')->fetchColumn() === 0, 'Web: Option „nur bei Daten“ lässt sich umschalten'); $t = $csrf('/staff/report_builder.php'); $call('POST', '/staff/report_builder.php', ['csrf_token' => $t, 'action' => 'schedule_flag', 'schedule_id' => (int) $sch['id'], 'flag' => 'schedule_id; DROP TABLE x'], $jar); $check((int) $pdo()->query('SELECT COUNT(*) FROM report_schedules')->fetchColumn() === 1, 'Web: eine ungültige Option wird abgewiesen'); [, $page] = $call('GET', '/staff/report_builder.php', [], $jar); $check(str_contains($page, 'leitung@example.org') && str_contains($page, 'Letzte Kalenderwoche'), 'Web: Zeitplan steht in der Liste'); $t = $csrf('/staff/report_builder.php'); $call('POST', '/staff/report_builder.php', ['csrf_token' => $t, 'action' => 'schedule_toggle', 'schedule_id' => (int) $sch['id']], $jar); $check((int) $pdo()->query('SELECT enabled FROM report_schedules')->fetchColumn() === 0, 'Web: Zeitplan pausieren'); $t = $csrf('/staff/report_builder.php'); $call('POST', '/staff/report_builder.php', ['csrf_token' => $t, 'action' => 'schedule_delete', 'schedule_id' => (int) $sch['id']], $jar); $check((int) $pdo()->query('SELECT COUNT(*) FROM report_schedules')->fetchColumn() === 0, 'Web: Zeitplan löschen'); // Datenschutz: standardmäßig aus [, $page] = $call('GET', '/staff/customer_profile.php?id=' . $uid, [], $jar); $check(!str_contains($page, 'Kundendaten löschen') && !str_contains($page, 'Alle Daten exportieren'), 'Datenschutz: ohne Einschalten keine Knöpfe'); $t = $csrf('/staff/customer_profile.php?id=' . $uid); [$s, $body, $hdr] = $call('POST', '/staff/customer_profile.php?id=' . $uid, ['csrf_token' => $t, 'action' => 'gdpr_export'], $jar); $check(!str_contains($hdr, 'application/json'), 'Datenschutz: Export ist ohne Einschalten gesperrt'); [, $page] = $call('GET', '/staff/settings.php?tab=datenschutz', [], $jar); $check(str_contains($page, 'name="gdpr_tools_enabled"'), 'Einstellungen: Schalter für die Datenschutz-Werkzeuge'); $t = $csrf('/staff/settings.php?tab=datenschutz'); $call('POST', '/staff/settings.php?tab=datenschutz', ['csrf_token' => $t, 'action' => 'save_privacy', 'retention_months' => 24, 'gdpr_tools_enabled' => 1], $jar); $check($pdo()->query("SELECT setting_value FROM settings WHERE setting_key = 'gdpr_tools_enabled'")->fetchColumn() === '1', 'Einstellungen: Schalter wird gespeichert'); [, $page] = $call('GET', '/staff/customer_profile.php?id=' . $uid, [], $jar); $check(str_contains($page, 'Kundendaten löschen') && str_contains($page, 'Alle Daten exportieren'), 'Datenschutz: nach dem Einschalten sind die Knöpfe da'); $t = $csrf('/staff/customer_profile.php?id=' . $uid); [$s, $body, $hdr] = $call('POST', '/staff/customer_profile.php?id=' . $uid, ['csrf_token' => $t, 'action' => 'gdpr_export'], $jar); $json = json_decode($body, true); $check($s === 200 && str_contains(strtolower($hdr), 'attachment') && ($json['kunde']['email'] ?? '') === 'erika@example.org' && ($json['tickets'][0]['nummer'] ?? '') === 'W-1', 'Datenschutz: Export liefert die JSON-Datei (HTTP ' . $s . ')'); $check(str_contains((string) $pdo()->query("SELECT GROUP_CONCAT(action, '|') FROM admin_audit_log")->fetchColumn(), 'Kundendaten exportiert'), 'Datenschutz: Export steht im Audit-Log'); [$s] = $call('POST', '/staff/customer_profile.php?id=' . $uid, ['csrf_token' => $t, 'action' => 'gdpr_erase', 'confirm_email' => 'falsch@example.org'], $jar); $check((int) $pdo()->query("SELECT COUNT(*) FROM users WHERE id = $uid")->fetchColumn() === 1, 'Datenschutz: ohne richtige Bestätigung wird nichts gelöscht'); [$s] = $call('POST', '/staff/customer_profile.php?id=' . $uid, ['csrf_token' => $t, 'action' => 'gdpr_erase', 'confirm_email' => 'erika@example.org'], $jar); $check($s === 302 && (int) $pdo()->query("SELECT COUNT(*) FROM users WHERE id = $uid")->fetchColumn() === 0 && $pdo()->query("SELECT requester_name FROM tickets WHERE ticket_number = 'W-1'")->fetchColumn() === 'Gelöscht', 'Datenschutz: Löschen mit Bestätigung (HTTP ' . $s . ')'); // Zähler „braucht Aufmerksamkeit“ über die Mitarbeiter-API (Chrome-Erweiterung und Windows-Programm) $apiJson = function (string $method, string $path, ?array $body, string $token) use ($webPort): array { $ch = curl_init("http://127.0.0.1:$webPort$path"); $h = ['Content-Type: application/json'] + ($token !== '' ? [1 => 'Authorization: Bearer ' . $token] : []); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, CURLOPT_HTTPHEADER => $h]); if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body ?? [])); } $raw = (string) curl_exec($ch); $st = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); return [$st, json_decode($raw, true) ?? []]; }; [, $lr] = $apiJson('POST', '/api/staff/v1/login.php', ['email' => 'agent@test.de', 'password' => 'Sehr-Sicher-1234', 'deviceName' => 'T'], ''); $tok = (string) ($lr['token'] ?? ''); $check($tok !== '', 'API: Anmeldung des Mitarbeiters'); $n2 = date('Y-m-d H:i:s'); $pdo()->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)') ->execute(['W-ATT', 'Zähler-Test', 'Kunde', 'z@example.org', $dept, $prio, 'open', 'web', $n2, $n2]); $attId = (int) $pdo()->query("SELECT id FROM tickets WHERE ticket_number = 'W-ATT'")->fetchColumn(); [$st, $a1] = $apiJson('GET', '/api/staff/v1/activity.php', null, $tok); $check($st === 200 && in_array($attId, $a1['attention']['unassigned'] ?? [], true) && ($a1['attention']['unassignedCount'] ?? 0) >= 1, 'API: Aktivität liefert das neue Ticket im Zähler (HTTP ' . $st . ')'); [$st, $a2] = $apiJson('GET', '/api/staff/v1/activity.php?since=' . urlencode($n2), null, $tok); $check(in_array($attId, $a2['attention']['unassigned'] ?? [], true), 'API: auch mit „since“ kommt der echte Stand (für ältere Clients zusätzlich newReplies/newUnassigned)'); $check(array_key_exists('newReplies', $a2) && array_key_exists('newUnassigned', $a2), 'API: die alten Felder bleiben erhalten'); $apiJson('GET', '/api/staff/v1/ticket.php?number=W-ATT', null, $tok); [, $a3] = $apiJson('GET', '/api/staff/v1/activity.php', null, $tok); $check(!in_array($attId, $a3['attention']['unassigned'] ?? [], true) && !in_array($attId, $a3['attention']['mine'] ?? [], true), 'API: nach dem Öffnen des Tickets ist es aus dem Zähler verschwunden'); // Meine Woche, Erinnerung (Einstellung) und Screenshot-Einfügen im Antwortfeld [$st, $page] = $call('GET', '/staff/my_week.php', [], $jar); $check($st === 200 && str_contains($page, 'Gelöste Tickets') && str_contains($page, 'Ø Zeit bis zur ersten Antwort') && str_contains($page, 'Gelöst pro Tag'), 'Meine Woche: Seite mit Kennzahlen (HTTP ' . $st . ')'); $check(str_contains($page, 'href="/staff/my_week.php"') && str_contains($page, 'Letzte Woche') && str_contains($page, 'Letzter Monat'), 'Meine Woche: Menüpunkt und Zeiträume'); [$st, $page] = $call('GET', '/staff/my_week.php?p=lastmonth', [], $jar); $check($st === 200 && str_contains($page, 'Letzter Monat'), 'Meine Woche: anderer Zeitraum'); [$st] = $call('GET', '/staff/my_week.php?p=' . urlencode('x