Files
deskhub/DeskHub-Webseite/tests/admin_models_integration.php
T
2026-10-16 19:12:24 +00:00

206 lines
17 KiB
PHP

<?php
declare(strict_types=1);
/**
* Prüft die gemeinsame Logik der osTicket-artigen Verwaltung:
* E-Mail-Adressen (inkl. IMAP/SMTP, Deaktivieren), Rollen pro Abteilung,
* Abteilungs-Status, Hilfethemen-Hierarchie und Team-Benachrichtigung.
* Läuft gegen eine frische In-Memory-SQLite-Datenbank.
*/
putenv('DESKHUB_SQLITE_PATH=:memory:');
require_once dirname(__DIR__) . '/includes/bootstrap.php';
$failures = [];
$check = function (bool $ok, string $what) use (&$failures): void {
if (!$ok) $failures[] = $what;
};
$throws = function (callable $fn, string $code) use ($check): void {
try { $fn(); $check(false, "erwartete Fehler {$code}"); }
catch (EmailAddressException $e) { $check($e->errorCode() === $code, "Fehlercode {$code}, erhalten {$e->errorCode()}"); }
};
$pdo = get_db();
$now = db_now();
$dept1 = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
$pdo->prepare('INSERT INTO departments (name, email, is_default, created_at) VALUES (?,?,0,?)')->execute(['Zweite Abteilung', '', $now]);
$dept2 = (int) $pdo->lastInsertId();
$prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn();
// ── E-Mail-Adressen ─────────────────────────────────────────────────────────
$id = email_address_save($pdo, 0, ['email' => 'Support@Example.Test', 'label' => 'Support', 'department_id' => $dept1, 'priority_id' => $prio, 'is_default' => true]);
$row = $pdo->query("SELECT * FROM email_addresses WHERE id = {$id}")->fetch();
$check($row['email'] === 'support@example.test', 'E-Mail wird kleingeschrieben gespeichert');
$check((int) $row['is_active'] === 1, 'neue Adresse ist aktiv');
$throws(fn() => email_address_save($pdo, 0, ['email' => 'kaputt', 'department_id' => $dept1]), 'INVALID_EMAIL');
$throws(fn() => email_address_save($pdo, 0, ['email' => 'support@example.test', 'department_id' => $dept1]), 'DUPLICATE_EMAIL');
$throws(fn() => email_address_save($pdo, 0, ['email' => 'x@example.test', 'department_id' => 9999]), 'INVALID_DEPARTMENT');
$throws(fn() => email_address_save($pdo, 0, ['email' => 'imap@example.test', 'department_id' => $dept1, 'imap_enabled' => true, 'imap_host' => '']), 'IMAP_HOST_REQUIRED');
$throws(fn() => email_address_save($pdo, 0, ['email' => 'imap@example.test', 'department_id' => $dept1, 'imap_enabled' => true, 'imap_host' => 'imap.example.test']), 'IMAP_PASSWORD_REQUIRED');
$throws(fn() => email_address_save($pdo, 0, ['email' => 'smtp@example.test', 'department_id' => $dept1, 'smtp_enabled' => true, 'smtp_host' => '']), 'SMTP_HOST_REQUIRED');
// IMAP anlegen, Passwort bleibt bei leerem Feld erhalten, Deaktivieren pausiert den Abruf
email_address_save($pdo, $id, ['email' => 'support@example.test', 'department_id' => $dept1, 'imap_enabled' => true, 'imap_host' => 'imap.example.test', 'imap_password' => 'geheim']);
$imap = email_address_imap($pdo, $id);
$check($imap !== null && (int) $imap['is_active'] === 1 && (int) $imap['department_id'] === $dept1, 'IMAP-Postfach angelegt und verknüpft');
$check(decrypt_secret($imap['password_enc']) === 'geheim', 'IMAP-Passwort verschlüsselt gespeichert');
email_address_save($pdo, $id, ['email' => 'support@example.test', 'department_id' => $dept2, 'imap_enabled' => true, 'imap_host' => 'imap2.example.test', 'imap_password' => '']);
$imap = email_address_imap($pdo, $id);
$check(decrypt_secret($imap['password_enc']) === 'geheim', 'leeres Passwortfeld lässt das Passwort unverändert');
$check((int) $imap['department_id'] === $dept2 && $imap['host'] === 'imap2.example.test', 'Abteilung/Host werden am Postfach nachgezogen');
email_address_save($pdo, $id, ['email' => 'support@example.test', 'department_id' => $dept2, 'is_active' => false, 'imap_enabled' => true, 'imap_host' => 'imap2.example.test']);
$check((int) email_address_imap($pdo, $id)['is_active'] === 0, 'deaktivierte Adresse pausiert den IMAP-Abruf');
email_address_save($pdo, $id, ['email' => 'support@example.test', 'department_id' => $dept2, 'imap_enabled' => true, 'imap_host' => 'imap2.example.test']);
$check((int) $pdo->query("SELECT is_active FROM email_addresses WHERE id = {$id}")->fetchColumn() === 0, 'fehlendes is_active lässt den Status unverändert');
// SMTP pro Adresse: nur aktive Adresse mit aktiviertem SMTP wird verwendet; Daten bleiben beim Abschalten erhalten
email_address_save($pdo, $id, ['email' => 'support@example.test', 'department_id' => $dept2, 'is_active' => true, 'smtp_enabled' => true, 'smtp_host' => 'smtp.example.test', 'smtp_port' => 465, 'smtp_encryption' => 'ssl', 'smtp_username' => 'u', 'smtp_password' => 'pw']);
$smtp = email_address_smtp_for($pdo, 'Support <support@example.test>');
$check($smtp !== null && $smtp['smtp_host'] === 'smtp.example.test' && (int) $smtp['smtp_port'] === 465, 'eigener SMTP wird für die Absenderadresse gefunden');
$check($smtp !== null && decrypt_secret($smtp['smtp_password_enc']) === 'pw', 'SMTP-Passwort verschlüsselt gespeichert');
$check(email_address_smtp_for($pdo, 'andere@example.test') === null, 'fremde Absenderadresse nutzt keinen eigenen SMTP');
email_address_save($pdo, $id, ['email' => 'support@example.test', 'department_id' => $dept2, 'smtp_enabled' => false]);
$check(email_address_smtp_for($pdo, 'support@example.test') === null, 'ausgeschalteter SMTP wird nicht verwendet');
$kept = $pdo->query("SELECT smtp_host, smtp_password_enc FROM email_addresses WHERE id = {$id}")->fetch();
$check($kept['smtp_host'] === 'smtp.example.test' && $kept['smtp_password_enc'] !== null, 'SMTP-Zugangsdaten bleiben beim Ausschalten erhalten');
// Absenderauswahl ignoriert deaktivierte Adressen
$pdo->prepare('UPDATE email_addresses SET is_active = 0 WHERE id = ?')->execute([$id]);
$check(resolve_from_address($dept2) === null, 'deaktivierte Adresse wird nicht als Absender gewählt');
$pdo->prepare('UPDATE email_addresses SET is_active = 1 WHERE id = ?')->execute([$id]);
$check(resolve_from_address($dept2) !== null, 'aktive Adresse wird als Absender gewählt');
// Löschen entfernt das Postfach mit
$deleted = email_address_delete($pdo, $id);
$check($deleted === 'support@example.test', 'Löschen gibt die E-Mail zurück');
$check(email_address_imap($pdo, $id) === null, 'Postfach wird mit der Adresse gelöscht');
// ── Abteilungen: Status ─────────────────────────────────────────────────────
$pdo->prepare('UPDATE departments SET is_active = 0 WHERE id = ?')->execute([$dept2]);
$select = array_map('intval', array_column(departments_for_select($pdo), 'id'));
$check(!in_array($dept2, $select, true), 'deaktivierte Abteilung fehlt in Auswahllisten');
$select = array_map('intval', array_column(departments_for_select($pdo, [$dept2]), 'id'));
$check(in_array($dept2, $select, true), 'bereits gewählte deaktivierte Abteilung bleibt auswählbar');
$check(active_department_id($pdo, $dept2) === $dept1, 'deaktivierte Abteilung weicht auf eine aktive aus');
$check(active_department_id($pdo, $dept1) === $dept1, 'aktive Abteilung bleibt');
$pdo->prepare('UPDATE departments SET is_active = 1 WHERE id = ?')->execute([$dept2]);
// ── Hilfethemen: Hierarchie, Typ, Abteilungsstatus ──────────────────────────
$ins = $pdo->prepare('INSERT INTO help_topics (name, department_id, priority_id, is_active, is_public, parent_id, sort_order) VALUES (?,?,?,?,?,?,?)');
$ins->execute(['Eltern', $dept1, $prio, 1, 1, null, 1]);
$parent = (int) $pdo->lastInsertId();
$ins->execute(['Kind', $dept1, $prio, 1, 1, $parent, 2]);
$child = (int) $pdo->lastInsertId();
$ins->execute(['Intern', $dept1, $prio, 1, 0, null, 3]);
$ins->execute(['In zweiter Abteilung', $dept2, $prio, 1, 1, null, 4]);
$paths = array_column(help_topic_options($pdo, true), 'path');
$check(in_array('Eltern / Kind', $paths, true), 'Kind-Thema hat den Pfad "Eltern / Kind"');
$check(!in_array('Intern', $paths, true), 'interne Themen sind für Kunden nicht sichtbar');
$check(in_array('Intern', array_column(help_topic_options($pdo, false), 'path'), true), 'interne Themen sind für Mitarbeiter sichtbar');
$check(array_search('Eltern', $paths, true) < array_search('Eltern / Kind', $paths, true), 'Eltern stehen vor ihren Kindern');
$pdo->prepare('UPDATE departments SET is_active = 0 WHERE id = ?')->execute([$dept2]);
$check(!in_array('In zweiter Abteilung', array_column(help_topic_options($pdo, true), 'path'), true), 'Themen deaktivierter Abteilungen sind im Kundenportal verborgen');
$pdo->prepare('UPDATE help_topics SET parent_id = ? WHERE id = ?')->execute([$child, $parent]); // Zyklus
$check(count(help_topic_options($pdo, false)) >= 1, 'zyklische Hierarchie lässt die Auflistung nicht hängen');
$pdo->prepare('UPDATE help_topics SET parent_id = NULL WHERE id = ?')->execute([$parent]);
$pdo->prepare('UPDATE departments SET is_active = 1 WHERE id = ?')->execute([$dept2]);
// ── Rollen pro Abteilung ────────────────────────────────────────────────────
$pdo->prepare('INSERT INTO roles (name, permissions, is_active, created_at) VALUES (?,?,1,?)')
->execute(['Nur lesen', json_encode(['reply_ticket' => false, 'delete_ticket' => false, 'close_ticket' => false]), $now]);
$readOnly = (int) $pdo->lastInsertId();
$pdo->prepare('INSERT INTO roles (name, permissions, is_active, created_at) VALUES (?,?,1,?)')
->execute(['Löscher', json_encode(['reply_ticket' => true, 'delete_ticket' => true]), $now]);
$deleter = (int) $pdo->lastInsertId();
$pdo->prepare("INSERT INTO staff (name, email, password_hash, role, role_id, department_id, is_active, created_at) VALUES ('Agent','agent@example.test','x','agent',?,?,1,?)")
->execute([$readOnly, $dept1, $now]);
$agentId = (int) $pdo->lastInsertId();
$pdo->prepare('INSERT INTO staff_department (staff_id, department_id, notify, role_id) VALUES (?,?,1,?)')->execute([$agentId, $dept2, $deleter]);
$agent = $pdo->query("SELECT * FROM staff WHERE id = {$agentId}")->fetch();
$check(!has_permission($agent, 'reply_ticket'), 'Hauptrolle gilt ohne Abteilungsangabe');
$check(!has_permission($agent, 'reply_ticket', $dept1), 'Hauptrolle gilt in der Hauptabteilung');
$check(has_permission($agent, 'reply_ticket', $dept2) && has_permission($agent, 'delete_ticket', $dept2), 'eigene Rolle gilt in der erweiterten Abteilung');
$pdo->prepare('UPDATE staff_department SET role_id = NULL WHERE staff_id = ? AND department_id = ?')->execute([$agentId, $dept2]);
// Cache der Funktion ist pro Prozess: neuen Mitarbeiter mit gleicher Konstellation prüfen
$pdo->prepare("INSERT INTO staff (name, email, password_hash, role, role_id, department_id, is_active, created_at) VALUES ('Agent2','agent2@example.test','x','agent',?,?,1,?)")
->execute([$readOnly, $dept1, $now]);
$agent2Id = (int) $pdo->lastInsertId();
$pdo->prepare('INSERT INTO staff_department (staff_id, department_id, notify, role_id) VALUES (?,?,1,NULL)')->execute([$agent2Id, $dept2]);
$agent2 = $pdo->query("SELECT * FROM staff WHERE id = {$agent2Id}")->fetch();
$check(!has_permission($agent2, 'reply_ticket', $dept2), 'ohne eigene Rolle gilt in der erweiterten Abteilung die Hauptrolle');
$pdo->prepare('UPDATE roles SET is_active = 0 WHERE id = ?')->execute([$readOnly]);
$agent3 = $agent2; $agent3['id'] = 987654; // frischer Cache-Schlüssel, Rollen-Cache bleibt: neue Rolle prüfen
$pdo->prepare("INSERT INTO roles (name, permissions, is_active, created_at) VALUES ('Aus', ?, 0, ?)")->execute([json_encode(['reply_ticket' => false]), $now]);
$offId = (int) $pdo->lastInsertId();
$agent3['role_id'] = $offId; $agent3['department_id'] = $dept1;
$check(has_permission($agent3, 'reply_ticket') === (bool) default_permissions('agent')['reply_ticket'], 'deaktivierte Rolle fällt auf Standardrechte zurück');
$admin = ['id' => 1, 'role' => 'admin', 'role_id' => null, 'department_id' => $dept1];
$check(has_permission($admin, 'delete_ticket', $dept2), 'Administrator darf immer');
// ── Team-Benachrichtigung ───────────────────────────────────────────────────
$pdo->prepare('INSERT INTO teams (name, is_active, no_alerts, created_at) VALUES (?,?,?,?)')->execute(['Team A', 1, 1, $now]);
$teamQuiet = (int) $pdo->lastInsertId();
$pdo->prepare('INSERT INTO teams (name, is_active, no_alerts, created_at) VALUES (?,?,?,?)')->execute(['Team B', 0, 0, $now]);
$teamOff = (int) $pdo->lastInsertId();
$ticket = ['ticket_number' => 'T-1', 'subject' => 'Test'];
$check(notify_team_assignment($pdo, $ticket, $teamQuiet) === 0, 'Team mit deaktivierter Benachrichtigung sendet nichts');
$check(notify_team_assignment($pdo, $ticket, $teamOff) === 0, 'deaktiviertes Team sendet nichts');
$check(notify_team_assignment($pdo, $ticket, 99999) === 0, 'unbekanntes Team sendet nichts');
// ── Abteilung löschen: nie ein Absturz, immer eine verständliche Meldung ──────────
$deptCode = function (callable $fn): string {
try { $fn(); return 'OK'; }
catch (AdminModelException $e) { return $e->errorCode() . '|' . $e->getMessage(); }
catch (Throwable $e) { return 'ABSTURZ ' . get_class($e) . ': ' . $e->getMessage(); }
};
$pdo->prepare('INSERT INTO departments (name, email, is_default, created_at) VALUES (?,?,0,?)')->execute(['Zum Löschen', '', $now]);
$delDept = (int) $pdo->lastInsertId();
$pdo->prepare('INSERT INTO help_topics (name, department_id, priority_id, is_active, created_at) VALUES (?,?,?,1,?)')->execute(['Thema der Abteilung', $delDept, $prio, $now]);
$topicOfDept = (int) $pdo->lastInsertId();
$res = $deptCode(fn() => department_delete($pdo, $delDept));
$check(str_starts_with($res, 'DEPARTMENT_IN_USE|') && str_contains($res, '1 Hilfethemen'), 'Abteilung mit Hilfethema: verständliche Meldung statt Absturz: ' . $res);
$check((int) $pdo->query("SELECT COUNT(*) FROM departments WHERE id = {$delDept}")->fetchColumn() === 1, 'die Abteilung bleibt dabei erhalten');
$pdo->exec("DELETE FROM help_topics WHERE id = {$topicOfDept}");
$pdo->prepare('INSERT INTO email_addresses (email, label, department_id, is_active, created_at) VALUES (?,?,?,1,?)')->execute(['zum-loeschen@example.test', 'X', $delDept, $now]);
$mailId = (int) $pdo->lastInsertId();
$res = $deptCode(fn() => department_delete($pdo, $delDept));
$check(str_starts_with($res, 'DEPARTMENT_IN_USE|') && str_contains($res, 'E-Mail-Adressen'), 'Abteilung mit E-Mail-Adresse wird nicht gelöscht (die Adresse ginge sonst verloren): ' . $res);
$pdo->exec("DELETE FROM email_addresses WHERE id = {$mailId}");
$pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)')->execute(['DEL-1', 'x', 'k', 'k@example.test', $delDept, $prio, 'open', $now, $now]);
$ticketOfDept = (int) $pdo->lastInsertId();
$res = $deptCode(fn() => department_delete($pdo, $delDept));
$check(str_starts_with($res, 'DEPARTMENT_IN_USE|') && str_contains($res, '1 Tickets'), 'Abteilung mit Tickets wird nicht gelöscht: ' . $res);
$pdo->exec("DELETE FROM tickets WHERE id = {$ticketOfDept}");
$check(str_starts_with($deptCode(fn() => department_delete($pdo, $dept1)), 'DEPARTMENT_IS_DEFAULT|') || str_starts_with($deptCode(fn() => department_delete($pdo, $dept1)), 'DEPARTMENT_IN_USE|'), 'Standardabteilung kann nicht gelöscht werden');
$check(str_starts_with($deptCode(fn() => department_delete($pdo, 999999)), 'NOT_FOUND|'), 'unbekannte Abteilung liefert eine Meldung');
// zusätzliche Mitgliedschaft und Textbaustein: werden beim Löschen aufgeräumt
$pdo->prepare("INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES ('Mitglied','mitglied@example.test','x','agent',?,1,?)")->execute([$dept1, $now]);
$memberId = (int) $pdo->lastInsertId();
$pdo->prepare('INSERT INTO staff_department (staff_id, department_id) VALUES (?,?)')->execute([$memberId, $delDept]);
$pdo->prepare('INSERT INTO canned_responses (title, body, department_id, created_at) VALUES (?,?,?,?)')->execute(['Baustein', 'Text', $delDept, $now]);
$canId = (int) $pdo->lastInsertId();
$res = $deptCode(fn() => department_delete($pdo, $delDept));
$check($res === 'OK', 'leere Abteilung lässt sich löschen: ' . $res);
$check((int) $pdo->query("SELECT COUNT(*) FROM departments WHERE id = {$delDept}")->fetchColumn() === 0, 'Abteilung ist weg');
$check((int) $pdo->query("SELECT COUNT(*) FROM staff_department WHERE department_id = {$delDept}")->fetchColumn() === 0, 'zusätzliche Mitgliedschaften wurden mit aufgeräumt');
$check($pdo->query("SELECT department_id FROM canned_responses WHERE id = {$canId}")->fetchColumn() === null, 'Textbausteine der Abteilung gelten danach für alle');
// Mitarbeiter mit dieser Hauptabteilung blockieren das Löschen
$pdo->prepare('INSERT INTO departments (name, email, is_default, created_at) VALUES (?,?,0,?)')->execute(['Mit Mitarbeiter', '', $now]);
$withStaff = (int) $pdo->lastInsertId();
$pdo->prepare("INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES ('Haupt','haupt@example.test','x','agent',?,1,?)")->execute([$withStaff, $now]);
$res = $deptCode(fn() => department_delete($pdo, $withStaff));
$check(str_starts_with($res, 'DEPARTMENT_IN_USE|') && str_contains($res, 'Hauptabteilung'), 'Mitarbeiter mit dieser Hauptabteilung blockieren das Löschen: ' . $res);
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Admin models integration checks passed.\n";