205 lines
18 KiB
PHP
205 lines
18 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Eigenes Ticket-Formular pro Abteilung: Verwaltungsseite, Anzeige für Kunden (Abteilungsfelder, Texte, ohne Betreff),
|
|
* serverseitige Prüfung (Pflichtfelder der Abteilung, kein Einfluss globaler Felder) und Abgrenzung zum Standardformular.
|
|
* Echter Webserver, SQLite.
|
|
*/
|
|
|
|
if (!extension_loaded('pdo_sqlite') || !function_exists('curl_init')) { echo "Abteilungsformular-Test übersprungen: pdo_sqlite oder curl fehlt.\n"; exit(0); }
|
|
|
|
$root = dirname(__DIR__);
|
|
$tmp = sys_get_temp_dir() . '/deskhub-dform-' . bin2hex(random_bytes(4));
|
|
mkdir($tmp, 0755, true);
|
|
$skip = '#^(config/config\.php|database/[^/]+\.sqlite[^/]*|backups|uploads|tests|plugins/[^/]+|\.git)(/|$)#';
|
|
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST) as $f) {
|
|
$rel = str_replace(chr(92), '/', substr($f->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', '<?php return ' . var_export([
|
|
'app_key' => 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)) <b>x</b>', '" . 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, '<a href="https://example.org/agb" target="_blank" rel="noopener noreferrer">Nutzungsbedingungen</a>'), 'Hinweistext zeigt den Link an');
|
|
$check(!str_contains($form, 'href="javascript:') && !str_contains($form, '<b>x</b>'), '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".*?(?=<div class="ticket-form-section)#s', $form, $cb) ? $cb[0] : '';
|
|
$check(str_contains($contactBlock, 'Telefon') && str_contains($contactBlock, 'E-Mail-Adresse'), 'Feld mit Abschnitt Kontaktdaten steht im Kontakt-Bereich');
|
|
if (preg_match('#<script>\s*// Formular passt sich.*?</script>#s', $form, $js) && trim((string) shell_exec('node -v 2>' . (DIRECTORY_SEPARATOR === '\\' ? 'NUL' : '/dev/null')) ) !== '') {
|
|
$jf = tempnam(sys_get_temp_dir(), 'dformjs') . '.js'; file_put_contents($jf, preg_replace('#</?script>#', '', $js[0]));
|
|
exec('node --check ' . escapeshellarg($jf) . ' 2>&1', $out, $rc); @unlink($jf);
|
|
$check($rc === 0, 'Das Umschalt-Skript hat gültige JavaScript-Syntax: ' . implode(' ', $out));
|
|
}
|
|
$check(str_contains($form, 'dept-section') && preg_match('#dept-section" data-dept="' . $deptB . '".*?Abrechnung.*?Kostenstelle#s', $form) === 1, 'Eigener Abschnitt „Abrechnung“ mit seinem Feld ist vorhanden');
|
|
$check(str_contains($form, 'data-dept="' . $deptB . '"') && str_contains($form, 'data-dept=""'), 'Felder sind der Abteilung bzw. allen zugeordnet');
|
|
|
|
// 3) Absenden mit eigenem Formular (Betreff-Feld ist standardmäßig an; hier ohne Betreff senden wie bei ausgeblendetem Feld)
|
|
$pdoS = $db();
|
|
$cfg = json_decode((string) $pdoS->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";
|