setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $fresh->exec('PRAGMA foreign_keys = ON'); $fresh->exec(convert_schema_to_sqlite((string) file_get_contents($root . '/database/schema.sql'))); $migrated = run_migrations($fresh, true); if ($migrated['errors']) { fwrite(STDERR, 'Testdatenbank konnte nicht aufgebaut werden: ' . implode('; ', $migrated['errors']) . "\n"); exit(1); } $fresh = null; } $pdo = new PDO('sqlite:' . $tempDb); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->exec('PRAGMA foreign_keys = ON'); $password = 'MobileApi-Test-Password-42'; $pdo->prepare('UPDATE staff SET password_hash = ?, is_active = 1 WHERE id = 1') ->execute([password_hash($password, PASSWORD_DEFAULT)]); $agentPassword = 'MobileApi-Agent-Password-42'; $agentHash = password_hash($agentPassword, PASSWORD_DEFAULT); $pdo->prepare( "INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES ('Abteilungsagent', 'dept-agent@example.com', ?, 'agent', 2, 1, ?)" )->execute([$agentHash, $now ?? date('Y-m-d H:i:s')]); $pdo->prepare( "INSERT INTO roles (name, permissions, created_at) VALUES ('Nur Antworten', '{\"reply_ticket\":true}', ?)" )->execute([date('Y-m-d H:i:s')]); $restrictedRoleId = (int) $pdo->lastInsertId(); $pdo->prepare( "INSERT INTO staff (name, email, password_hash, role, role_id, department_id, is_active, created_at) VALUES ('Eingeschränkter Agent', 'restricted-agent@example.com', ?, 'agent', ?, 1, 1, ?)" )->execute([$agentHash, $restrictedRoleId, date('Y-m-d H:i:s')]); $number = 'API-' . random_int(100000, 999999); $now = date('Y-m-d H:i:s'); $pdo->prepare( "INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, created_at, updated_at) VALUES (?,?,?,?,?,?,'open','web',?,?)" )->execute([$number, 'Mobiler Integrationstest', 'Test Kunde', 'kunde@example.test', 1, 2, $now, $now]); $ticketId = (int) $pdo->lastInsertId(); $pdo->prepare( "INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,'customer',?,0,?)" )->execute([$ticketId, 'Bitte mobil bearbeiten.', $now]); // Signaturen (Mitarbeiter und Abteilung 1) und Textbausteine für zwei Abteilungen $pdo->exec("UPDATE departments SET signature = '

Abteilungs-Team

' WHERE id = 1"); $pdo->exec("UPDATE staff SET signature = '

Viele Grüße
Admin persönlich

' WHERE email = 'admin@example.com'"); $pdo->exec("INSERT INTO departments (name, email, is_default) VALUES ('Zweite Abteilung', 'zweite@example.org', 0)"); $secondDeptId = (int) $pdo->lastInsertId(); $pdo->exec('DELETE FROM canned_responses'); $cannedIns = $pdo->prepare('INSERT INTO canned_responses (title, body, department_id, created_at) VALUES (?,?,?,?)'); $cannedIns->execute(['Nur Abteilung eins', 'A', 1, $now]); $cannedIns->execute(['Nur Abteilung zwei', 'B', $secondDeptId, $now]); $cannedIns->execute(['Für alle', 'C', null, $now]); $port = random_int(18080, 18999); $command = [PHP_BINARY, '-S', "127.0.0.1:$port", '-t', $root]; $previousDbPath = getenv('DESKHUB_SQLITE_PATH'); $previousInsecure = getenv('DESKHUB_API_ALLOW_INSECURE'); putenv('DESKHUB_SQLITE_PATH=' . $tempDb); putenv('DESKHUB_API_ALLOW_INSECURE=1'); $process = proc_open( $command, [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], $pipes, $root, null, ); putenv($previousDbPath === false ? 'DESKHUB_SQLITE_PATH' : 'DESKHUB_SQLITE_PATH=' . $previousDbPath); putenv($previousInsecure === false ? 'DESKHUB_API_ALLOW_INSECURE' : 'DESKHUB_API_ALLOW_INSECURE=' . $previousInsecure); if (!is_resource($process)) { unlink($tempDb); fwrite(STDERR, "PHP-Testserver konnte nicht gestartet werden.\n"); exit(1); } try { $ready = false; for ($attempt = 0; $attempt < 30; $attempt++) { $socket = @fsockopen('127.0.0.1', $port, $errno, $error, 0.1); if ($socket) { fclose($socket); $ready = true; break; } usleep(100000); } if (!$ready) { $stderr = stream_get_contents($pipes[2]); throw new RuntimeException('PHP-Testserver ist nicht erreichbar. ' . trim($stderr)); } [$status] = request($port, 'GET', '/api/staff/v1/me.php'); assert_same(401, $status, 'Geschützte Endpunkte müssen ohne Token 401 liefern.'); [$status, $login] = request($port, 'POST', '/api/staff/v1/login.php', [ 'email' => 'admin@example.com', 'password' => $password, 'deviceName' => 'Integrationstest', ]); assert_same(200, $status, 'Mitarbeiter-Login muss erfolgreich sein.'); $token = $login['token'] ?? ''; assert_true(strlen($token) >= 40, 'Login muss ein starkes Bearer-Token liefern.'); $headers = ['Authorization: Bearer ' . $token]; [$status, $metadata] = request($port, 'GET', '/api/staff/v1/metadata.php', null, $headers); assert_same(200, $status, 'Metadaten müssen abrufbar sein.'); assert_same('admin@example.com', $metadata['staff']['email'] ?? null, 'Falsches Mitarbeiterprofil.'); [$status, $tickets] = request( $port, 'GET', '/api/staff/v1/tickets.php?q=' . rawurlencode('Mobiler Integrationstest'), null, $headers, ); assert_same(200, $status, 'Ticketliste muss abrufbar sein.'); assert_same($number, $tickets['items'][0]['number'] ?? null, 'Ticketsuche liefert nicht das Testticket.'); // Antwort-Signaturen im Ticket und Textbausteine nach Abteilung (für Windows- und Android-App) [$status, $ticketData] = request($port, 'GET', '/api/staff/v1/ticket.php?number=' . rawurlencode($number), null, $headers); $rs = $ticketData['ticket']['replySignatures'] ?? []; assert_same(200, $status, 'Ticket muss abrufbar sein.'); assert_same('own', $rs['default'] ?? null, 'Voreinstellung: eigene Signatur, wenn vorhanden.'); assert_true(($rs['own']['available'] ?? false) === true && str_contains((string) ($rs['own']['text'] ?? ''), 'Admin persönlich'), 'Eigene Signatur kommt als Klartext.'); assert_true(($rs['department']['available'] ?? false) === true && str_contains((string) ($rs['department']['text'] ?? ''), 'Abteilungs-Team') && ($rs['department']['hasImage'] ?? false) === true, 'Abteilungs-Signatur mit Hinweis auf Bild.'); assert_true(($rs['own']['hasImage'] ?? true) === false && !str_contains((string) ($rs['own']['text'] ?? ''), '<'), 'Klartext enthält kein HTML.'); [$status, $cannedAll] = request($port, 'GET', '/api/staff/v1/canned_responses.php', null, $headers); assert_same(3, count($cannedAll['items'] ?? []), 'Ohne Filter: alle Textbausteine.'); [$status, $cannedDept] = request($port, 'GET', '/api/staff/v1/canned_responses.php?departmentId=1', null, $headers); $titles = array_column($cannedDept['items'] ?? [], 'title'); assert_true(in_array('Nur Abteilung eins', $titles, true) && in_array('Für alle', $titles, true) && !in_array('Nur Abteilung zwei', $titles, true), 'Mit departmentId: Textbausteine dieser Abteilung und die für alle, keine fremden.'); $pdo->exec('UPDATE tickets SET locked_by = NULL, locked_at = NULL'); // das Abrufen oben hat die Ticket-Sperre des Admins gesetzt, die spätere Tests nicht stören soll [, $departmentLogin] = request($port, 'POST', '/api/staff/v1/login.php', [ 'email' => 'dept-agent@example.com', 'password' => $agentPassword, 'deviceName' => 'Abteilungstest', ]); $departmentHeaders = ['Authorization: Bearer ' . ($departmentLogin['token'] ?? '')]; [$status] = request( $port, 'GET', '/api/staff/v1/ticket.php?number=' . rawurlencode($number), null, $departmentHeaders, ); assert_same(403, $status, 'Abteilungsfremde Tickets müssen serverseitig gesperrt sein.'); [, $restrictedLogin] = request($port, 'POST', '/api/staff/v1/login.php', [ 'email' => 'restricted-agent@example.com', 'password' => $agentPassword, 'deviceName' => 'Rollentest', ]); $restrictedHeaders = ['Authorization: Bearer ' . ($restrictedLogin['token'] ?? '')]; [$status] = request($port, 'POST', '/api/staff/v1/ticket_message.php', [ 'number' => $number, 'type' => 'note', 'message' => 'Diese Notiz darf nicht gespeichert werden.', ], $restrictedHeaders); assert_same(403, $status, 'Fehlende Rollenrechte müssen serverseitig gesperrt sein.'); [$status] = request($port, 'POST', '/api/staff/v1/ticket_message.php', [ 'number' => $number, 'type' => 'reply', 'message' => 'Diese Antwort darf das Ticket nicht schließen.', 'setStatus' => 'closed', ], $restrictedHeaders); assert_same(403, $status, 'Antwortrechte dürfen das Recht zum Schließen nicht umgehen.'); [$status] = request($port, 'POST', '/api/staff/v1/ticket_message.php', [ 'number' => $number, 'type' => 'reply', 'message' => 'Öffentliche Antwort aus dem Integrationstest', 'setStatus' => 'answered', ], $restrictedHeaders); assert_same(201, $status, 'Erlaubte öffentliche Antworten müssen erstellt werden.'); [$status, $detail] = request( $port, 'GET', '/api/staff/v1/ticket.php?number=' . rawurlencode($number), null, $headers, ); assert_same(200, $status, 'Ticketdetail muss abrufbar sein.'); assert_same('Bitte mobil bearbeiten.', $detail['ticket']['threads'][0]['message'] ?? null, 'Thread fehlt.'); [$status, $updated] = request($port, 'PATCH', '/api/staff/v1/ticket_update.php', [ 'number' => $number, 'status' => 'pending', 'assignedStaffId' => 1, ], $headers); assert_same(200, $status, 'Status und Zuständigkeit müssen änderbar sein.'); assert_same('pending', $updated['ticket']['status'] ?? null, 'Status wurde nicht gespeichert.'); [$status] = request($port, 'POST', '/api/staff/v1/ticket_message.php', [ 'number' => $number, 'type' => 'note', 'message' => 'Interne Notiz aus dem Integrationstest', ], $headers); assert_same(201, $status, 'Interne Notiz muss erstellt werden.'); [$status] = request($port, 'POST', '/api/staff/v1/logout.php', [], $headers); assert_same(200, $status, 'Logout muss erfolgreich sein.'); [$status] = request($port, 'GET', '/api/staff/v1/me.php', null, $headers); assert_same(401, $status, 'Widerrufenes Token darf nicht weiter funktionieren.'); $check = new PDO('sqlite:' . $tempDb); $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); foreach ($pipes as $pipe) { if (is_resource($pipe)) fclose($pipe); } proc_close($process); @unlink($tempDb); } function request(int $port, string $method, string $path, ?array $body = null, array $headers = []): array { $headers[] = 'Accept: application/json'; $content = null; if ($body !== null) { $content = json_encode($body, JSON_THROW_ON_ERROR); $headers[] = 'Content-Type: application/json'; } $context = stream_context_create([ 'http' => [ 'method' => $method, 'header' => implode("\r\n", $headers), 'content' => $content, 'ignore_errors' => true, 'timeout' => 10, ], ]); $raw = file_get_contents("http://127.0.0.1:$port$path", false, $context); $statusLine = $http_response_header[0] ?? ''; preg_match('/\s(\d{3})\s/', $statusLine, $matches); $status = (int) ($matches[1] ?? 0); $decoded = $raw !== false && $raw !== '' ? json_decode($raw, true) : []; return [$status, is_array($decoded) ? $decoded : []]; } function assert_true(bool $condition, string $message): void { if (!$condition) throw new RuntimeException($message); } function assert_same(mixed $expected, mixed $actual, string $message): void { if ($expected !== $actual) { throw new RuntimeException($message . ' Erwartet: ' . var_export($expected, true) . ', erhalten: ' . var_export($actual, true)); } }