getPathname(), strlen($root) + 1)); if (preg_match($skip, $rel) && !preg_match('#^(backups|uploads|plugins)/(\.htaccess|README\.md)$#', $rel)) continue; if ($f->isDir()) { @mkdir($tmp . '/' . $rel, 0755, true); continue; } @mkdir(dirname($tmp . '/' . $rel), 0755, true); copy($f->getPathname(), $tmp . '/' . $rel); } @mkdir($tmp . '/uploads'); @mkdir($tmp . '/config'); file_put_contents($tmp . '/config/config.php', ' bin2hex(random_bytes(32)), 'db' => ['driver' => 'sqlite', 'sqlite_path' => $tmp . '/database/deskhub.sqlite'], 'site' => ['name' => 'Test', 'url' => 'http://127.0.0.1', 'admin_email' => 'a@test.de', 'timezone' => 'Europe/Berlin'], 'uploads' => ['max_size_bytes' => 10485760, 'allowed_extensions' => ['png'], 'path' => $tmp . '/uploads'], ], true) . ';'); require_once $root . '/includes/db.php'; require_once $root . '/database/migrate.php'; $pdo = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite'); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); $pdo->exec('PRAGMA foreign_keys = ON'); $pdo->exec(convert_schema_to_sqlite((string) file_get_contents($root . '/database/schema.sql'))); run_migrations($pdo, true); $now = date('Y-m-d H:i:s'); $pw = password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT); $pdo->exec('DELETE FROM staff'); $deptA = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn(); $pdo->exec("INSERT INTO departments (name, email, is_default) VALUES ('Technik', 'b@example.org', 0)"); $deptB = (int) $pdo->query('SELECT MAX(id) FROM departments')->fetchColumn(); $pdo->prepare("INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES ('Admin','admin@test.de',?,'admin',?,1,?)")->execute([$pw, $deptA, $now]); $prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY id LIMIT 1')->fetchColumn(); $pdo->prepare("INSERT INTO help_topics (name, department_id, priority_id) VALUES ('Allgemein', ?, ?)")->execute([$deptA, $prio]); $topicA = (int) $pdo->lastInsertId(); $pdo->prepare("INSERT INTO help_topics (name, department_id, priority_id) VALUES ('Störung', ?, ?)")->execute([$deptB, $prio]); $topicB = (int) $pdo->lastInsertId(); $pdo->prepare("INSERT INTO custom_fields (label, field_type, is_required, is_active, sort_order, created_at) VALUES ('Globales Pflichtfeld', 'text', 1, 1, 1, ?)")->execute([$now]); $globalField = (int) $pdo->lastInsertId(); $pdo = null; $failures = []; $total = 0; $check = function (bool $ok, string $what) use (&$failures, &$total): void { $total++; if (!$ok) $failures[] = $what; }; $port = random_int(28000, 28999); $log = tempnam(sys_get_temp_dir(), 'dformlog'); $proc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$port", '-t', $tmp], [['pipe', 'r'], ['file', $log, 'w'], ['file', $log, 'a']], $pipes, $tmp); $req = function (string $method, string $path, array $data, string $jar) use ($port): array { $ch = curl_init("http://127.0.0.1:$port$path"); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 60, CURLOPT_COOKIEJAR => $jar, CURLOPT_COOKIEFILE => $jar]); if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data)); } $b = (string) curl_exec($ch); $s = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); return [$s, $b]; }; $csrf = fn(string $h) => preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $h, $m) ? $m[1] : ''; $db = fn() => new PDO('sqlite:' . $GLOBALS['tmp'] . '/database/deskhub.sqlite', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]); try { for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $port)) break; usleep(100000); } $jar = tempnam(sys_get_temp_dir(), 'dformjar'); [, $l] = $req('GET', '/staff/login.php', [], $jar); $req('POST', '/staff/login.php', ['csrf_token' => $csrf($l), 'email' => 'admin@test.de', 'password' => 'Sehr-Sicher-1234'], $jar); // 1) Verwaltung [$s, $page] = $req('GET', "/staff/department_form.php?dept={$deptB}", [], $jar); $check($s === 200 && str_contains($page, 'Eigenes Formular für diese Abteilung verwenden'), 'Formular-Seite der Abteilung lädt (HTTP ' . $s . ')'); [, $list] = $req('GET', '/staff/departments.php', [], $jar); $check(str_contains($list, "department_form.php?dept={$deptB}"), 'Abteilungsliste verlinkt das Formular'); $req('POST', "/staff/department_form.php?dept={$deptB}", ['csrf_token' => $csrf($page), 'action' => 'save_form', 'dept' => $deptB, 'own_form' => '1', 'title' => 'Störungsmeldung', 'intro' => 'Bitte genau beschreiben.', 'subject_label' => 'Kurztitel', 'message_label' => 'Was ist passiert?', 'message_hint' => 'Fehlerbild', 'submit_label' => 'Störung melden', 'subject_width' => 'twothirds'], $jar); [, $page] = $req('GET', "/staff/department_form.php?dept={$deptB}", [], $jar); $req('POST', "/staff/department_form.php?dept={$deptB}", ['csrf_token' => $csrf($page), 'action' => 'save_field', 'dept' => $deptB, 'label' => 'Gerätenummer', 'field_type' => 'text', 'is_required' => '1', 'is_active' => '1', 'sort_order' => '10'], $jar); $row = $db()->query("SELECT * FROM custom_fields WHERE department_id = {$deptB}")->fetch(); $check($row && $row['label'] === 'Gerätenummer' && (int) $row['is_required'] === 1, 'Abteilungsfeld wird gespeichert'); $check((int) $db()->query("SELECT own_form FROM departments WHERE id = {$deptB}")->fetchColumn() === 1, 'eigenes Formular ist aktiviert'); $fieldB = (int) ($row['id'] ?? 0); [, $cf] = $req('GET', '/staff/custom_fields.php', [], $jar); $check(!str_contains($cf, 'Gerätenummer') && str_contains($cf, 'Globales Pflichtfeld'), 'Abteilungsfelder erscheinen nicht in der globalen Feldliste'); $db()->exec("INSERT INTO custom_fields (label, field_type, is_required, is_active, sort_order, department_id, form_section, created_at) VALUES ('Telefon', 'text', 0, 1, 20, {$deptB}, 'Ihre Kontaktdaten', '" . date('Y-m-d H:i:s') . "'), ('Kostenstelle', 'text', 0, 1, 30, {$deptB}, 'Abrechnung', '" . date('Y-m-d H:i:s') . "')"); $db()->exec("INSERT INTO custom_fields (label, field_type, is_required, is_active, sort_order, department_id, form_section, created_at) VALUES ('Gerätetyp', 'text', 0, 1, 5, {$deptB}, '@topic', '" . date('Y-m-d H:i:s') . "')"); $db()->exec("INSERT INTO custom_fields (label, field_type, is_required, is_active, sort_order, department_id, form_section, created_at) VALUES ('Titel der Geschichte', 'text', 0, 1, 6, {$deptB}, '@details_top', '" . date('Y-m-d H:i:s') . "'), ('Genre', 'text', 0, 1, 7, {$deptB}, '@details_mid', '" . date('Y-m-d H:i:s') . "')"); $db()->exec("INSERT INTO custom_fields (label, field_type, is_required, is_active, sort_order, department_id, form_section, created_at) VALUES ('GanzOben', 'text', 0, 1, 1, {$deptB}, '@topic_top', '" . date('Y-m-d H:i:s') . "'), ('VorKontakt', 'text', 0, 1, 2, {$deptB}, '@contact_top', '" . date('Y-m-d H:i:s') . "')"); $db()->exec("INSERT INTO custom_fields (label, field_type, options, is_required, is_active, sort_order, department_id, form_section, radio_inline, created_at) VALUES ('Genre-Wahl', 'radio', 'Krimi Liebe', 0, 1, 8, {$deptB}, '@details', 1, '" . date('Y-m-d H:i:s') . "')"); $db()->exec("UPDATE custom_fields SET form_width = 'half' WHERE department_id = {$deptB} AND label IN ('Titel der Geschichte','Genre')"); $db()->exec("INSERT INTO custom_fields (label, field_type, is_required, is_active, sort_order, department_id, form_section, help_text, created_at) VALUES ('Nutzerbedingungen', 'checkbox', 1, 1, 90, {$deptB}, '@details', 'Ich habe die [Nutzungsbedingungen](https://example.org/agb) gelesen. [Böse](javascript:alert(1)) x', '" . date('Y-m-d H:i:s') . "')"); $consentId = (int) $db()->query("SELECT id FROM custom_fields WHERE label = 'Nutzerbedingungen'")->fetchColumn(); // 2) Kundenformular [$s, $form] = $req('GET', '/ticket_new.php', [], tempnam(sys_get_temp_dir(), 'dformc')); $check($s === 200 && str_contains($form, 'Gerätenummer') && str_contains($form, 'Störungsmeldung') && str_contains($form, 'Störung melden'), 'Kundenformular enthält Felder und Texte der Abteilung'); $posI = strpos($form, 'id="issue-title"'); $posC = strpos($form, 'class="contact-title"'); $check($posI !== false && $posC !== false && $posI < $posC, 'Abschnitt „Ihr Anliegen“ steht vor den Kontaktdaten'); $pGo = strpos($form, 'GanzOben'); $pCat = strpos($form, 'id="help_topic_id"'); $pVk = strpos($form, 'VorKontakt'); $pNm = strpos($form, 'id="name"'); $check($pGo !== false && $pGo < $pCat, 'Feld vor der Themenauswahl'); $check($pVk !== false && $pVk < $pNm && $pVk > $posC, 'Feld vor Name/E-Mail im Kontaktbereich'); $check(preg_match('#type="radio" id="custom_\d+_0" name="custom_\d+" value="Krimi"#', $form) === 1 && str_contains($form, 'value="Liebe"'), 'Radio-Auswahl wird als Punkte dargestellt'); $check(substr_count($form, 'custom-field-group fw-half') === 2 && str_contains($form, 'class="dept-fields"'), 'Breite „halbe Zeile“ wird als Klasse ausgegeben, Felder stehen in einer Zeilen-Gruppe'); $check(str_contains($form, '"subject_width":"twothirds"') && str_contains($form, '"message_width":"full"'), 'Breite von Betreff und Beschreibung wird an die Umschaltung übergeben'); $check(str_contains($form, 'display:flex;flex-wrap:wrap;gap:4px 20px'), 'Radio-Optionen können nebeneinander angeordnet werden'); $check(str_contains($form, 'Nutzungsbedingungen'), 'Hinweistext zeigt den Link an'); $check(!str_contains($form, 'href="javascript:') && !str_contains($form, 'x'), 'Unsichere Links und HTML im Hinweistext werden nicht ausgegeben'); $pT = strpos($form, 'Titel der Geschichte'); $pS = strpos($form, 'id="subject-wrap"'); $pG = strpos($form, 'Genre'); $pM = strpos($form, 'id="message-wrap"'); $check($pT !== false && $pT < $pS && $pS < $pG && $pG < $pM, 'Feldposition: vor dem Betreff, zwischen Betreff und Beschreibung'); $posTopicField = strpos($form, 'Gerätetyp'); $check($posTopicField !== false && $posTopicField > $posI && $posTopicField < $posC, 'Feld für Abschnitt 1 (Themenauswahl) steht vor den Kontaktdaten'); $contactBlock = preg_match('#class="contact-title".*?(?=
query("SELECT form_config FROM departments WHERE id = {$deptB}")->fetchColumn(), true); $cfg['subject_width'] = 'twothirds'; $cfg['subject_show'] = false; $cfg['message_show'] = false; $pdoS->prepare('UPDATE departments SET form_config = ? WHERE id = ?')->execute([json_encode($cfg), $deptB]); $cj = tempnam(sys_get_temp_dir(), 'dformc'); [, $form] = $req('GET', '/ticket_new.php', [], $cj); $post = ['csrf_token' => $csrf($form), 'name' => 'Kunde', 'email' => 'kunde@example.org', 'help_topic_id' => (string) $topicB]; [, $first] = $req('POST', '/ticket_new.php', $post, $cj); $check((int) $db()->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === 0, 'Fehlendes Pflichtfeld der Abteilung verhindert das Ticket'); $post['csrf_token'] = $csrf($first) ?: $post['csrf_token']; [, $noConsent] = $req('POST', '/ticket_new.php', $post + ['custom_' . $fieldB => 'GER-42'], $cj); $check((int) $db()->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === 0, 'Ohne Zustimmung (Pflicht-Checkbox) wird kein Ticket angelegt'); $post['csrf_token'] = $csrf($noConsent) ?: $post['csrf_token']; $req('POST', '/ticket_new.php', $post + ['custom_' . $fieldB => 'GER-42', 'custom_' . $consentId => '1'], $cj); $t = $db()->query('SELECT * FROM tickets ORDER BY id DESC LIMIT 1')->fetch(); $check($t && (int) $t['department_id'] === $deptB, 'Ticket landet in der Abteilung (ohne das globale Pflichtfeld auszufüllen)'); $check($t && $t['subject'] === 'Anfrage Technik', 'Ohne Betrefffeld wird ein Betreff erzeugt'); $val = $db()->query("SELECT value FROM ticket_field_values WHERE custom_field_id = {$fieldB}")->fetchColumn(); $check($val === 'GER-42', 'Wert des Abteilungsfeldes ist am Ticket gespeichert'); $tm = (string) $db()->query('SELECT message FROM ticket_threads ORDER BY id DESC LIMIT 1')->fetchColumn(); $check(str_contains($tm, 'Gerätenummer: GER-42') && str_contains($tm, 'Nutzerbedingungen: Ja'), 'Ohne Beschreibungsfeld wird der Text aus den eigenen Feldern gebildet'); $globalVal = $db()->query("SELECT COUNT(*) FROM ticket_field_values WHERE custom_field_id = {$globalField}")->fetchColumn(); $check((int) $globalVal === 0, 'Globales Feld wird bei eigenem Formular nicht gespeichert'); // 4) Standardformular bleibt unverändert [, $form] = $req('GET', '/ticket_new.php', [], $cj); $post = ['csrf_token' => $csrf($form), 'name' => 'Kunde', 'email' => 'kunde@example.org', 'help_topic_id' => (string) $topicA, 'subject' => 'Frage', 'message' => 'Text']; [, $second] = $req('POST', '/ticket_new.php', $post, $cj); $post['csrf_token'] = $csrf($second) ?: $post['csrf_token']; $check((int) $db()->query("SELECT COUNT(*) FROM tickets WHERE department_id = {$deptA}")->fetchColumn() === 0, 'Andere Abteilung nutzt das Standardformular: globales Pflichtfeld bleibt Pflicht'); $req('POST', '/ticket_new.php', $post + ['custom_' . $globalField => 'ok'], $cj); $check((int) $db()->query("SELECT COUNT(*) FROM tickets WHERE department_id = {$deptA}")->fetchColumn() === 1, 'Standardformular funktioniert mit dem globalen Feld'); // 4b) Regel-Aktion "Ticket schließen" $rdb = $db(); $rdb->exec("INSERT INTO ticket_rules (name, match_type, is_active, sort_order, created_at) VALUES ('Werbung schließen', 'all', 1, 1, '" . date('Y-m-d H:i:s') . "')"); $ruleId = (int) $rdb->lastInsertId(); $rdb->exec("INSERT INTO ticket_rule_conditions (rule_id, field, operator, value) VALUES ({$ruleId}, 'subject', 'contains', 'Werbung')"); $rdb->exec("INSERT INTO ticket_rule_actions (rule_id, action_type, action_value) VALUES ({$ruleId}, 'close', '1')"); [, $form] = $req('GET', '/ticket_new.php', [], $cj); $spam = ['csrf_token' => $csrf($form), 'name' => 'Werber', 'email' => 'werber@example.org', 'help_topic_id' => (string) $topicA, 'subject' => 'Super Werbung für dich', 'message' => 'Kauf mich', 'custom_' . $globalField => 'ok']; $req('POST', '/ticket_new.php', $spam, $cj); $closed = $db()->query("SELECT status, closed_at FROM tickets WHERE requester_email = 'werber@example.org'")->fetch(); $check($closed && $closed['status'] === 'closed' && !empty($closed['closed_at']), 'Regel-Aktion „Ticket schließen“ schließt das neue Ticket sofort'); $check((int) $db()->query("SELECT COUNT(*) FROM activity_log WHERE action LIKE '%automatisch geschlossen%'")->fetchColumn() === 1, 'Das automatische Schließen steht im Aktivitätsprotokoll'); [, $rulesPage] = $req('GET', '/staff/ticket_rules.php?new=1', [], $jar); $check(str_contains($rulesPage, 'Ticket schließen (wird angelegt'), 'Admin-Oberfläche bietet die Aktion „Ticket schließen“ an'); // 5) Abschalten: wieder Standardformular $db()->exec("UPDATE departments SET own_form = 0 WHERE id = {$deptB}"); [, $form] = $req('GET', '/ticket_new.php', [], $cj); $check(!str_contains($form, '"title":"Störungsmeldung"'), 'Deaktiviertes Abteilungsformular wird nicht mehr ausgeliefert'); } finally { proc_terminate($proc); proc_close($proc); } usleep(300000); $rm = function (string $d) use (&$rm) { foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") && !is_link("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); } @rmdir($d); }; if (str_starts_with($tmp, sys_get_temp_dir() . '/deskhub-dform-')) $rm($tmp); @unlink($log); if ($failures) { fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n"); exit(1); } echo "Department form checks passed ({$total} Prüfungen).\n";