Files
2026-10-24 20:14:43 +00:00

275 lines
23 KiB
PHP

<?php
declare(strict_types=1);
/**
* Prüft das Plugin "Datenschutz": Tickets ohne Registrierung werden nach der Frist unwiderruflich anonymisiert, registrierte Kunden nicht,
* Löschung auf Antrag, Sicherung gegen versehentliches Einschalten, Einstellungen und Protokoll ohne Kundendaten.
*/
$pluginDir = sys_get_temp_dir() . '/deskhub-ds-' . bin2hex(random_bytes(4));
mkdir($pluginDir, 0755, true);
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
putenv('DESKHUB_TESTING=1');
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
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;
};
$pdo = get_db();
require_once __DIR__ . '/fixtures/paths.php';
$src = plugin_source_dir_or_skip('datenschutz', 'plugin_datenschutz');
$zipPath = sys_get_temp_dir() . '/ds-' . bin2hex(random_bytes(3)) . '.zip';
$zip = new ZipArchive();
$zip->open($zipPath, ZipArchive::CREATE);
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) {
$zip->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1)));
}
$zip->close();
$r = plugin_install_zip($pdo, $zipPath);
$check($r['ok'] && $r['slug'] === 'datenschutz', 'Plugin lässt sich installieren: ' . json_encode($r));
$act = plugin_set_active($pdo, 'datenschutz', true, ['hooks.cron', 'ui.menu', 'db.migrations']);
$check($act['ok'], 'Plugin lässt sich aktivieren: ' . json_encode($act));
$state = &plugin_state();
$state['booted'] = false;
plugins_boot();
$check(count(plugin_admin_menu_items(['id' => 1, 'role' => 'admin'])) === 1 && count(plugin_admin_menu_items(['id' => 2, 'role' => 'agent'])) === 0 && count(plugin_menu_items(['id' => 1, 'role' => 'admin'])) === 0, 'Menüpunkt nur für Administratoren im Adminportal');
$dept = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
$prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn();
$admin = ['id' => (int) $pdo->query("SELECT id FROM staff WHERE role = 'admin' ORDER BY id LIMIT 1")->fetchColumn(), 'role' => 'admin'];
$agent = ['id' => 999, 'role' => 'agent'];
$day = fn(int $d) => date('Y-m-d H:i:s', time() - $d * 86400);
$mkUser = function (string $name, string $email) use ($pdo): int {
$pdo->prepare('INSERT INTO users (name, email, password_hash, phone, created_at) VALUES (?,?,?,?,?)')->execute([$name, $email, 'hash', '0170 1234567', db_now()]);
return (int) $pdo->lastInsertId();
};
$n = 0;
$mk = function (string $name, string $email, string $status, int $ageDays, ?int $userId = null, ?int $closedAgo = null) use ($pdo, $dept, $prio, &$n, $day): int {
$n++;
$closed = in_array($status, ['closed', 'resolved'], true) ? $day($closedAgo ?? $ageDays) : null;
$pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, requester_ip, user_id, department_id, priority_id, status, source, closed_at, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)')
->execute(['DS-' . $n, 'Thema ' . $n, $name, $email, '203.0.113.7', $userId, $dept, $prio, $status, 'web', $closed, $day($ageDays), $day($closedAgo ?? $ageDays)]);
$id = (int) $pdo->lastInsertId();
$pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'customer', ?, 0, ?)")->execute([$id, 'Mein Name ist ' . $name . ', Tel. 0171 555', $day($ageDays)]);
$pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'staff', 'Wir kümmern uns.', 0, ?)")->execute([$id, $day($ageDays)]);
$pdo->prepare('INSERT INTO ticket_collaborators (ticket_id, email, name, created_at) VALUES (?,?,?,?)')->execute([$id, 'mitleser@kunde.example', 'Mitleser', db_now()]);
return $id;
};
$row = fn(int $id) => $pdo->query("SELECT * FROM tickets WHERE id = {$id}")->fetch();
$isAnon = fn(int $id) => $row($id)['requester_name'] === 'Anonymisiert' && str_starts_with($row($id)['requester_email'], 'anon_') && str_ends_with($row($id)['requester_email'], '@deleted.local');
$user = $mkUser('Erika Muster', 'erika@muster.example');
$old = $mk('Gast Alt', 'gast.alt@kunde.example', 'closed', 45);
$oldResolved = $mk('Gast Gelöst', 'gast.geloest@kunde.example', 'resolved', 40);
$young = $mk('Gast Neu', 'gast.neu@kunde.example', 'closed', 10);
$openOld = $mk('Gast Offen', 'gast.offen@kunde.example', 'open', 60);
$regOld = $mk('Erika Muster', 'erika@muster.example', 'closed', 90, $user);
$guestSameMail = $mk('Erika Gast', 'erika@muster.example', 'closed', 90);
$closedLongAgoCreatedRecently = $mk('Gast Z', 'gast.z@kunde.example', 'closed', 5, null, 5);
$cf = $pdo->prepare("INSERT INTO custom_fields (label, field_type, is_active, created_at) VALUES ('Telefon','text',1,?)");
try { $cf->execute([db_now()]); $cfId = (int) $pdo->lastInsertId(); $pdo->prepare('INSERT INTO ticket_field_values (ticket_id, custom_field_id, value) VALUES (?,?,?)')->execute([$old, $cfId, '0171 999']); } catch (Throwable $e) { $cfId = 0; }
$pdo->prepare('INSERT INTO ticket_access_tokens (ticket_id, token_hash, token_enc, created_at) VALUES (?,?,?,?)')->execute([$old, hash('sha256', 'abc'), 'enc', db_now()]);
$pdo->prepare('INSERT INTO ticket_ratings (ticket_id, rating, comment, created_at) VALUES (?,?,?,?)')->execute([$old, 4, 'Ich bin Gast Alt', db_now()]);
// Standardwerte und Schutz gegen versehentliches Einschalten
$cfg = datenschutz_config();
$check($cfg['enabled'] === false && $cfg['amount'] === 30 && $cfg['unit'] === 'days' && $cfg['basis'] === 'closed' && !$cfg['include_open'] && !in_array('messages', $cfg['fields'], true) && in_array('name', $cfg['fields'], true) && in_array('email', $cfg['fields'], true), 'Standard: ausgeschaltet, 30 Tage nach dem Schließen');
$check(datenschutz_run($pdo) === 0 && !$isAnon($old), 'ausgeschaltet wird nichts anonymisiert');
$check(datenschutz_candidate_count($pdo) === 3, 'Vorschau zählt: gelöste/geschlossene Gast-Tickets nach der Frist (alt, gelöst, Gast mit gleicher Adresse): ' . datenschutz_candidate_count($pdo));
plugin_set_setting('datenschutz', 'enabled', '1');
// Lauf
$n1 = datenschutz_run($pdo);
$check($n1 === 3, 'Lauf anonymisiert 3 Tickets: ' . $n1);
$check($isAnon($old) && $isAnon($oldResolved) && $isAnon($guestSameMail), 'Gast-Tickets nach der Frist sind anonymisiert');
$check($row($old)['requester_ip'] === '0.0.0.0', 'IP-Adresse ist entfernt');
$check(!$isAnon($young) && !$isAnon($openOld) && !$isAnon($regOld) && !$isAnon($closedLongAgoCreatedRecently), 'junge, offene und registrierte Tickets bleiben unberührt');
$check($row($regOld)['requester_name'] === 'Erika Muster' && (int) $row($regOld)['user_id'] === $user, 'registrierter Kunde bleibt vollständig erhalten');
$check((int) $pdo->query("SELECT COUNT(*) FROM users WHERE id = {$user} AND name = 'Erika Muster' AND email = 'erika@muster.example'")->fetchColumn() === 1, 'Konto des registrierten Kunden bleibt');
$check((int) $pdo->query("SELECT COUNT(*) FROM ticket_collaborators WHERE ticket_id = {$old}")->fetchColumn() === 0 && (int) $pdo->query("SELECT COUNT(*) FROM ticket_collaborators WHERE ticket_id = {$young}")->fetchColumn() === 1, 'Mitleser werden entfernt');
$check((int) $pdo->query("SELECT COUNT(*) FROM ticket_access_tokens WHERE ticket_id = {$old}")->fetchColumn() === 0, 'Zugangslinks sind ungültig');
$check($pdo->query("SELECT comment FROM ticket_ratings WHERE ticket_id = {$old}")->fetchColumn() === null, 'Bewertungs-Kommentar ist entfernt');
if ($cfId) $check($pdo->query("SELECT value FROM ticket_field_values WHERE ticket_id = {$old}")->fetchColumn() === null, 'Zusatzfelder sind geleert');
$check($pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$old} AND author_type = 'customer'")->fetchColumn() === 'Mein Name ist Gast Alt, Tel. 0171 555', 'Nachrichten bleiben standardmäßig erhalten');
$check(datenschutz_run($pdo) === 0, 'ein zweiter Lauf findet nichts mehr');
$e1 = $row($old)['requester_email'];
$check(!str_contains($e1, 'gast') && !str_contains($e1, hash('sha256', 'gast.alt@kunde.example')) && strlen($e1) > 20, 'Ersatzadresse hat keinen Bezug zur echten Adresse');
$check($row($old)['requester_email'] !== $row($oldResolved)['requester_email'], 'Ersatzadressen sind je Ticket verschieden');
$check((int) $pdo->query("SELECT COUNT(*) FROM activity_log WHERE ticket_id = {$old} AND action LIKE '%anonymisiert%'")->fetchColumn() === 1, 'Aktivität im Ticket vermerkt');
$log = $pdo->query('SELECT * FROM plugin_datenschutz_log ORDER BY id DESC LIMIT 1')->fetch();
$check($log['kind'] === 'auto' && (int) $log['tickets'] === 3 && !str_contains((string) $log['note'], '@'), 'Protokoll ohne Kundendaten');
// Einstellungen: Frist, Berechnung ab Erstellung, offene Tickets, Nachrichten
plugin_set_setting('datenschutz', 'amount', '7');
$check(datenschutz_candidate_count($pdo) === 1, 'Frist 7 Tage: nur das 10 Tage alte geschlossene Gast-Ticket');
plugin_set_setting('datenschutz', 'basis', 'created');
$check(datenschutz_candidate_count($pdo) === 1, 'ab Erstellung: dasselbe Ticket');
plugin_set_setting('datenschutz', 'include_open', '1');
$check(datenschutz_candidate_count($pdo) === 2, 'mit offenen Tickets: auch das alte offene Gast-Ticket');
plugin_set_setting('datenschutz', 'fields', 'name,email,ip,collaborators,fieldvalues,tokens,rating,messages');
datenschutz_run($pdo);
$check($isAnon($young) && $isAnon($openOld), 'Frist 7 Tage: weitere Tickets anonymisiert');
$check($pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$young} AND author_type = 'customer'")->fetchColumn() === DATENSCHUTZ_TEXT_REMOVED && $pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$young} AND author_type = 'staff'")->fetchColumn() === 'Wir kümmern uns.', 'Kundennachrichten entfernt, Antworten der Mitarbeiter bleiben');
$check(!$isAnon($closedLongAgoCreatedRecently) && !$isAnon($regOld), '5 Tage altes und registriertes bleiben');
$check(!$cfg['include_open'] && datenschutz_config()['amount'] === 7, 'Einstellungen werden gelesen');
plugin_set_setting('datenschutz', 'amount', '0');
$check(datenschutz_config()['amount'] === 1, 'Frist hat eine Untergrenze');
// Anhänge
$uploads = rtrim((string) config()['uploads']['path'], '/');
@mkdir($uploads, 0775, true);
$file = 'ds-test-' . bin2hex(random_bytes(3)) . '.txt';
file_put_contents($uploads . '/' . $file, 'Inhalt');
$att = $mk('Gast Anhang', 'gast.anhang@kunde.example', 'closed', 100);
$th = (int) $pdo->query("SELECT id FROM ticket_threads WHERE ticket_id = {$att} AND author_type = 'customer'")->fetchColumn();
$pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)')->execute([$th, 'lebenslauf.pdf', $file, 6, db_now()]);
plugin_set_setting('datenschutz', 'amount', '30');
plugin_set_setting('datenschutz', 'basis', 'closed');
plugin_set_setting('datenschutz', 'include_open', '0');
plugin_set_setting('datenschutz', 'fields', 'name,email,ip,collaborators,fieldvalues,tokens,rating');
datenschutz_run($pdo);
$check(is_file($uploads . '/' . $file), 'ohne die Einstellung bleibt der Anhang');
$att2 = $mk('Gast Anhang 2', 'gast.anhang2@kunde.example', 'closed', 100);
$th2 = (int) $pdo->query("SELECT id FROM ticket_threads WHERE ticket_id = {$att2} AND author_type = 'customer'")->fetchColumn();
$file2 = 'ds-test-' . bin2hex(random_bytes(3)) . '.txt';
file_put_contents($uploads . '/' . $file2, 'Inhalt');
$pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)')->execute([$th2, 'ausweis.jpg', $file2, 6, db_now()]);
plugin_set_setting('datenschutz', 'fields', 'name,email,ip,collaborators,fieldvalues,tokens,rating,attachments');
datenschutz_run($pdo);
$check(!is_file($uploads . '/' . $file2) && $pdo->query("SELECT stored_name FROM ticket_attachments WHERE thread_id = {$th2}")->fetchColumn() === 'geloescht', 'mit der Einstellung wird der Anhang gelöscht');
@unlink($uploads . '/' . $file);
// Hintergrundlauf höchstens einmal pro Stunde
plugin_set_setting('datenschutz', 'last_tick', (string) time());
$late = $mk('Gast Spät', 'gast.spaet@kunde.example', 'closed', 100);
$check(datenschutz_tick($pdo) === 0 && !$isAnon($late), 'Hintergrundlauf wartet eine Stunde');
plugin_set_setting('datenschutz', 'last_tick', '0');
$check(datenschutz_tick($pdo) >= 1 && $isAnon($late), 'Hintergrundlauf anonymisiert nach Ablauf');
// ── Auswahl der Datenarten und Frist-Einheiten ─────────────────────────────
plugin_set_setting('datenschutz', 'amount', '30');
plugin_set_setting('datenschutz', 'unit', 'days');
plugin_set_setting('datenschutz', 'basis', 'closed');
plugin_set_setting('datenschutz', 'include_open', '0');
$nameOnly = $mk('Nur Name', 'nur.name@kunde.example', 'closed', 100);
plugin_set_setting('datenschutz', 'fields', 'name');
$check(datenschutz_run($pdo) === 1, 'nur Name: ein Ticket bearbeitet');
$r1 = $row($nameOnly);
$check($r1['requester_name'] === 'Anonymisiert' && $r1['requester_email'] === 'nur.name@kunde.example' && $r1['requester_ip'] === '203.0.113.7', 'nur Name: E-Mail und IP bleiben');
$check((int) $pdo->query("SELECT COUNT(*) FROM ticket_collaborators WHERE ticket_id = {$nameOnly}")->fetchColumn() === 1, 'nur Name: Mitleser bleiben');
$check(datenschutz_run($pdo) === 0 && datenschutz_candidate_count($pdo) === 0, 'bearbeitete Tickets werden nicht erneut angefasst, auch wenn die E-Mail noch echt ist');
$mailOnly = $mk('Nur Mail', 'nur.mail@kunde.example', 'closed', 100);
plugin_set_setting('datenschutz', 'fields', 'email');
datenschutz_run($pdo);
$r2 = $row($mailOnly);
$check($r2['requester_name'] === 'Nur Mail' && str_starts_with($r2['requester_email'], 'anon_') && $r2['requester_ip'] === '203.0.113.7', 'nur E-Mail: Name und IP bleiben');
$both = $mk('Beides', 'beides@kunde.example', 'closed', 100);
plugin_set_setting('datenschutz', 'fields', 'name,email');
datenschutz_run($pdo);
$r3 = $row($both);
$check($r3['requester_name'] === 'Anonymisiert' && str_starts_with($r3['requester_email'], 'anon_') && $r3['requester_ip'] === '203.0.113.7', 'Name und E-Mail: IP bleibt');
$full = $mk('Komplett', 'komplett@kunde.example', 'closed', 100);
plugin_set_setting('datenschutz', 'fields', implode(',', array_keys(datenschutz_field_labels())));
datenschutz_run($pdo);
$check($isAnon($full) && $row($full)['requester_ip'] === '0.0.0.0' && $pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$full} AND author_type = 'customer'")->fetchColumn() === DATENSCHUTZ_TEXT_REMOVED, 'komplett: alles entfernt');
$check(datenschutz_config()['fields'] === array_keys(datenschutz_field_labels()), 'Auswahl wird gelesen');
plugin_set_setting('datenschutz', 'fields', 'quatsch,name');
$check(datenschutz_config()['fields'] === ['name'], 'ungültige Datenarten werden ignoriert');
plugin_set_setting('datenschutz', 'fields', '');
$none = $mk('Nichts', 'nichts@kunde.example', 'closed', 100);
$check(datenschutz_config()['fields'] === [] && datenschutz_run($pdo) === 0 && $row($none)['requester_name'] === 'Nichts', 'ohne ausgewählte Datenarten passiert nichts');
plugin_set_setting('datenschutz', 'fields', 'name,email,ip,collaborators,fieldvalues,tokens,rating');
$check(array_keys(datenschutz_presets()) === ['standard', 'full', 'name', 'email', 'name_email'] && datenschutz_presets()['name'][1] === ['name'], 'Vorlagen für die Auswahl');
foreach (['days' => ['1', 'Tag'], 'weeks' => ['2', 'Wochen'], 'months' => ['1', 'Monat'], 'years' => ['3', 'Jahre']] as $unit => [$amount, $word]) {
plugin_set_setting('datenschutz', 'unit', $unit);
plugin_set_setting('datenschutz', 'amount', $amount);
$c = datenschutz_config();
$expected = strtotime("-{$amount} {$unit}");
$check(abs(strtotime(datenschutz_cutoff($c)) - $expected) < 5 && datenschutz_frist_label($c) === "{$amount} {$word}", "Frist in {$unit}: " . datenschutz_frist_label($c));
}
plugin_set_setting('datenschutz', 'unit', 'jahrhunderte');
$check(datenschutz_config()['unit'] === 'days', 'unbekannte Einheit fällt auf Tage zurück');
plugin_set_setting('datenschutz', 'unit', 'months');
plugin_set_setting('datenschutz', 'amount', '2');
$mid = $mk('Zwei Monate', 'zwei@kunde.example', 'closed', 45);
$old2 = $mk('Drei Monate', 'drei@kunde.example', 'closed', 95);
datenschutz_run($pdo);
$check(!$isAnon($mid) && $isAnon($old2), 'Frist 2 Monate: 45 Tage alt bleibt, 95 Tage alt wird anonymisiert');
plugin_set_setting('datenschutz', 'unit', 'days');
plugin_set_setting('datenschutz', 'amount', '30');
// ── Löschung auf Antrag ────────────────────────────────────────────────────
$check(datenschutz_find_customer($pdo, 'unbekannt@x.example') === null && datenschutz_find_customer($pdo, '') === null, 'unbekannter Kunde');
$found = datenschutz_find_customer($pdo, ' ERIKA@muster.example ');
$check($found && (int) $found['user']['id'] === $user && $found['tickets'] === 1 && $found['open'] === 0 && !$found['already'], 'Kunde wird gefunden (Groß-/Kleinschreibung egal), Tickets gezählt');
$check(!datenschutz_delete_customer($pdo, $agent, $user, false)['ok'], 'Mitarbeiter dürfen nicht löschen');
$openReg = $mk('Erika Muster', 'erika@muster.example', 'open', 3, $user);
$blocked = datenschutz_delete_customer($pdo, $admin, $user, false);
$check(!$blocked['ok'] && str_contains($blocked['error'], 'offene Tickets'), 'mit offenen Tickets: Hinweis statt Löschung');
$check((int) $pdo->query("SELECT COUNT(*) FROM users WHERE id = {$user} AND name = 'Erika Muster'")->fetchColumn() === 1, 'nichts wurde verändert');
$pdo->prepare('INSERT INTO customer_notes (user_id, staff_id, note, created_at) VALUES (?,?,?,?)')->execute([$user, $admin['id'], 'Ruft oft an', db_now()]);
$pdo->prepare('INSERT INTO password_resets (account_type, email, token_hash, expires_at, created_at) VALUES (?,?,?,?,?)')->execute(['customer', 'erika@muster.example', 'x', db_now(), db_now()]);
$del = datenschutz_delete_customer($pdo, $admin, $user, true);
$check($del['ok'] && $del['tickets'] === 2, 'Löschung mit Schließen der offenen Tickets: ' . json_encode($del));
$u = $pdo->query("SELECT * FROM users WHERE id = {$user}")->fetch();
$check($u['name'] === 'Anonymisiert' && str_starts_with($u['email'], 'anon_') && $u['phone'] === null && $u['password_hash'] !== 'hash' && strlen($u['password_hash']) > 30, 'Konto ist anonymisiert, Anmeldung nicht mehr möglich');
$check($isAnon($regOld) && $isAnon($openReg) && $row($openReg)['status'] === 'closed' && $row($regOld)['user_id'] === null, 'alle Tickets des Kunden sind anonymisiert, das offene geschlossen');
$check($pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$regOld} AND author_type = 'customer'")->fetchColumn() === DATENSCHUTZ_TEXT_REMOVED, 'bei Löschung auf Antrag werden die Nachrichten immer entfernt (unabhängig von der Einstellung)');
$check((int) $pdo->query("SELECT COUNT(*) FROM customer_notes WHERE user_id = {$user}")->fetchColumn() === 0 && (int) $pdo->query("SELECT COUNT(*) FROM password_resets WHERE email = 'erika@muster.example'")->fetchColumn() === 0, 'Notizen und Passwort-Rücksetzung sind gelöscht');
$l = $pdo->query('SELECT * FROM plugin_datenschutz_log ORDER BY id DESC LIMIT 1')->fetch();
$check($l['kind'] === 'request' && (int) $l['staff_id'] === $admin['id'] && str_contains((string) $l['note'], 'e***@m***.example') && !str_contains((string) $l['note'], 'erika@'), 'Protokoll enthält nur die verkürzte Adresse: ' . $l['note']);
$check(!datenschutz_delete_customer($pdo, $admin, $user, true)['ok'], 'ein zweites Mal geht nicht');
$check(datenschutz_find_customer($pdo, 'erika@muster.example') === null, 'Kunde ist unter der alten Adresse nicht mehr auffindbar');
$check(!datenschutz_delete_customer($pdo, $admin, 99999, true)['ok'], 'unbekannte Kunden-ID');
$check(datenschutz_mask('ab@firma.de') === 'a***@f***.de' && datenschutz_mask('x') === 'x***', 'Maskierung für das Protokoll');
// Selbstheilung: fehlt die Tabelle nach einem Update (ältere DeskHub-Dateien), holt das Plugin die Änderung selbst nach
$pdo->exec('DROP TABLE plugin_datenschutz_done');
$pdo->exec("DELETE FROM schema_migrations WHERE migration LIKE 'plugin_datenschutz_002%'");
datenschutz_ensure_schema(true);
$check((int) $pdo->query("SELECT COUNT(*) FROM sqlite_master WHERE name = 'plugin_datenschutz_done'")->fetchColumn() === 1, 'Selbstheilung: die fehlende Tabelle wird nachgeholt');
// ── Reiter der Seite ───────────────────────────────────────────────────────
$pagePath = $pluginDir . '/datenschutz/pages/datenschutz.php';
$adminStaff = ['id' => $admin['id'], 'role' => 'admin', 'name' => 'Chef'];
$render = function (string $tab, array $get = [], ?array $who = null) use ($pdo, $adminStaff, $pagePath): string {
$_GET = ($tab === '' ? [] : ['tab' => $tab]) + $get;
$_SERVER['REQUEST_METHOD'] = 'GET';
$staff = $who ?? $adminStaff;
ob_start();
include $pagePath;
return (string) ob_get_clean();
};
$pdo->exec('DELETE FROM plugin_datenschutz_log');
for ($i = 1; $i <= 120; $i++) datenschutz_log($pdo, 'auto', $i, 0, "Eintrag-{$i}-ende", null);
$home = $render('');
$check(str_contains($home, '<h3>Regeln</h3>') && !str_contains($home, 'Eintrag-') && !str_contains($home, 'name="fields[]"') && !str_contains($home, 'E-Mail-Adresse des Kunden'), 'Übersicht: nur Regeln und Status');
$check(str_contains($home, 'tab=einstellungen') && str_contains($home, 'tab=antrag') && str_contains($home, 'tab=protokoll'), 'Reiterleiste mit allen Reitern');
$set = $render('einstellungen');
$check(str_contains($set, 'name="fields[]"') && !str_contains($set, '<h3>Regeln</h3>'), 'Reiter Einstellungen');
$req = $render('antrag');
$check(str_contains($req, 'E-Mail-Adresse des Kunden') && !str_contains($req, 'name="fields[]"'), 'Reiter Löschung auf Antrag');
$p1 = $render('protokoll');
$check(substr_count($p1, '-ende') === 50 && str_contains($p1, 'Eintrag-120-ende') && !str_contains($p1, 'Eintrag-70-ende') && str_contains($p1, 'Seite 1 von 3') && !str_contains($p1, '<h3>Regeln</h3>'), 'Protokoll: eigener Reiter, 50 pro Seite, neueste zuerst');
$p3 = $render('protokoll', ['seite' => '3']);
$check(substr_count($p3, '-ende') === 20 && str_contains($p3, 'Eintrag-1-ende'), 'Protokoll Seite 3 mit 20 Einträgen');
$check(str_contains($render('protokoll', ['seite' => '99']), 'Seite 3 von 3'), 'zu große Seitenzahl wird begrenzt');
$check(str_contains($render('quatsch'), '<h3>Regeln</h3>'), 'unbekannter Reiter → Übersicht');
$check(str_contains($render('', [], ['id' => 2, 'role' => 'agent', 'name' => 'X']), 'nur für Administratoren') && !str_contains($render('protokoll', [], ['id' => 2, 'role' => 'agent', 'name' => 'X']), 'Eintrag-'), 'Nicht-Admins sehen nichts');
$check(datenschutz_tab_url('protokoll', ['seite' => 2]) === '/staff/plugin.php?p=datenschutz&page=datenschutz&tab=protokoll&seite=2', 'datenschutz_tab_url');
unset($GLOBALS['x']);
foreach (scandir($pluginDir) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') continue;
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
}
@rmdir($pluginDir);
@unlink($zipPath);
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo 'Datenschutz plugin checks passed.' . "\n";