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, '