Upload via GUI (258 Dateien)

This commit is contained in:
2026-10-16 19:12:24 +00:00
parent de06066052
commit 5a602de484
50 changed files with 8948 additions and 0 deletions
@@ -0,0 +1,353 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/includes/db.php';
require_once dirname(__DIR__) . '/database/migrate.php';
$root = dirname(__DIR__);
$tempDb = tempnam(sys_get_temp_dir(), 'deskhub-web-');
if ($tempDb === false) {
throw new RuntimeException('Temporäre Web-Testdatenbank konnte nicht erstellt werden.');
}
$schema = file_get_contents($root . '/database/schema.sql');
if ($schema === false) {
throw new RuntimeException('Datenbankschema konnte nicht gelesen werden.');
}
$pdo = new PDO('sqlite:' . $tempDb);
$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($schema));
$migrations = run_migrations($pdo, true);
if ($migrations['errors']) {
throw new RuntimeException('Frische SQLite-Migration fehlgeschlagen: ' . implode('; ', $migrations['errors']));
}
$password = 'Web-Smoke-Password-42';
$hash = password_hash($password, PASSWORD_DEFAULT);
$now = new DateTimeImmutable();
$createdAt = $now->modify('-2 hours')->format('Y-m-d H:i:s');
$respondedAt = $now->modify('-30 minutes')->format('Y-m-d H:i:s');
$resolvedAt = $now->modify('-10 minutes')->format('Y-m-d H:i:s');
$nowSql = $now->format('Y-m-d H:i:s');
// Hintergrundjobs sind nicht Teil dieses Seitentests; im Dev-Server würden sie die Antwort blockieren.
$pdo->exec("INSERT OR REPLACE INTO settings (setting_key, setting_value) VALUES ('pseudo_cron_enabled', '0')");
$pdo->prepare('UPDATE staff SET password_hash = ?, is_active = 1 WHERE id = 1')->execute([$hash]);
$pdo->prepare(
"INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at)
VALUES ('Web Agent', 'web-agent@example.test', ?, 'agent', 1, 1, ?)"
)->execute([$hash, $nowSql]);
$agentId = (int) $pdo->lastInsertId();
$pdo->prepare(
"INSERT INTO users (name, email, password_hash, created_at)
VALUES ('Web Kunde', 'web-customer@example.test', ?, ?)"
)->execute([$hash, $nowSql]);
$userId = (int) $pdo->lastInsertId();
$pdo->prepare(
"INSERT INTO tickets
(ticket_number, subject, user_id, requester_name, requester_email, department_id,
priority_id, assigned_staff_id, status, source, first_responded_at, resolution_due_at,
resolved_at, created_at, updated_at)
VALUES ('WEB-1001', 'Web-Smoke Ticket', ?, 'Web Kunde', 'web-customer@example.test',
1, 2, ?, 'resolved', 'web', ?, ?, ?, ?, ?)"
)->execute([$userId, $agentId, $respondedAt, $resolvedAt, $resolvedAt, $createdAt, $nowSql]);
$ticketId = (int) $pdo->lastInsertId();
$pdo->prepare(
"INSERT INTO ticket_threads
(ticket_id, author_type, user_id, message, is_internal_note, created_at)
VALUES (?, 'customer', ?, 'Realistische Smoke-Test-Anfrage', 0, ?)"
)->execute([$ticketId, $userId, $createdAt]);
$pdo->prepare(
"INSERT INTO activity_log (ticket_id, staff_id, action, created_at)
VALUES (?, ?, 'Mitarbeiter zugewiesen: Web Agent', ?)"
)->execute([$ticketId, $agentId, $createdAt]);
$pdo = null;
$port = random_int(19000, 19999);
$command = [PHP_BINARY, '-d', 'max_execution_time=15', '-S', "127.0.0.1:$port", '-t', $root];
$previousDbPath = getenv('DESKHUB_SQLITE_PATH');
// Temporäres Plugin für den Seitentest (Plugin-Ordner des Projekts bleibt unberührt)
$smokePluginDir = sys_get_temp_dir() . '/deskhub-smoke-plugins-' . bin2hex(random_bytes(4));
mkdir($smokePluginDir . '/smoke/pages', 0755, true);
file_put_contents($smokePluginDir . '/smoke/plugin.json', json_encode(['slug' => 'smoke', 'name' => 'Smoke', 'version' => '1.0.0', 'permissions' => ['ui.menu', 'api.endpoints']]));
file_put_contents($smokePluginDir . '/smoke/plugin.php', "<?php plugin_register_menu('Smoke-Seite', 'hello'); plugin_register_api('ping', fn(\$s, \$r) => ['pong' => true]);");
file_put_contents($smokePluginDir . '/smoke/pages/hello.php', '<p>Hallo aus dem Plugin</p>');
file_put_contents($smokePluginDir . '/smoke/pages/geheim.php', '<p>nicht registriert</p>');
$smokePdo = new PDO('sqlite:' . $tempDb);
$smokePdo->exec("INSERT INTO plugins (slug, name, version, is_active, installed_at, approved_permissions) VALUES ('smoke', 'Smoke', '1.0.0', 1, datetime('now'), 'api.endpoints,ui.menu')");
// Mitgeliefertes Plugin "Tagesbericht" mitladen, damit seine Einstellungsseite im echten Webserver geprüft wird
require_once __DIR__ . '/fixtures/paths.php';
$__tagSrc = plugin_source_dir('tagesbericht');
foreach ($__tagSrc === null ? [] : new RecursiveIteratorIterator(new RecursiveDirectoryIterator($__tagSrc, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST) as $__f) {
$__dest = $smokePluginDir . '/tagesbericht/' . str_replace(chr(92), '/', substr(str_replace(chr(92), '/', $__f->getPathname()), strlen($__tagSrc) + 1));
$__f->isDir() ? @mkdir($__dest, 0755, true) : (@mkdir(dirname($__dest), 0755, true) + copy($__f->getPathname(), $__dest));
}
if ($__tagSrc !== null) $smokePdo->exec("INSERT INTO plugins (slug, name, version, is_active, installed_at, approved_permissions) VALUES ('tagesbericht', 'Tagesbericht', '1.0.0', 1, datetime('now'), 'hooks.cron,ui.menu')");
$smokePdo = null;
putenv('DESKHUB_PLUGINS_DIR=' . $smokePluginDir);
// Offizieller Katalog (geprüfter Modus) für den Seitentest
$smokeCnf = tempnam(sys_get_temp_dir(), 'cnf');
file_put_contents($smokeCnf, "[req]
distinguished_name=req_dn
[req_dn]
");
$smokeKey = openssl_pkey_new(['config' => $smokeCnf, 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
$smokeDefaults = tempnam(sys_get_temp_dir(), 'cd');
file_put_contents($smokeDefaults, '<?php return [];'); // zunächst ohne offiziellen Katalog
putenv('DESKHUB_CATALOG_DEFAULTS=' . $smokeDefaults);
putenv('DESKHUB_TESTING=1');
// App-Download: Android-Datei vorbereiten (Windows bleibt bewusst leer)
$smokeAppsDir = sys_get_temp_dir() . '/deskhub-smoke-apps-' . bin2hex(random_bytes(4));
mkdir($smokeAppsDir, 0755, true);
$smokeApk = 'PK' . random_bytes(3000);
file_put_contents($smokeAppsDir . '/DeskHub.apk', $smokeApk);
putenv('DESKHUB_APPS_DIR=' . $smokeAppsDir);
putenv('DESKHUB_SQLITE_PATH=' . $tempDb);
// Serverlog in Datei statt Pipe: Ein nicht geleerter Pipe-Puffer würde den Server nach vielen Requests blockieren.
$serverLogFile = tempnam(sys_get_temp_dir(), 'deskhub-web-log-');
$process = proc_open(
$command,
[['pipe', 'r'], ['file', $serverLogFile, 'w'], ['file', $serverLogFile, 'a']],
$pipes,
$root,
null,
);
putenv($previousDbPath === false ? 'DESKHUB_SQLITE_PATH' : 'DESKHUB_SQLITE_PATH=' . $previousDbPath);
if (!is_resource($process)) {
@unlink($tempDb);
throw new RuntimeException('PHP-Web-Testserver konnte nicht gestartet werden.');
}
try {
wait_for_server($port);
$publicCookies = [];
assert_page(browser_request($port, 'GET', '/home.php', $publicCookies), 'Supportcenter-Startseite');
foreach ([
['admin@example.com', 'Web Agent'],
['web-agent@example.test', 'Statistiken'],
] as [$email, $reportsMarker]) {
$cookies = [];
login($port, '/staff/login.php', $email, $password, $cookies, '/staff/reports.php');
assert_page(browser_request($port, 'GET', '/staff/reports.php', $cookies), $reportsMarker);
// Die Standardliste zeigt nur offene Tickets; das Smoke-Ticket ist gelöst und steht unter "Alle".
assert_page(browser_request($port, 'GET', '/staff/tickets.php?tab=all', $cookies), 'WEB-1001');
if ($email === 'admin@example.com') {
// Verwaltungsseiten im osTicket-Stil: Liste, Neu-Formular und Bearbeiten-Formular müssen ohne PHP-Fehler laden.
foreach ([
'/staff/email_addresses.php' => 'E-Mail-Adressen', '/staff/email_addresses.php?new=1' => 'Abgehend',
'/staff/help_topics.php' => 'Hilfethemen', '/staff/help_topics.php?new=1' => 'Optionen bei neuem Ticket',
'/staff/departments.php' => 'Abteilungen', '/staff/departments.php?new=1' => 'Mitglieder', '/staff/departments.php?edit=1' => 'Abteilung aktualisieren',
'/staff/staff_manage.php' => 'Mitarbeiter', '/staff/staff_manage.php?new=1' => 'Berechtigungen', '/staff/staff_manage.php?edit=1' => 'Erweiterter Zugriff',
'/staff/teams.php' => 'Teams', '/staff/teams.php?new=1' => 'Teaminformationen',
'/staff/roles.php' => 'Rollen', '/staff/roles.php?new=1' => 'Rolleninformationen',
'/staff/priorities.php' => 'SLA', '/staff/priorities.php?new=1' => 'SLA-Zeiten',
'/staff/custom_lists.php' => 'Listen', '/staff/custom_lists.php?new=1' => 'Einträge',
'/staff/site_pages.php' => 'Seiten', '/staff/site_pages.php?new=1' => 'Seiteninformationen',
'/staff/business_hours.php' => 'Feiertage', '/staff/business_hours.php?tab=holidays' => 'Feiertage',
'/staff/update.php' => 'Datenbank aktualisieren', '/staff/plugins.php' => 'Plugin-Katalog', '/staff/backup.php' => 'Vorhandene Backups', '/staff/security_check.php' => 'Sicherheitsprüfung', '/staff/import_osticket.php' => 'osTicket Import', '/staff/ticket_rules.php' => 'Ticket-Regeln', '/staff/ticket_rules.php?new=1' => 'Regelinformationen',
] as $path => $marker) {
$response = browser_request($port, 'GET', $path, $cookies);
assert_page($response, $marker);
foreach (['Fatal error', 'Warning:', 'Notice:', 'Deprecated:', 'Uncaught'] as $bad) {
if (stripos($response[1], $bad) !== false) {
throw new RuntimeException("Seite {$path} enthält eine PHP-Meldung ({$bad}).");
}
}
}
}
// Abteilung löschen, die noch gebraucht wird: Meldung statt HTTP 500
if ($email === 'admin@example.com') {
$dp = browser_request($port, 'GET', '/staff/departments.php', $cookies);
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $dp[1], $dm);
$del = browser_request($port, 'POST', '/staff/departments.php', $cookies, ['action' => 'delete', 'id' => '1', 'csrf_token' => $dm[1] ?? '']);
if ($del[0] !== 302) throw new RuntimeException('Löschen einer benutzten Abteilung liefert HTTP ' . $del[0] . ' statt einer Weiterleitung mit Meldung.');
$after = browser_request($port, 'GET', '/staff/departments.php', $cookies);
if (stripos($after[1], 'kann nicht gelöscht werden') === false) throw new RuntimeException('Nach dem Löschversuch fehlt die verständliche Meldung.');
}
// App-Download: Seite für alle Mitarbeiter, Datei nur mit Anmeldung
assert_page(browser_request($port, 'GET', '/staff/apps.php', $cookies), 'Android-App herunterladen');
$dl = browser_request($port, 'GET', '/staff/app_download.php?platform=android', $cookies);
if ($dl[0] !== 200 || $dl[1] !== $smokeApk) throw new RuntimeException('App-Download liefert nicht die hinterlegte Datei.');
if (stripos(implode("
", $dl[2]), 'application/vnd.android.package-archive') === false) throw new RuntimeException('App-Download hat den falschen Dateityp.');
if (browser_request($port, 'GET', '/staff/app_download.php?platform=windows', $cookies)[0] !== 404) throw new RuntimeException('Nicht hinterlegte App sollte 404 liefern.');
if (browser_request($port, 'GET', '/staff/app_download.php?platform=../../config/config', $cookies)[0] !== 404) throw new RuntimeException('Unbekannte Plattform sollte 404 liefern.');
$noCookies = [];
$anon = browser_request($port, 'GET', '/staff/app_download.php?platform=android', $noCookies);
if ($anon[0] !== 302 || $anon[1] === $smokeApk) throw new RuntimeException('Ohne Anmeldung darf die App nicht ladbar sein (Status ' . $anon[0] . ').');
if (browser_request($port, 'GET', '/app_downloads/DeskHub.apk', $noCookies)[0] === 200) throw new RuntimeException('Der App-Ordner ist direkt per URL erreichbar.');
if ($email === 'admin@example.com') {
assert_page(browser_request($port, 'GET', '/staff/plugin.php?p=smoke&page=hello', $cookies), 'Hallo aus dem Plugin');
// Ohne Vorgaben: manueller Upload sichtbar, kein strenger Modus
$pl = browser_request($port, 'GET', '/staff/plugins.php', $cookies);
if (stripos($pl[1], 'Eigenes Plugin hochladen') === false || stripos($pl[1], 'Geprüfte Plugins') !== false) throw new RuntimeException('Plugin-Seite ohne offiziellen Katalog zeigt falschen Modus.');
if ($__tagSrc !== null) assert_page(browser_request($port, 'GET', '/staff/plugin.php?p=tagesbericht&page=einstellungen', $cookies), 'Testbericht an mich senden');
assert_page(browser_request($port, 'GET', '/staff/tickets.php?tab=all', $cookies), 'Smoke-Seite'); // Menüpunkt in der Navigation
foreach (['/staff/plugin.php?p=smoke&page=geheim', '/staff/plugin.php?p=smoke&page=../plugin', '/staff/plugin.php?p=unbekannt&page=hello'] as $blocked) {
if (browser_request($port, 'GET', $blocked, $cookies)[0] !== 404) throw new RuntimeException("Plugin-Seite {$blocked} sollte 404 liefern.");
}
// Mit offiziellem Katalog: geprüfter Modus, kein manueller Upload
file_put_contents($smokeDefaults, '<?php return ' . var_export(['plugins_url' => 'https://katalog.invalid/catalog.php?type=plugins', 'themes_url' => 'https://katalog.invalid/catalog.php?type=themes', 'publisher_key' => openssl_pkey_get_details($smokeKey)['key']], true) . ';');
$pl = browser_request($port, 'GET', '/staff/plugins.php', $cookies);
if (stripos($pl[1], 'Geprüfte Plugins') === false || stripos($pl[1], 'Eigenes Plugin hochladen') === false || stripos($pl[1], 'Eigene Quellen erlauben') !== false) throw new RuntimeException('Plugin-Seite zeigt im geprüften Modus die falschen Bereiche.');
file_put_contents($smokeDefaults, '<?php return [];');
}
$lockPdo = new PDO('sqlite:' . $tempDb);
$lockPdo->exec("UPDATE tickets SET locked_by = NULL, locked_at = NULL WHERE ticket_number = 'WEB-1001'");
$lockPdo = null;
assert_page(
browser_request($port, 'GET', '/staff/ticket_view.php?n=WEB-1001', $cookies),
'Web-Smoke Ticket'
);
}
$customerCookies = [];
login($port, '/login.php', 'web-customer@example.test', $password, $customerCookies, '/home.php');
assert_page(browser_request($port, 'GET', '/home.php', $customerCookies), 'Meine Tickets');
assert_page(browser_request($port, 'GET', '/dashboard.php', $customerCookies), 'WEB-1001');
assert_page(
browser_request($port, 'GET', '/ticket_view.php?n=WEB-1001', $customerCookies),
'Web-Smoke Ticket'
);
echo "Web smoke integration test passed for admin, agent, and customer.\n";
} catch (Throwable $e) {
$serverLog = trim((string) @file_get_contents($serverLogFile));
throw new RuntimeException(
$e->getMessage() . ($serverLog !== '' ? "\nPHP-Testserver:\n" . $serverLog : ''),
0,
$e
);
} finally {
proc_terminate($process);
foreach ($pipes as $pipe) {
if (is_resource($pipe)) {
fclose($pipe);
}
}
proc_close($process);
@unlink($tempDb);
@unlink($serverLogFile);
@unlink($smokeAppsDir . '/DeskHub.apk'); @rmdir($smokeAppsDir);
@unlink($smokeDefaults); @unlink($smokeCnf);
foreach (['/smoke/pages/hello.php', '/smoke/pages/geheim.php', '/smoke/pages', '/smoke/plugin.json', '/smoke/plugin.php', '/smoke'] as $__p) { $__f = $smokePluginDir . $__p; is_dir($__f) ? @rmdir($__f) : @unlink($__f); }
if (is_dir($smokePluginDir)) {
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($smokePluginDir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $__f) {
$__f->isDir() ? @rmdir($__f->getPathname()) : @unlink($__f->getPathname());
}
@rmdir($smokePluginDir);
}
}
function wait_for_server(int $port): void
{
for ($attempt = 0; $attempt < 40; $attempt++) {
$socket = @fsockopen('127.0.0.1', $port, $errno, $error, 0.1);
if ($socket) {
fclose($socket);
return;
}
usleep(100000);
}
throw new RuntimeException('PHP-Web-Testserver ist nicht erreichbar.');
}
function login(
int $port,
string $path,
string $email,
string $password,
array &$cookies,
string $expectedLocation
): void {
[$status, $body] = browser_request($port, 'GET', $path, $cookies);
if ($status !== 200 || !preg_match('/name="csrf_token" value="([^"]+)"/', $body, $match)) {
throw new RuntimeException("Loginformular {$path} konnte nicht geladen werden.");
}
[$status, , $headers] = browser_request($port, 'POST', $path, $cookies, [
'csrf_token' => html_entity_decode($match[1], ENT_QUOTES),
'action' => 'login',
'email' => $email,
'password' => $password,
]);
$location = response_header($headers, 'Location');
if ($status !== 302 || $location !== $expectedLocation) {
throw new RuntimeException(
"Login für {$email} fehlgeschlagen (HTTP {$status}, Location " . ($location ?? 'fehlt') . ').'
);
}
}
function browser_request(
int $port,
string $method,
string $path,
array &$cookies,
?array $form = null
): array {
$headers = ['Accept: text/html'];
if ($cookies) {
$cookiePairs = [];
foreach ($cookies as $name => $value) {
$cookiePairs[] = $name . '=' . $value;
}
$headers[] = 'Cookie: ' . implode('; ', $cookiePairs);
}
$content = null;
if ($form !== null) {
$content = http_build_query($form);
$headers[] = 'Content-Type: application/x-www-form-urlencoded';
}
$context = stream_context_create([
'http' => [
'method' => $method,
'header' => implode("\r\n", $headers),
'content' => $content,
'ignore_errors' => true,
'follow_location' => 0,
'timeout' => 15,
],
]);
$body = file_get_contents("http://127.0.0.1:$port$path", false, $context);
$responseHeaders = $http_response_header ?? [];
preg_match('/\s(\d{3})\s/', $responseHeaders[0] ?? '', $statusMatch);
foreach ($responseHeaders as $header) {
if (stripos($header, 'Set-Cookie:') !== 0) {
continue;
}
$cookie = trim(substr($header, strlen('Set-Cookie:')));
[$pair] = explode(';', $cookie, 2);
[$name, $value] = array_pad(explode('=', $pair, 2), 2, '');
$cookies[$name] = $value;
}
$status = (int) ($statusMatch[1] ?? 0);
echo "{$method} {$path} -> {$status}\n";
return [$status, $body === false ? '' : $body, $responseHeaders];
}
function response_header(array $headers, string $name): ?string
{
foreach ($headers as $header) {
if (stripos($header, $name . ':') === 0) {
return trim(substr($header, strlen($name) + 1));
}
}
return null;
}
function assert_page(array $response, string $marker): void
{
[$status, $body] = $response;
if ($status !== 200) {
throw new RuntimeException("Seite mit Marker {$marker} liefert HTTP {$status}.");
}
if (stripos($body, $marker) === false) {
throw new RuntimeException("Erwarteter Seiteninhalt fehlt: {$marker}.");
}
}