errorCode() === $code, "Fehlercode {$code}, erhalten {$e->errorCode()}"); } }; $pdo = get_db(); $pdo->prepare("INSERT INTO staff (name, email, password_hash, role, is_active, created_at) VALUES ('Helfer','helfer@example.test','x','agent',1,?)")->execute([db_now()]); $staffId = (int) $pdo->lastInsertId(); // ── API-Schlüssel ─────────────────────────────────────────────────────────── $throws(fn() => api_key_create($pdo, '', true, false, '', $staffId), 'NAME_REQUIRED'); $throws(fn() => api_key_create($pdo, 'X', false, false, '', $staffId), 'NO_PERMISSION'); $throws(fn() => api_key_create($pdo, 'X', true, false, 'kein-ip', $staffId), 'INVALID_IP'); $created = api_key_create($pdo, 'Website', true, true, '', $staffId); $check(preg_match('/^dh_[a-f0-9]{48}$/', $created['key']) === 1, 'Schlüssel hat das erwartete Format'); $stored = $pdo->query('SELECT * FROM api_keys WHERE id = ' . $created['id'])->fetch(); $check($stored['key_hash'] === hash('sha256', $created['key']) && !str_contains(json_encode($stored), $created['key']), 'nur der Hash des Schlüssels wird gespeichert'); $check(api_key_verify($pdo, $created['key'], '203.0.113.5') !== null, 'gültiger Schlüssel wird akzeptiert'); $check(api_key_verify($pdo, 'dh_' . str_repeat('0', 48), '203.0.113.5') === null, 'unbekannter Schlüssel wird abgelehnt'); $check(api_key_verify($pdo, 'falsch', '203.0.113.5') === null, 'Schlüssel mit falschem Format wird abgelehnt'); $restricted = api_key_create($pdo, 'Nur ein Server', true, false, '198.51.100.7', $staffId); $check(api_key_verify($pdo, $restricted['key'], '198.51.100.7') !== null && api_key_verify($pdo, $restricted['key'], '203.0.113.5') === null, 'IP-Beschränkung wird durchgesetzt'); $pdo->prepare('UPDATE api_keys SET is_active = 0 WHERE id = ?')->execute([$restricted['id']]); $check(api_key_verify($pdo, $restricted['key'], '198.51.100.7') === null, 'deaktivierter Schlüssel wird abgelehnt'); // ── Ticket anlegen (direkt) ───────────────────────────────────────────────── $throws(fn() => public_ticket_create($pdo, ['name' => '', 'email' => 'a@example.test', 'subject' => 's', 'message' => 'm'], '203.0.113.5'), 'INVALID_NAME'); $throws(fn() => public_ticket_create($pdo, ['name' => 'A', 'email' => 'kaputt', 'subject' => 's', 'message' => 'm'], '203.0.113.5'), 'INVALID_EMAIL'); $throws(fn() => public_ticket_create($pdo, ['name' => 'A', 'email' => 'a@example.test', 'subject' => '', 'message' => 'm'], '203.0.113.5'), 'INVALID_SUBJECT'); $throws(fn() => public_ticket_create($pdo, ['name' => 'A', 'email' => 'a@example.test', 'subject' => 's', 'message' => ''], '203.0.113.5'), 'INVALID_MESSAGE'); $throws(fn() => public_ticket_create($pdo, ['name' => 'A', 'email' => 'a@example.test', 'subject' => 's', 'message' => 'm', 'helpTopicId' => 99999], '203.0.113.5'), 'INVALID_TOPIC'); $pdo->prepare("INSERT INTO banlist (match_type, value, created_at) VALUES ('email', 'boese@example.test', ?)")->execute([db_now()]); $throws(fn() => public_ticket_create($pdo, ['name' => 'B', 'email' => 'boese@example.test', 'subject' => 's', 'message' => 'm'], '203.0.113.5'), 'BANNED'); $ok = public_ticket_create($pdo, ['name' => 'Max', 'email' => 'max@example.test', 'subject' => 'Hilfe', 'message' => 'Es geht nicht'], '203.0.113.5'); $row = $pdo->query("SELECT * FROM tickets WHERE ticket_number = '{$ok['number']}'")->fetch(); $check($row && $row['source'] === 'api' && $row['status'] === 'open', 'Ticket wird mit Quelle "api" angelegt'); $check((int) $pdo->query("SELECT COUNT(*) FROM ticket_threads WHERE ticket_id = {$ok['id']}")->fetchColumn() === 1, 'die erste Nachricht wird gespeichert'); $check(!empty($row['first_response_due_at']) && !empty($row['resolution_due_at']), 'SLA-Fristen werden gesetzt'); // ── Webhooks ──────────────────────────────────────────────────────────────── $throws(fn() => webhook_save($pdo, 0, ['name' => 'X', 'url' => 'ftp://example.test/x', 'events' => ['ticket.created']]), 'INVALID_URL'); $throws(fn() => webhook_save($pdo, 0, ['name' => 'X', 'url' => 'http://127.0.0.1/x', 'events' => ['ticket.created']]), 'INVALID_URL'); $throws(fn() => webhook_save($pdo, 0, ['name' => 'X', 'url' => 'http://10.0.0.5/x', 'events' => ['ticket.created']]), 'INVALID_URL'); $throws(fn() => webhook_save($pdo, 0, ['name' => 'X', 'url' => 'https://93.184.216.34/x', 'events' => []]), 'NO_EVENTS'); $throws(fn() => webhook_save($pdo, 0, ['name' => '', 'url' => 'https://93.184.216.34/x', 'events' => ['ticket.created']]), 'NAME_REQUIRED'); $hookId = webhook_save($pdo, 0, ['name' => 'Extern', 'url' => 'https://93.184.216.34/hook', 'events' => ['ticket.created', 'bogus'], 'secret' => 'geheim']); $hook = $pdo->query("SELECT * FROM webhooks WHERE id = {$hookId}")->fetch(); $check($hook['events'] === 'ticket.created', 'unbekannte Ereignisse werden verworfen'); $check(decrypt_secret($hook['secret_enc']) === 'geheim' && $hook['secret_enc'] !== 'geheim', 'das Webhook-Geheimnis wird verschlüsselt gespeichert'); webhook_save($pdo, $hookId, ['name' => 'Extern', 'url' => 'https://93.184.216.34/hook', 'events' => ['ticket.created'], 'secret' => '']); $check(decrypt_secret($pdo->query("SELECT secret_enc FROM webhooks WHERE id = {$hookId}")->fetchColumn()) === 'geheim', 'leeres Geheimnis lässt das bisherige unverändert'); $payload = webhook_payload($pdo, 'ticket.created', $ok['id']); $check($payload['event'] === 'ticket.created' && $payload['ticket']['number'] === $ok['number'] && $payload['ticket']['requesterEmail'] === 'max@example.test', 'Webhook-Nutzlast enthält die Ticketdaten'); // Zustellung an einen echten Empfänger (lokaler Testserver); das Speichern prüft interne Ziele, die Zustellung selbst nicht $recvDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'deskhub_recv_' . bin2hex(random_bytes(4)); mkdir($recvDir); file_put_contents($recvDir . '/hook.php', ' file_get_contents("php://input"), "sig" => $_SERVER["HTTP_X_DESKHUB_SIGNATURE"] ?? "", "event" => $_SERVER["HTTP_X_DESKHUB_EVENT"] ?? ""])); http_response_code(204);'); $recvPort = random_int(20000, 20999); $recv = proc_open([PHP_BINARY, '-S', "127.0.0.1:{$recvPort}", '-t', $recvDir], [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], $recvPipes); for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $recvPort)) break; usleep(100000); } $localHook = $hook; $localHook['url'] = "http://127.0.0.1:{$recvPort}/hook.php"; putenv('DESKHUB_TESTING=0'); [$bs, $be] = webhook_deliver($localHook, $payload); $check($bs === 0 && $be !== null && !is_file($recvDir . '/got.json'), 'Zustellung an eine interne Adresse wird beim Senden blockiert (DNS-Rebinding-Schutz)'); putenv('DESKHUB_TESTING=1'); [$status, $error] = webhook_deliver($localHook, $payload); $got = json_decode((string) @file_get_contents($recvDir . '/got.json'), true) ?: []; $check($error === null && $status === 204, 'Webhook wird zugestellt (HTTP 204)'); $check(($got['event'] ?? '') === 'ticket.created', 'Ereignisname steht im Header'); $check(($got['sig'] ?? '') === 'sha256=' . hash_hmac('sha256', (string) ($got['body'] ?? ''), 'geheim'), 'die Signatur passt zum Körper und Geheimnis'); $deadHook = $hook; $deadHook['url'] = 'http://127.0.0.1:1/none'; [$status, $error] = webhook_deliver($deadHook, $payload); $check($error !== null, 'nicht erreichbares Ziel meldet einen Fehler'); proc_terminate($recv); // ── Öffentliche API über echten Server ────────────────────────────────────── $apiPort = random_int(21000, 21999); $api = proc_open([PHP_BINARY, '-d', 'max_execution_time=20', '-S', "127.0.0.1:{$apiPort}", '-t', $root], [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], $apiPipes, $root); for ($i = 0; $i < 80; $i++) { if (@fsockopen('127.0.0.1', $apiPort)) break; usleep(100000); } $call = function (string $method, string $query, ?array $json, ?string $key) use ($apiPort): array { $headers = ['Content-Type: application/json', 'Accept: application/json']; if ($key !== null) $headers[] = 'X-API-Key: ' . $key; $ctx = stream_context_create(['http' => ['method' => $method, 'header' => implode("\r\n", $headers), 'content' => $json !== null ? json_encode($json) : '', 'ignore_errors' => true, 'timeout' => 20]]); $body = @file_get_contents("http://127.0.0.1:{$apiPort}/api/public/v1/tickets.php{$query}", false, $ctx); $status = 0; foreach ($http_response_header ?? [] as $h) if (preg_match('#^HTTP/\S+\s+(\d{3})#', $h, $m)) $status = (int) $m[1]; return [$status, json_decode((string) $body, true) ?: []]; }; [$s, $b] = $call('POST', '', ['name' => 'X'], null); $check($s === 401, 'ohne Schlüssel: 401'); [$s, $b] = $call('POST', '', ['name' => 'Eva', 'email' => 'eva@example.test', 'subject' => 'Per API', 'message' => 'Hallo'], $created['key']); $check($s === 201 && !empty($b['number']), 'Ticket per API angelegt (201)'); $apiNumber = $b['number'] ?? ''; [$s, $b] = $call('POST', '', ['name' => 'Eva', 'email' => 'kaputt', 'subject' => 'x', 'message' => 'y'], $created['key']); $check($s === 422 && ($b['error']['code'] ?? '') === 'INVALID_EMAIL', 'ungültige Eingabe: 422 mit Fehlercode'); [$s, $b] = $call('GET', '?number=' . urlencode($apiNumber) . '&email=eva@example.test', null, $created['key']); $check($s === 200 && ($b['ticket']['status'] ?? '') === 'open' && count($b['ticket']['messages'] ?? []) === 1, 'Status per API abgefragt'); [$s, $b] = $call('GET', '?number=' . urlencode($apiNumber) . '&email=fremd@example.test', null, $created['key']); $check($s === 404, 'falsche E-Mail-Adresse: Ticket bleibt verborgen (404)'); // Entwarnung (Minecraft-Plugin): Ticket wird gelöst, der Text steht unverändert (nicht doppelt maskiert) in der internen Notiz [$s, $b] = $call('POST', '', ['action' => 'resolve', 'number' => $apiNumber, 'email' => 'eva@example.test', 'message' => 'Der Server "Lobby" ist wieder erreichbar & ok.'], $created['key']); $check($s === 200 && ($b['status'] ?? '') === 'resolved', 'Entwarnung per API löst das Ticket'); $noteRow = $pdo->prepare("SELECT message FROM ticket_threads WHERE ticket_id = (SELECT id FROM tickets WHERE ticket_number = ?) AND is_internal_note = 1 ORDER BY id DESC LIMIT 1"); $noteRow->execute([$apiNumber]); $check($noteRow->fetchColumn() === 'Der Server "Lobby" ist wieder erreichbar & ok.', 'Notiz bei Entwarnung ist Klartext ohne HTML-Entities: ' . json_encode($noteRow->fetchColumn())); $readOnly = api_key_create($pdo, 'Nur lesen', false, true, '', $staffId); [$s] = $call('POST', '', ['name' => 'Eva', 'email' => 'eva@example.test', 'subject' => 'x', 'message' => 'y'], $readOnly['key']); $check($s === 403, 'Schlüssel ohne Recht zum Anlegen: 403'); $createOnly = api_key_create($pdo, 'Nur anlegen', true, false, '', $staffId); [$s] = $call('GET', '?number=' . urlencode($apiNumber) . '&email=eva@example.test', null, $createOnly['key']); $check($s === 403, 'Schlüssel ohne Recht zum Lesen: 403'); proc_terminate($api); // ── Gespeicherte Ansichten ────────────────────────────────────────────────── $web = queue_filters_from_web(['q' => ' Rechnung ', 'department_id' => '2', 'status' => 'open', 'unbekannt' => 'x', 'tab' => '']); $check($web === ['q' => 'Rechnung', 'status' => 'open', 'departmentId' => '2'], 'Web-Filter werden in neutrale Filter übersetzt und bereinigt'); $check(queue_filters_to_web($web) === ['q' => 'Rechnung', 'status' => 'open', 'department_id' => '2'], 'neutrale Filter lassen sich zurückübersetzen'); $check(queue_filters_to_web(['status' => 'open,pending', 'q' => 'x'])['tab'] === 'open', 'mehrere Status aus den Apps werden zum Web-Reiter "Offen"'); $check(queue_filters_to_api(['tab' => 'closed', 'quick' => 'me'])['status'] === 'closed' && queue_filters_to_api(['quick' => 'me'])['assigned'] === 'me', 'Web-Reiter und Schnellfilter werden für die Apps übersetzt'); $q1 = queue_create($pdo, $staffId, 'Meine offenen', ['status' => 'open', 'assigned' => 'me'], false); $throws(fn() => queue_create($pdo, $staffId, 'meine OFFENEN', [], false), 'NAME_TAKEN'); $throws(fn() => queue_create($pdo, $staffId, ' ', [], false), 'NAME_REQUIRED'); $pdo->prepare("INSERT INTO staff (name, email, password_hash, role, is_active, created_at) VALUES ('Kollege','kollege@example.test','x','agent',1,?)")->execute([db_now()]); $otherId = (int) $pdo->lastInsertId(); $qShared = queue_create($pdo, $otherId, 'Team-Überblick', ['sla' => 'overdue'], true); queue_create($pdo, $otherId, 'Privat des Kollegen', [], false); $names = array_column(queues_for($pdo, $staffId), 'name'); $check(in_array('Meine offenen', $names, true) && in_array('Team-Überblick', $names, true) && !in_array('Privat des Kollegen', $names, true), 'eigene und geteilte Ansichten sind sichtbar, fremde private nicht'); $sharedRow = $pdo->query("SELECT * FROM saved_queues WHERE id = {$qShared}")->fetch(); $check(!queue_can_edit($sharedRow, ['id' => $staffId, 'role' => 'agent']) && queue_can_edit($sharedRow, ['id' => $staffId, 'role' => 'admin']) && queue_can_edit($sharedRow, ['id' => $otherId, 'role' => 'agent']), 'fremde Ansichten darf nur der Besitzer oder ein Administrator ändern'); foreach ([$tempDb, $recvDir . '/got.json', $recvDir . '/hook.php'] as $f) @unlink($f); @rmdir($recvDir); if ($failures) { fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n"); exit(1); } echo "Integrations API checks passed.\n";