159 lines
14 KiB
PHP
159 lines
14 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Prüft die Schnittstellen für Fremdsysteme: API-Schlüssel, öffentliche Ticket-API (über einen echten
|
|
* PHP-Testserver), Webhooks inkl. Signatur und gespeicherte Ansichten.
|
|
*/
|
|
|
|
$root = dirname(__DIR__);
|
|
$tempDb = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'deskhub_integrations_' . bin2hex(random_bytes(4)) . '.sqlite';
|
|
putenv('DESKHUB_SQLITE_PATH=' . $tempDb);
|
|
putenv('DESKHUB_TESTING=1'); // erlaubt dem Test, Webhooks an einen lokalen Empfänger zu senden
|
|
require_once $root . '/includes/bootstrap.php';
|
|
|
|
$failures = [];
|
|
$check = function (bool $ok, string $what) use (&$failures): void {
|
|
if (!$ok) $failures[] = $what;
|
|
};
|
|
$throws = function (callable $fn, string $code) use ($check): void {
|
|
try { $fn(); $check(false, "erwartete Fehler {$code}"); }
|
|
catch (AdminModelException $e) { $check($e->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', '<?php file_put_contents(__DIR__ . "/got.json", json_encode(["body" => 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 & <b>ok</b>.'], $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";
|