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', " ['pong' => true]);"); file_put_contents($smokePluginDir . '/smoke/pages/hello.php', '

Hallo aus dem Plugin

'); file_put_contents($smokePluginDir . '/smoke/pages/geheim.php', '

nicht registriert

'); $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, ' 'Mail-Warteschlange', '/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/login_log.php' => 'Anmelde-Wächter', '/staff/problems.php' => 'Neues Problem', '/staff/reports.php' => 'Jetzt zu tun', '/staff/reports.php?tab=activity' => 'Ticketaktivität', '/staff/reports.php?tab=analysis' => 'Statistiken', '/staff/problems.php?tab=create' => 'Neues Problem erfassen', '/staff/login_alert.php?t=abc' => 'ungültig, abgelaufen', '/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: Die Apps kommen von der Plugin-Seite (hier ohne erreichbaren Katalog): Seite lädt, Download liefert 404, ohne Anmeldung 302 assert_page(browser_request($port, 'GET', '/staff/apps.php', $cookies), 'Apps für Mitarbeiter'); if (browser_request($port, 'GET', '/staff/app_download.php?platform=android', $cookies)[0] !== 404) throw new RuntimeException('Ohne veröffentlichte App sollte der Download 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, ' '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, '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}."); } }