Files
2026-11-01 11:15:59 +00:00

180 lines
16 KiB
PHP

<?php
declare(strict_types=1);
/**
* Prüft den Teil der öffentlichen Ticket-API, mit dem Website-Formulare (z. B. das WordPress-Plugin) das Kundenformular nutzen:
* GET ?topics=1, GET ?form=1 (eigene Felder, Formular der Abteilung) und "fields" beim Anlegen. Über einen echten PHP-Testserver.
*/
$root = dirname(__DIR__);
$tempDb = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'deskhub_pubform_' . bin2hex(random_bytes(4)) . '.sqlite';
putenv('DESKHUB_SQLITE_PATH=' . $tempDb);
putenv('DESKHUB_TESTING=1');
require_once $root . '/includes/bootstrap.php';
$failures = [];
$check = function (bool $ok, string $what) use (&$failures): void { if (!$ok) $failures[] = $what; };
$pdo = get_db();
$now = db_now();
$pdo->prepare("INSERT INTO staff (name, email, password_hash, role, is_active, created_at) VALUES ('Helfer','helfer@example.test','x','agent',1,?)")->execute([$now]);
$staffId = (int) $pdo->lastInsertId();
// Der Pseudo-Cron läuft sonst nach einer Anfrage im Testserver (nur ein Prozess) und hält ihn lange auf
$pdo->exec("INSERT OR REPLACE INTO settings (setting_key, setting_value) VALUES ('pseudo_cron_enabled', '0')");
// Abteilungen, Themen, Felder
$deptPlain = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
$pdo->prepare("INSERT INTO departments (name, email) VALUES ('Störungen','stoerung@firma.example')")->execute();
$deptOwn = (int) $pdo->lastInsertId();
$prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn();
$pdo->prepare("INSERT INTO help_topics (name, department_id, priority_id, is_active, sort_order) VALUES ('Allgemein', ?, ?, 1, 1)")->execute([$deptPlain, $prio]);
$topicPlain = (int) $pdo->lastInsertId();
$pdo->prepare("INSERT INTO help_topics (name, department_id, priority_id, is_active, sort_order) VALUES ('Störung melden', ?, ?, 1, 2)")->execute([$deptOwn, $prio]);
$topicOwn = (int) $pdo->lastInsertId();
$pdo->prepare("INSERT INTO help_topics (name, department_id, priority_id, is_active, sort_order) VALUES ('Intern', ?, ?, 0, 3)")->execute([$deptPlain, $prio]);
$topicOff = (int) $pdo->lastInsertId();
$cf = $pdo->prepare('INSERT INTO custom_fields (label, field_type, options, is_required, is_active, sort_order, help_topic_id, department_id, form_section, help_text, form_width, radio_inline, created_at) VALUES (?,?,?,?,1,?,?,?,?,?,?,?,?)');
// themenabhängige und allgemeine Felder (Abteilung ohne eigenes Formular)
$cf->execute(['Allgemeines Feld', 'text', null, 0, 1, null, null, null, null, 'normal', 0, $now]);
$fidGeneral = (int) $pdo->lastInsertId();
$cf->execute(['Rechnungsnummer', 'text', null, 1, 2, $topicPlain, null, null, 'Siehe [Hilfe](/hilfe) oder https://example.org/x', 'half', 0, $now]);
$fidInvoice = (int) $pdo->lastInsertId();
$cf->execute(['Nur für Störung', 'text', null, 0, 3, $topicOwn, null, null, null, 'full', 0, $now]);
$fidOtherTopic = (int) $pdo->lastInsertId();
// Formular der Abteilung "Störungen": ersetzt die allgemeinen Felder
$pdo->prepare('UPDATE departments SET own_form = 1, form_config = ? WHERE id = ?')->execute([json_encode(['title' => 'Störungsmeldung', 'intro' => 'Bitte beschreiben Sie die Störung.', 'subject_show' => false, 'message_show' => false, 'submit_label' => 'Störung melden']), $deptOwn]);
$cf->execute(['Gerät', 'select', "Drucker\nRouter\nLaptop", 1, 1, null, $deptOwn, '@topic', null, 'full', 0, $now]);
$fidDevice = (int) $pdo->lastInsertId();
$cf->execute(['Seit wann', 'date', null, 0, 2, null, $deptOwn, null, null, 'full', 0, $now]);
$fidSince = (int) $pdo->lastInsertId();
$cf->execute(['Anzahl Geräte', 'number', null, 0, 3, null, $deptOwn, null, null, 'full', 0, $now]);
$fidCount = (int) $pdo->lastInsertId();
$cf->execute(['Dringend', 'checkbox', null, 0, 4, null, $deptOwn, null, null, 'full', 0, $now]);
$fidUrgent = (int) $pdo->lastInsertId();
$cf->execute(['Bereich', 'radio', "Büro\nLager", 0, 5, null, $deptOwn, null, null, 'full', 1, $now]);
$fidArea = (int) $pdo->lastInsertId();
$full = api_key_create($pdo, 'Website', true, true, '', $staffId);
$readOnly = api_key_create($pdo, 'Nur lesen', false, true, '', $staffId);
$restricted = api_key_create($pdo, 'Nur ein Server', true, true, '198.51.100.7', $staffId);
$inactive = api_key_create($pdo, 'Abgeschaltet', true, true, '198.51.100.7', $staffId);
$pdo->prepare('UPDATE api_keys SET is_active = 0 WHERE id = ?')->execute([$inactive['id']]);
$port = random_int(22000, 22999);
// Die Ausgabe des Servers geht in eine Datei: Ein ungelesenes Rohr läuft nach einigen Dutzend Anfragen voll und hält den Server an
$srvLog = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'deskhub_pubform_srv_' . bin2hex(random_bytes(4)) . '.log';
$srv = proc_open([PHP_BINARY, '-d', 'max_execution_time=20', '-S', "127.0.0.1:{$port}", '-t', $root], [['pipe', 'r'], ['file', $srvLog, 'w'], ['file', $srvLog, 'a']], $pipes, $root);
for ($i = 0; $i < 80; $i++) { if (@fsockopen('127.0.0.1', $port)) break; usleep(100000); }
$call = function (string $method, string $query, ?array $json, ?string $key) use ($port): 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:{$port}/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) ?: []];
};
$values = fn(int $ticketId) => array_column($pdo->query("SELECT custom_field_id, value FROM ticket_field_values WHERE ticket_id = {$ticketId}")->fetchAll(), 'value', 'custom_field_id');
$ticketBy = fn(string $number) => $pdo->query("SELECT * FROM tickets WHERE ticket_number = " . $pdo->quote($number))->fetch();
try {
// ── Schlüssel mit IP-Beschränkung: die Meldung nennt die Adresse, die DeskHub sieht ──────────────
[$s, $b] = $call('GET', '?topics=1', null, $restricted['key']);
$check($s === 401 && ($b['error']['code'] ?? '') === 'IP_NOT_ALLOWED' && str_contains($b['error']['message'] ?? '', '127.0.0.1') && str_contains($b['error']['message'] ?? '', 'Erlaubte IPs'), 'Gültiger Schlüssel, falsche IP: Meldung IP_NOT_ALLOWED nennt die gesehene Adresse');
[$s, $b] = $call('GET', '?topics=1', null, 'dh_' . str_repeat('0', 48));
$check($s === 401 && ($b['error']['code'] ?? '') === 'UNAUTHENTICATED', 'Unbekannter Schlüssel: weiterhin nur UNAUTHENTICATED (verrät nichts)');
[$s, $b] = $call('GET', '?topics=1', null, $inactive['key']);
$check($s === 401 && ($b['error']['code'] ?? '') === 'UNAUTHENTICATED', 'Abgeschalteter Schlüssel: UNAUTHENTICATED, kein Hinweis auf die IP');
// ── Themen ─────────────────────────────────────────────────────────────
[$s, $b] = $call('GET', '?topics=1', null, $full['key']);
$names = array_column($b['topics'] ?? [], 'name');
$check($s === 200 && in_array('Allgemein', $names, true) && in_array('Störung melden', $names, true) && !in_array('Intern', $names, true), 'Themenliste: öffentliche aktive Themen, ohne deaktivierte');
$t = array_values(array_filter($b['topics'] ?? [], fn($x) => $x['name'] === 'Störung melden'))[0] ?? [];
$check(($t['id'] ?? 0) === $topicOwn && ($t['departmentId'] ?? 0) === $deptOwn, 'Themenliste enthält Nummer und Abteilung');
[$s] = $call('GET', '?topics=1', null, $readOnly['key']);
$check($s === 403, 'Themenliste: ein Schlüssel nur zum Lesen darf das nicht');
[$s] = $call('GET', '?topics=1', null, null);
$check($s === 401, 'Themenliste: ohne Schlüssel nicht erreichbar');
// ── Formular: Abteilung ohne eigenes Formular ──────────────────────────
[$s, $b] = $call('GET', '?form=1&helpTopicId=' . $topicPlain, null, $full['key']);
$form = $b['form'] ?? [];
$ids = array_column($form['fields'] ?? [], 'id');
$check($s === 200 && ($form['ownForm'] ?? null) === false, 'Standardformular: ownForm ist false');
$check($ids === [$fidGeneral, $fidInvoice], 'Standardformular: allgemeine Felder und die des Themas, nicht die eines anderen Themas');
$check(($form['subject']['show'] ?? null) === true && ($form['message']['label'] ?? '') === 'Beschreibung' && ($form['submitLabel'] ?? '') === 'Ticket erstellen', 'Standardformular: Standardtexte');
$inv = $form['fields'][1] ?? [];
$check(($inv['required'] ?? null) === true && ($inv['width'] ?? '') === 'half' && ($inv['type'] ?? '') === 'text', 'Feld: Pflicht, Breite und Typ');
$check(str_contains($inv['helpHtml'] ?? '', 'href="http://') && !str_contains($inv['helpHtml'] ?? '', 'href="/hilfe"') && str_contains($inv['helpHtml'] ?? '', '/hilfe" target="_blank" rel="noopener noreferrer">Hilfe</a>') && str_contains($inv['helpHtml'] ?? '', 'rel="noopener noreferrer"'), 'Hinweistext: Links sind absolut und sicher');
$check(!str_contains(json_encode($form), '<script'), 'Antwort enthält kein Skript');
// ── Formular: eigenes Formular der Abteilung ───────────────────────────
[$s, $b] = $call('GET', '?form=1&helpTopicId=' . $topicOwn, null, $full['key']);
$form = $b['form'] ?? [];
$check(($form['ownForm'] ?? null) === true && ($form['title'] ?? '') === 'Störungsmeldung' && ($form['intro'] ?? '') === 'Bitte beschreiben Sie die Störung.', 'Eigenes Formular: Titel und Einleitung');
$check(($form['subject']['show'] ?? null) === false && ($form['message']['show'] ?? null) === false && ($form['submitLabel'] ?? '') === 'Störung melden', 'Eigenes Formular: ohne Betreff und Beschreibung, eigener Knopf-Text');
$byId = array_column($form['fields'] ?? [], null, 'id');
$check(array_keys($byId) === [$fidDevice, $fidSince, $fidCount, $fidUrgent, $fidArea], 'Eigenes Formular: nur die Felder der Abteilung (in Reihenfolge)');
$check(($byId[$fidDevice]['options'] ?? []) === ['Drucker', 'Router', 'Laptop'] && ($byId[$fidDevice]['type'] ?? '') === 'select' && ($byId[$fidDevice]['section'] ?? '') === 'topic', 'Auswahlfeld: Optionen und Platz im Formular');
$check(($byId[$fidArea]['type'] ?? '') === 'radio' && ($byId[$fidArea]['radioInline'] ?? null) === true, 'Optionsfeld: nebeneinander');
[$s, $b] = $call('GET', '?form=1&departmentId=' . $deptOwn, null, $full['key']);
$check($s === 200 && ($b['form']['ownForm'] ?? null) === true, 'Formular auch über die Abteilungsnummer');
[$s, $b] = $call('GET', '?form=1&helpTopicId=' . $topicOff, null, $full['key']);
$check($s === 422 && ($b['error']['code'] ?? '') === 'INVALID_TOPIC', 'Deaktiviertes Thema: Fehler INVALID_TOPIC');
[$s] = $call('GET', '?form=1&helpTopicId=' . $topicOwn, null, $readOnly['key']);
$check($s === 403, 'Formular: ein Schlüssel nur zum Lesen darf das nicht');
// ── Anlegen ohne "fields": alles wie bisher ────────────────────────────
[$s, $b] = $call('POST', '', ['name' => 'Alt', 'email' => 'alt@example.test', 'subject' => 'Wie früher', 'message' => 'ohne Felder', 'helpTopicId' => $topicPlain], $full['key']);
$check($s === 201, 'Ohne "fields": Pflichtfelder des Themas werden nicht verlangt (bestehende Anbindungen funktionieren weiter)');
$check($values((int) $ticketBy($b['number'])['id']) === [], 'Ohne "fields": keine Feldwerte gespeichert');
// ── Anlegen mit "fields": Standardformular ─────────────────────────────
[$s, $b] = $call('POST', '', ['name' => 'Eva', 'email' => 'eva@example.test', 'subject' => 'Rechnung', 'message' => 'Frage', 'helpTopicId' => $topicPlain, 'fields' => []], $full['key']);
$check($s === 422 && ($b['error']['code'] ?? '') === 'INVALID_FIELD' && str_contains($b['error']['message'] ?? '', 'Rechnungsnummer'), 'Pflichtfeld fehlt: Fehler INVALID_FIELD mit Feldnamen');
[$s, $b] = $call('POST', '', ['name' => 'Eva', 'email' => 'eva@example.test', 'subject' => 'Rechnung', 'message' => 'Frage', 'helpTopicId' => $topicPlain, 'fields' => [(string) $fidInvoice => ' RE-2026-17 ', (string) $fidOtherTopic => 'fremdes Feld', '9999' => 'gibt es nicht']], $full['key']);
$check($s === 201, 'Mit gültigen Feldern wird das Ticket angelegt');
$tk = $ticketBy($b['number'] ?? '');
$vals = $values((int) ($tk['id'] ?? 0));
$check(($vals[$fidInvoice] ?? '') === 'RE-2026-17', 'Feldwert wird (ohne Leerzeichen) gespeichert');
$check(!isset($vals[$fidOtherTopic]) && !isset($vals[9999]), 'Felder eines anderen Themas und unbekannte Felder werden nicht gespeichert');
// ── Anlegen mit "fields": eigenes Formular ─────────────────────────────
$base = ['name' => 'Max', 'email' => 'max@example.test', 'helpTopicId' => $topicOwn];
[$s, $b] = $call('POST', '', $base + ['fields' => [(string) $fidDevice => 'Fax']], $full['key']);
$check($s === 422 && ($b['error']['code'] ?? '') === 'INVALID_FIELD' && str_contains($b['error']['message'] ?? '', 'Gerät'), 'Auswahlfeld: ein nicht angebotener Wert wird abgelehnt');
[$s, $b] = $call('POST', '', $base + ['fields' => [(string) $fidDevice => 'Router', (string) $fidCount => 'viele']], $full['key']);
$check($s === 422 && str_contains($b['error']['message'] ?? '', 'Zahl'), 'Zahlenfeld: Text wird abgelehnt');
[$s, $b] = $call('POST', '', $base + ['fields' => [(string) $fidDevice => 'Router', (string) $fidSince => '31.02.2026']], $full['key']);
$check($s === 422 && str_contains($b['error']['message'] ?? '', 'Datum'), 'Datumsfeld: ungültiges Datum wird abgelehnt');
[$s, $b] = $call('POST', '', $base + ['fields' => (object) []], $full['key']);
$check($s === 422 && str_contains($b['error']['message'] ?? '', 'Gerät'), 'Eigenes Formular: Pflichtfeld „Gerät“ fehlt');
[$s, $b] = $call('POST', '', $base + ['fields' => [(string) $fidDevice => 'Router', (string) $fidSince => '2026-10-31', (string) $fidCount => '3', (string) $fidUrgent => true, (string) $fidArea => 'Lager']], $full['key']);
$check($s === 201, 'Eigenes Formular: ohne Betreff und Beschreibung wird das Ticket angelegt');
$tk = $ticketBy($b['number'] ?? '');
$check(($tk['subject'] ?? '') === 'Anfrage Störungen' && (int) ($tk['department_id'] ?? 0) === $deptOwn, 'Betreff „Anfrage <Abteilung>“ und Abteilung des Themas');
$msg = (string) $pdo->query('SELECT message FROM ticket_threads WHERE ticket_id = ' . (int) $tk['id'])->fetchColumn();
$check(str_contains($msg, 'Gerät: Router') && str_contains($msg, 'Dringend: Ja') && str_contains($msg, 'Anzahl Geräte: 3'), 'Ticket-Text besteht aus den Angaben der Felder (Checkbox als Ja/Nein)');
$vals = $values((int) $tk['id']);
$check(($vals[$fidDevice] ?? '') === 'Router' && ($vals[$fidUrgent] ?? '') === '1' && ($vals[$fidArea] ?? '') === 'Lager' && ($vals[$fidSince] ?? '') === '2026-10-31', 'Alle Feldwerte sind gespeichert');
// Pflicht-Checkbox
$pdo->prepare('UPDATE custom_fields SET is_required = 1 WHERE id = ?')->execute([$fidUrgent]);
[$s, $b] = $call('POST', '', $base + ['fields' => [(string) $fidDevice => 'Router', (string) $fidUrgent => 'nein']], $full['key']);
$check($s === 422 && str_contains($b['error']['message'] ?? '', 'bestätigen'), 'Pflicht-Checkbox muss angehakt sein');
// Hat das Ticket einen Betreff und Text, bleiben die erhalten
[$s, $b] = $call('POST', '', $base + ['subject' => 'Eigener Betreff', 'message' => 'Eigener Text', 'fields' => [(string) $fidDevice => 'Laptop', (string) $fidUrgent => '1']], $full['key']);
$tk = $ticketBy($b['number'] ?? '');
$check($s === 201 && ($tk['subject'] ?? '') === 'Eigener Betreff', 'Ein mitgeschickter Betreff bleibt erhalten');
} finally {
proc_terminate($srv);
}
@unlink($tempDb);
@unlink($srvLog);
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Öffentliche API: Formular und Felder bestanden.\n";