Upload via GUI (364 Dateien)
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft, dass DeskHub nirgends in einem Rahmen (iframe) eingebettet werden darf: Die PHP-Seiten senden X-Frame-Options DENY und
|
||||
* frame-ancestors 'none', und die .htaccess überschreibt das nicht mit einer schwächeren Einstellung (früher SAMEORIGIN).
|
||||
*/
|
||||
$root = dirname(__DIR__);
|
||||
$failures = [];
|
||||
$check = function (bool $ok, string $what) use (&$failures): void { if (!$ok) $failures[] = $what; };
|
||||
|
||||
$bootstrap = (string) file_get_contents($root . '/includes/bootstrap.php');
|
||||
$check(str_contains($bootstrap, "header('X-Frame-Options: DENY');"), 'bootstrap.php sendet X-Frame-Options: DENY');
|
||||
$check(str_contains($bootstrap, "frame-ancestors 'none'"), "bootstrap.php sendet frame-ancestors 'none'");
|
||||
|
||||
$htaccess = (string) file_get_contents($root . '/.htaccess');
|
||||
$check(preg_match('/^Header always set X-Frame-Options "DENY"$/m', $htaccess) === 1, '.htaccess setzt X-Frame-Options auf DENY');
|
||||
$check(stripos($htaccess, 'SAMEORIGIN') === false, '.htaccess enthält kein SAMEORIGIN mehr');
|
||||
$check(!preg_match('/^Header always set Content-Security-Policy/m', $htaccess), '.htaccess ersetzt die Content-Security-Policy der Anwendung nicht');
|
||||
|
||||
// Keine Seite darf die Rahmen-Header selbst lockern
|
||||
$loosened = [];
|
||||
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS));
|
||||
foreach ($it as $f) {
|
||||
$p = str_replace(chr(92), '/', $f->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";
|
||||
@@ -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)));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft die Plugin-Webseite: WordPress-Plugin und Chrome-Erweiterung als Apps (Version aus dem Paket, Auslieferung) und dass das
|
||||
* Vorschaubild eines Plugins bei neuen Versionen erhalten bleibt, auch wenn die alte Version vorher entfernt wurde.
|
||||
*/
|
||||
|
||||
if (!class_exists('ZipArchive')) { echo "Plugin-Webseite (Apps): übersprungen, PHP-ZIP fehlt.\n"; exit(0); }
|
||||
|
||||
$tmp = sys_get_temp_dir() . '/deskhub-site-apps-' . bin2hex(random_bytes(4));
|
||||
mkdir($tmp . '/storage', 0755, true);
|
||||
putenv('PLUGIN_SITE_CONFIG=' . $tmp . '/gibtesnicht.php');
|
||||
putenv('PLUGIN_SITE_STORAGE=' . $tmp . '/storage');
|
||||
putenv('PLUGIN_SITE_BASE_URL=https://plugins.example.test');
|
||||
putenv('PLUGIN_SITE_TESTING=1');
|
||||
|
||||
require_once __DIR__ . '/fixtures/paths.php';
|
||||
$siteDir = website_dir_or_skip('plugin_website_apps');
|
||||
require_once $siteDir . '/inc/lib.php';
|
||||
|
||||
$failures = [];
|
||||
$total = 0;
|
||||
$check = function (bool $ok, string $what) use (&$failures, &$total): void {
|
||||
$total++;
|
||||
if (!$ok) $failures[] = $what;
|
||||
};
|
||||
$makeZip = function (array $files): string {
|
||||
$p = tempnam(sys_get_temp_dir(), 'pwa') . '.zip';
|
||||
$z = new ZipArchive();
|
||||
$z->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' => "<?php\n/**\n * Plugin Name: DeskHub Tickets\n * Version: {$v}\n */\n// {$pad}", 'deskhub-tickets/readme.txt' => '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' => '<?php echo 1;'])) === null, 'PHP ohne "Plugin Name:" ist kein WordPress-Plugin');
|
||||
$r = site_publish_app('wordpress', $wp('1.2.2'), '', 'Neue Farben');
|
||||
$check($r['ok'] && $r['version'] === '1.2.2', 'WordPress-Plugin ohne eingetippte Version: ' . json_encode($r));
|
||||
$check(!site_publish_app('wordpress', $wp('1.2.2'), '', '')['ok'], 'dieselbe Version wird nicht doppelt veröffentlicht');
|
||||
$check(site_publish_app('wordpress', $wp('1.3.0'), '', '')['ok'], 'zweite Version wird angenommen');
|
||||
$check(site_app_release('wordpress')['version'] === '1.3.0', 'neueste WordPress-Version wird geliefert');
|
||||
$bad = site_publish_app('wordpress', $makeZip(['x.txt' => $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' => '<?php // ' . $pad]);
|
||||
$check(site_publish('plugin', $plug('1.0.0'), [], $png, 'vorschau.png')['ok'], 'Plugin mit Bild veröffentlicht');
|
||||
$bild = site_item('plugin', 'bildtest')['preview'] ?? null;
|
||||
$check($bild !== null, 'Bild ist gespeichert');
|
||||
$check(site_publish('plugin', $plug('1.0.1'))['ok'] && (site_item('plugin', 'bildtest')['preview'] ?? null) === $bild, 'Update ohne Bild behält das Bild');
|
||||
// alte Version entfernen, bevor die neue hochgeladen wird: das Bild darf nicht verloren gehen
|
||||
foreach (site_item('plugin', 'bildtest')['versions'] as $v) site_delete_version((int) $v['id']);
|
||||
$check(site_item('plugin', 'bildtest') === null, 'ohne Version ist der Eintrag unsichtbar');
|
||||
$check(site_publish('plugin', $plug('1.1.0'))['ok'] && (site_item('plugin', 'bildtest')['preview'] ?? null) === $bild, 'Bild bleibt, auch wenn alle Versionen vorher entfernt wurden');
|
||||
$check(is_file($tmp . '/storage/previews/' . $bild), 'Bilddatei liegt noch im Speicher');
|
||||
$id = (int) site_item('plugin', 'bildtest')['id'];
|
||||
$check(site_set_preview($id, null, null)['ok'] && empty(site_item('plugin', 'bildtest')['preview']) && !is_file($tmp . '/storage/previews/' . $bild), 'Bild lässt sich entfernen');
|
||||
copy($png, $tmp . '/neu.png');
|
||||
$check(site_set_preview($id, $tmp . '/neu.png', 'neu.png')['ok'] && !empty(site_item('plugin', 'bildtest')['preview']), 'Bild lässt sich hinzufügen');
|
||||
|
||||
$rm = function (string $d) use (&$rm) {
|
||||
foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); }
|
||||
@rmdir($d);
|
||||
};
|
||||
$rm($tmp);
|
||||
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo "Plugin website apps checks passed ({$total} Prüfungen).\n";
|
||||
@@ -0,0 +1,179 @@
|
||||
<?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";
|
||||
Reference in New Issue
Block a user