354 lines
19 KiB
PHP
354 lines
19 KiB
PHP
<?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}.");
|
|
}
|
|
}
|