From 4319ceec47a4be5dbab80893435a4ffe9ba0b18e Mon Sep 17 00:00:00 2001 From: M_Viper Date: Sun, 1 Nov 2026 11:15:59 +0000 Subject: [PATCH] Upload via GUI (364 Dateien) --- DeskHub-Webseite/tests/frame_protection.php | 38 ++++ DeskHub-Webseite/tests/i18n_pages.php | 6 + DeskHub-Webseite/tests/login_guard.php | 7 +- .../tests/mobile_api_integration.php | 10 + DeskHub-Webseite/tests/plugin_website.php | 2 +- .../tests/plugin_website_apps.php | 91 +++++++++ DeskHub-Webseite/tests/public_api_form.php | 179 ++++++++++++++++++ 7 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 DeskHub-Webseite/tests/frame_protection.php create mode 100644 DeskHub-Webseite/tests/plugin_website_apps.php create mode 100644 DeskHub-Webseite/tests/public_api_form.php diff --git a/DeskHub-Webseite/tests/frame_protection.php b/DeskHub-Webseite/tests/frame_protection.php new file mode 100644 index 00000000..6f95d565 --- /dev/null +++ b/DeskHub-Webseite/tests/frame_protection.php @@ -0,0 +1,38 @@ +getPathname()); + if (substr($p, -4) !== '.php' || preg_match('#/(tests|tools|backups|uploads|plugins)/#', $p)) continue; + $src = (string) file_get_contents($p); + $weakXfo = preg_match_all('/header\(\s*[\'"]X-Frame-Options:\s*([A-Za-z-]+)/i', $src, $m1) && array_filter($m1[1], fn($v) => strtoupper($v) !== 'DENY'); + $weakCsp = preg_match_all('/frame-ancestors\s+([^;\'"]+|\'[a-z-]+\')/i', $src, $m2) && array_filter($m2[1], fn($v) => trim($v) !== "'none'"); + if ($weakXfo || $weakCsp) $loosened[] = substr($p, strlen($root) + 1); +} +$check(!$loosened, 'Keine Seite lockert den Rahmenschutz: ' . implode(', ', $loosened)); + +if ($failures) { + fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n"); + exit(1); +} +echo "Rahmenschutz (iframe) bestanden.\n"; diff --git a/DeskHub-Webseite/tests/i18n_pages.php b/DeskHub-Webseite/tests/i18n_pages.php index b267acff..fc084bea 100644 --- a/DeskHub-Webseite/tests/i18n_pages.php +++ b/DeskHub-Webseite/tests/i18n_pages.php @@ -70,6 +70,12 @@ try { foreach (['lang-en', 'lang-de'] as $slug) { $src = $root . '/../Plugins/plugins/' . $slug; $zp = $work . '/' . $slug . '.zip'; + if (!is_dir($src)) { // nur die gepackten Versionen vorhanden: das neueste ZIP verwenden + $packed = glob($root . '/../Plugins/plugins/' . $slug . '-*.zip') ?: []; + usort($packed, 'version_compare'); + if ($packed) { copy(end($packed), $zp); $zips[] = $zp; } + continue; + } $z = new ZipArchive(); $z->open($zp, ZipArchive::CREATE); foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) $z->addFile($f->getPathname(), str_replace(chr(92), '/', substr($f->getPathname(), strlen($src) + 1))); diff --git a/DeskHub-Webseite/tests/login_guard.php b/DeskHub-Webseite/tests/login_guard.php index 5ec1913e..27d03a29 100644 --- a/DeskHub-Webseite/tests/login_guard.php +++ b/DeskHub-Webseite/tests/login_guard.php @@ -112,6 +112,11 @@ $_SESSION = ['staff_login_at' => time() + 5]; $check(login_guard_session_revoked($sid) === false, 'Eine spätere Anmeldung bleibt gültig'); $check(login_guard_find_alert($pdo, $token2) === null, 'Der Link ist nur einmal verwendbar'); +// 8b) Unbekannte Adresse: keine Sperr-Einträge und keine Mails (sonst ließen sich Protokoll und Administrator-Postfächer füllen) +$lockedBefore = $events('locked'); +login_guard_locked("fremd@nirgends.example\r\nBcc: boese@x.example", 'web'); +$check($events('locked') === $lockedBefore, 'Sperre für eine unbekannte Adresse erzeugt keinen Eintrag'); + // 9) Ausschalter set_setting('login_guard', '0'); $before = $events(); @@ -122,7 +127,7 @@ $check($events() === $before, 'Ausgeschaltet gibt es auch keine Fehlversuch-Eint set_setting('login_guard', '1'); // 10) Aufbewahrung -$pdo->exec("UPDATE login_events SET created_at = '2000-01-01 00:00:00' WHERE id IN (SELECT id FROM login_events ORDER BY id LIMIT 3)"); +$pdo->exec("UPDATE login_events SET created_at = '2000-01-01 00:00:00' WHERE id IN (SELECT id FROM (SELECT id FROM login_events ORDER BY id LIMIT 3) AS oldest)"); $n0 = $events(); $old = (int) $pdo->query("SELECT COUNT(*) FROM login_events WHERE created_at < '2001-01-01'")->fetchColumn(); login_guard_purge($pdo); diff --git a/DeskHub-Webseite/tests/mobile_api_integration.php b/DeskHub-Webseite/tests/mobile_api_integration.php index c54706b1..f3d0ca8d 100644 --- a/DeskHub-Webseite/tests/mobile_api_integration.php +++ b/DeskHub-Webseite/tests/mobile_api_integration.php @@ -224,6 +224,16 @@ try { $stmt = $check->prepare('SELECT COUNT(*) FROM ticket_threads WHERE ticket_id = ? AND is_internal_note = 1'); $stmt->execute([$ticketId]); assert_same(1, (int) $stmt->fetchColumn(), 'Interne Notiz fehlt in der Datenbank.'); + // Browser-Erweiterung: Vorabfrage (OPTIONS) wird ohne Anmeldung beantwortet, CORS-Antwort nur für Erweiterungs-Adressen + $ctx = fn(string $method, string $origin) => stream_context_create(['http' => ['method' => $method, 'header' => "Origin: {$origin}\r\nAccess-Control-Request-Method: POST\r\nAccess-Control-Request-Headers: authorization, content-type", 'ignore_errors' => true, 'timeout' => 10]]); + $extOrigin = 'chrome-extension://abcdefghijklmnopabcdefghijklmnop'; + @file_get_contents("http://127.0.0.1:$port/api/staff/v1/login.php", false, $ctx('OPTIONS', $extOrigin)); + $h = implode("\n", $http_response_header ?? []); + assert_true(str_contains($h, ' 204 '), 'OPTIONS von der Erweiterung muss mit 204 beantwortet werden.'); + assert_true(str_contains($h, 'Access-Control-Allow-Origin: ' . $extOrigin) && stripos($h, 'Access-Control-Allow-Headers:') !== false && stripos($h, 'authorization') !== false, 'CORS-Antwort für die Erweiterung fehlt.'); + @file_get_contents("http://127.0.0.1:$port/api/staff/v1/login.php", false, $ctx('OPTIONS', 'https://boese.example')); + $h = implode("\n", $http_response_header ?? []); + assert_true(stripos($h, 'Access-Control-Allow-Origin') === false, 'Fremde Webseiten dürfen keine CORS-Freigabe bekommen.'); echo "Mobile API integration test passed.\n"; } finally { proc_terminate($process); diff --git a/DeskHub-Webseite/tests/plugin_website.php b/DeskHub-Webseite/tests/plugin_website.php index ed3d6e47..7156ac4c 100644 --- a/DeskHub-Webseite/tests/plugin_website.php +++ b/DeskHub-Webseite/tests/plugin_website.php @@ -226,7 +226,7 @@ try { $check($s === 403 && $abId === (int) site_db()->query("SELECT v.id FROM versions v JOIN items i ON i.id = v.item_id WHERE i.slug = 'abendrot'")->fetchColumn(), 'Löschen mit falschem CSRF-Token ändert nichts'); [$s, $body] = $http('POST', '/admin.php', ['jar' => $jar, 'data' => ['action' => 'delete', 'csrf' => $csrf, 'id' => $abId]]); $check($s === 200 && str_contains($body, 'Version entfernt.'), 'Löschen meldet Erfolg: HTTP ' . $s . ' ' . substr(strip_tags($body), 0, 200)); - $check((int) site_db()->query("SELECT COUNT(*) FROM items WHERE slug = 'abendrot'")->fetchColumn() === 0, 'der Eintrag ist nach dem Löschen aus der Datenbank entfernt'); + $check(site_item('theme', 'abendrot') === null && !in_array('abendrot', array_column(site_catalog('theme')['themes'], 'slug'), true), 'der Eintrag ist ohne Version nicht mehr sichtbar (Katalog); Vorschaubild bleibt für spätere Versionen erhalten'); $deletedPackage = $tmp . '/storage/packages/' . $abFile; // die Datei selbst wird erst nach dem Beenden des Testservers geprüft (Windows hält sie bis dahin offen) [$s, $body] = $http('GET', '/catalog.php?type=themes'); $check(!in_array('abendrot', array_column(json_decode($body, true)['themes'] ?? [], 'slug'), true) && in_array('nordlicht', array_column(json_decode($body, true)['themes'] ?? [], 'slug'), true), 'gelöschtes Theme ist aus dem Katalog, andere bleiben'); diff --git a/DeskHub-Webseite/tests/plugin_website_apps.php b/DeskHub-Webseite/tests/plugin_website_apps.php new file mode 100644 index 00000000..f6d1fc1f --- /dev/null +++ b/DeskHub-Webseite/tests/plugin_website_apps.php @@ -0,0 +1,91 @@ +open($p, ZipArchive::CREATE | ZipArchive::OVERWRITE); + foreach ($files as $n => $c) $z->addFromString($n, $c); + $z->close(); + return $p; +}; +$pad = bin2hex(random_bytes(1200)); // zufällig, damit das ZIP die Mindestgröße erreicht + +// ── WordPress-Plugin ───────────────────────────────────────────────────────── +$wp = fn(string $v) => $makeZip(['deskhub-tickets/deskhub-tickets.php' => " 'x']); +$check(site_zip_app_version('wordpress', $wp('1.2.2')) === '1.2.2', 'Version steht im Plugin-Kopf'); +$check(site_zip_app_version('wordpress', $makeZip(['a/a.php' => ' $pad]), '', ''); +$check(!$bad['ok'] && str_contains($bad['error'], 'kein WordPress-Plugin'), 'ZIP ohne Plugin-Kopf wird abgelehnt'); +$check(site_publish_app('wordpress', $wp('9.9.9'), '4.0.1', '')['ok'] && site_app_release('wordpress')['version'] === '4.0.1', 'eingetippte Version hat Vorrang'); + +// ── Chrome-Erweiterung ─────────────────────────────────────────────────────── +$ch = fn(string $v) => $makeZip(['manifest.json' => json_encode(['manifest_version' => 3, 'name' => 'DeskHub', 'version' => $v]), 'app.js' => $pad]); +$r = site_publish_app('chrome', $ch('2.0.0'), '', ''); +$check($r['ok'] && $r['version'] === '2.0.0', 'Chrome-Erweiterung: Version aus manifest.json: ' . json_encode($r)); +$check(site_publish_app('chrome', $ch('2.1'), '', '')['ok'] && site_app_release('chrome')['version'] === '2.1.0', 'zweiteilige Version wird zu 2.1.0'); +$check(!site_publish_app('chrome', $makeZip(['manifest.json' => '{}', 'a.js' => $pad]), '', '')['ok'], 'manifest.json ohne Version wird abgelehnt'); +$check(!site_publish_app('chrome', $wp('1.0.0'), '', '')['ok'], 'WordPress-Plugin ist keine Chrome-Erweiterung'); +$check(!site_publish_app('windows', $ch('1.0.0'), '', '')['ok'], 'Windows-App braucht EXE und eine Versionsnummer'); +$check(site_app_release('unbekannt') === null, 'unbekannte Plattform liefert nichts'); +$p = site_app_platforms(); +$check(isset($p['wordpress'], $p['chrome']) && $p['chrome']['file'] === 'deskhub-chrome-erweiterung.zip', 'Plattformliste enthält WordPress und Chrome'); + +// ── Vorschaubild bleibt erhalten ───────────────────────────────────────────── +$png = $tmp . '/vorschau.png'; +file_put_contents($png, base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==')); +$plug = fn(string $v) => $makeZip(['plugin.json' => json_encode(['slug' => 'bildtest', 'name' => 'Bildtest', 'version' => $v]), 'plugin.php' => '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";