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') && str_contains($inv['helpHtml'] ?? '', 'rel="noopener noreferrer"'), 'Hinweistext: Links sind absolut und sicher'); $check(!str_contains(json_encode($form), ' '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 “ 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";