Files
deskhub/DeskHub-Webseite/tests/mobile_api_integration.php
T
2026-11-01 11:15:59 +00:00

284 lines
14 KiB
PHP

<?php
declare(strict_types=1);
$root = dirname(__DIR__);
$sourceDb = $root . '/database/deskhub.sqlite';
$tempDb = tempnam(sys_get_temp_dir(), 'deskhub-api-');
if ($tempDb === false) {
fwrite(STDERR, "Testdatenbank konnte nicht erstellt werden.\n");
exit(1);
}
if (is_file($sourceDb)) {
copy($sourceDb, $tempDb);
} else {
// Keine Entwicklungsdatenbank vorhanden (frische Kopie, CI): Datenbank aus Schema und Migrationen aufbauen
require_once $root . '/includes/db.php';
require_once $root . '/database/migrate.php';
$fresh = new PDO('sqlite:' . $tempDb);
$fresh->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 = '<p>Abteilungs-Team<br><img src=\"https://example.org/logo.png\"></p>' WHERE id = 1");
$pdo->exec("UPDATE staff SET signature = '<p>Viele Grüße<br>Admin persönlich</p>' 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));
}
}