Files
2026-10-30 21:50:16 +00:00

350 lines
24 KiB
PHP

<?php
declare(strict_types=1);
/**
* Prüft das Plugin "VirusTotal-Anhangsprüfung": Zurückhalten neuer Anhänge, Abfrage des Fingerabdrucks, Freigeben (sauber), Sperren (Schadsoftware, Schwelle),
* unbekannte Dateien (zurückhalten, freigeben, hochladen und Analyse abwarten), Limits (Minute, Tag), Fehler und Wiederholung, Handarbeit (freigeben,
* löschen, erneut prüfen) und die Seite. VirusTotal wird durch eine Funktion im Test ersetzt ($GLOBALS['virustotal_http']).
*/
$pluginDir = sys_get_temp_dir() . '/deskhub-vt-' . 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';
require_once __DIR__ . '/fixtures/paths.php';
$failures = [];
$check = function (bool $ok, string $what) use (&$failures): void { if (!$ok) $failures[] = $what; };
$pdo = get_db();
// Ticket und ein Anhang, der schon vor dem Einschalten da war
$uploads = rtrim(str_replace('\\', '/', (string) (config()['uploads']['path'] ?? dirname(__DIR__) . '/uploads')), '/');
@mkdir($uploads, 0755, true);
$now = db_now();
$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();
$pdo->prepare("INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, created_at, updated_at) VALUES ('VT-1','Test','K','k@example.test',?,?,'open','web',?,?)")->execute([$dept, $prio, $now, $now]);
$ticketId = (int) $pdo->lastInsertId();
$pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'customer', 'Anhang anbei', 0, ?)")->execute([$ticketId, $now]);
$threadId = (int) $pdo->lastInsertId();
$created = [];
$attach = function (string $name, string $content) use ($pdo, $threadId, $uploads, &$created, $now): int {
$stored = 'vttest-' . bin2hex(random_bytes(4)) . '.bin';
file_put_contents($uploads . '/' . $stored, $content);
$created[] = $stored;
$pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)')->execute([$threadId, $name, $stored, strlen($content), $now]);
return (int) $pdo->lastInsertId();
};
$old = $attach('alt.bin', 'Bestand von früher');
// ── Installation ──
$src = plugin_source_dir_or_skip('virustotal', 'plugin_virustotal');
$zipPath = sys_get_temp_dir() . '/vt-' . 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'] === 'virustotal', 'Plugin lässt sich installieren: ' . json_encode($r));
$check(!plugin_set_active($pdo, 'virustotal', true)['ok'], 'Aktivierung ohne Bestätigung der Berechtigungen wird abgelehnt');
$act = plugin_set_active($pdo, 'virustotal', true, ['hooks.tickets', '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(isset(plugin_state()['loaded']['virustotal']), 'Plugin wird geladen');
$check(count(plugin_admin_menu_items(['id' => 1, 'role' => 'admin'])) === 1 && count(plugin_admin_menu_items(['id' => 2, 'role' => 'agent'])) === 0, 'Menüpunkt nur für Administratoren im Adminportal');
$check((int) $pdo->query('SELECT COUNT(*) FROM plugin_virustotal_files')->fetchColumn() === 0, 'Tabelle wurde angelegt');
$check(vt_start_id($pdo) === $old, 'Die Startmarke steht beim Laden auf dem letzten vorhandenen Anhang');
// ── Ohne Schlüssel wird nichts zurückgehalten ──
$noKey = $attach('ohne-schluessel.bin', 'ohne Schlüssel');
plugin_hook('ticket.created', ['ticketId' => $ticketId]);
$stored = (string) $pdo->query("SELECT stored_name FROM ticket_attachments WHERE id = $noKey")->fetchColumn();
$check($stored !== 'gesperrt' && is_file($uploads . '/' . $stored) && vt_tick($pdo) === 0, 'Ohne Schlüssel bleibt der Anhang unberührt (sonst bliebe er für immer gesperrt)');
// ── VirusTotal nachgebaut ──
$vt = ['files' => [], 'analyses' => [], 'calls' => [], 'uploads' => [], 'default' => [404, '{}'], 'upload' => [200, json_encode(['data' => ['type' => 'analysis', 'id' => 'ana-1']])]];
$stats = fn(int $mal, int $harm = 60, int $und = 10, int $susp = 0) => json_encode(['data' => ['attributes' => ['last_analysis_stats' => ['malicious' => $mal, 'suspicious' => $susp, 'undetected' => $und, 'harmless' => $harm]]]]);
$GLOBALS['virustotal_http'] = function (string $method, string $url, string $key, array $opts) use (&$vt): array {
$vt['calls'][] = "$method " . substr($url, strlen(VT_API) + 1);
if ($key !== 'a' . str_repeat('1', 63)) return ['status' => 401, 'body' => '{}'];
$path = substr($url, strlen(VT_API) + 1);
if ($method === 'POST' && $path === 'files') { $vt['uploads'][] = $opts; return ['status' => $vt['upload'][0], 'body' => $vt['upload'][1]]; }
if (str_starts_with($path, 'files/')) { $e = $vt['files'][substr($path, 6)] ?? $vt['default']; return ['status' => $e[0], 'body' => $e[1]]; }
if (str_starts_with($path, 'analyses/')) { $e = $vt['analyses'][rawurldecode(substr($path, 9))] ?? [404, '{}']; return ['status' => $e[0], 'body' => $e[1]]; }
return ['status' => 404, 'body' => '{}'];
};
$key = 'a' . str_repeat('1', 63);
plugin_set_setting('virustotal', 'key_enc', encrypt_secret($key));
$check(vt_config()['key'] === $key && !str_contains((string) get_setting('plugin.virustotal.key_enc', ''), $key), 'Schlüssel wird verschlüsselt gespeichert und gelesen');
$tick = function () use ($pdo) { plugin_set_setting('virustotal', 'tick_at', '0'); return vt_tick($pdo); };
$rowOf = fn(int $attId) => $pdo->query("SELECT * FROM plugin_virustotal_files WHERE attachment_id = $attId")->fetch();
$attOf = fn(int $attId) => $pdo->query("SELECT * FROM ticket_attachments WHERE id = $attId")->fetch();
// ── Der vorhandene und der schlüssellose Anhang werden jetzt (der schlüssellose nachträglich) erfasst, der alte nicht ──
$hold = vt_unseen($pdo);
$check(count($hold) === 1 && (int) $hold[0]['id'] === $noKey, 'Nachträglich erfasst wird nur, was nach dem Einschalten kam (nicht der Bestand)');
vt_hold($pdo, $hold[0]);
$sha = hash('sha256', 'ohne Schlüssel');
$vt['files'][$sha] = [200, $stats(0)];
$check($tick() === 1 && $rowOf($noKey)['status'] === 'clean', 'Der nachträglich zurückgehaltene Anhang wird geprüft und freigegeben');
// ── Sauber: zurückhalten, dann freigeben ──
$a1 = $attach('sauber.pdf', 'sauberer Inhalt');
plugin_hook('ticket.replied', ['ticketId' => $ticketId]);
$att = $attOf($a1);
$row = $rowOf($a1);
$check($att['stored_name'] === 'gesperrt' && str_starts_with($att['original_name'], '[IN PRÜFUNG]') && !is_file($uploads . '/' . $row['stored_name']) && is_file(vt_hold_dir() . '/' . $row['stored_name']), 'Neuer Anhang ist sofort gesperrt, umbenannt und aus dem Download-Ordner entfernt');
$check($row['status'] === 'pending' && $row['sha256'] === hash('sha256', 'sauberer Inhalt'), 'Eintrag wartet und enthält den SHA-256-Fingerabdruck');
$notes = (string) $pdo->query("SELECT GROUP_CONCAT(message, ' | ') FROM ticket_threads WHERE ticket_id = $ticketId AND author_type = 'system'")->fetchColumn();
$check(str_contains($notes, 'wird bei VirusTotal geprüft') && str_contains($notes, 'sauber.pdf'), 'Im Ticket steht ein Hinweis „wird geprüft“');
$vt['calls'] = [];
$vt['files'][hash('sha256', 'sauberer Inhalt')] = [200, $stats(0)];
$check($tick() === 1, 'Ein Lauf fragt den Fingerabdruck ab');
$check($vt['calls'] === ['GET files/' . hash('sha256', 'sauberer Inhalt')], 'Abgefragt wird nur der Fingerabdruck, keine Datei hochgeladen: ' . json_encode($vt['calls']));
$att = $attOf($a1);
$row = $rowOf($a1);
$check($row['status'] === 'clean' && $att['stored_name'] === $row['stored_name'] && $att['original_name'] === 'sauber.pdf' && is_file($uploads . '/' . $row['stored_name']) && !is_file(vt_hold_dir() . '/' . $row['stored_name']), 'Saubere Datei ist freigegeben: Name und Datei zurück, Download wieder möglich');
$check((int) $row['engines'] === 70 && (int) $row['malicious'] === 0, 'Anzahl der Scanner wird vermerkt');
// ── Schadsoftware ──
$a2 = $attach('rechnung.exe', 'boeser Inhalt');
plugin_hook('ticket.replied', ['ticketId' => $ticketId]);
$vt['files'][hash('sha256', 'boeser Inhalt')] = [200, $stats(41, 20, 9)];
$tick();
$att = $attOf($a2);
$row = $rowOf($a2);
$check($row['status'] === 'bad' && $att['stored_name'] === 'gesperrt' && str_starts_with($att['original_name'], '[GESPERRT]') && is_file(vt_hold_dir() . '/' . $row['stored_name']) && !is_file($uploads . '/' . $row['stored_name']), 'Schadsoftware bleibt gesperrt und in der Quarantäne');
$check(str_contains((string) $row['detail'], '41 von 70'), 'Meldung nennt die Zahl der Scanner: ' . $row['detail']);
$tags = (string) $pdo->query("SELECT GROUP_CONCAT(tg.name) FROM ticket_tags tt JOIN tags tg ON tg.id = tt.tag_id WHERE tt.ticket_id = $ticketId")->fetchColumn();
$check(str_contains($tags, 'Virus-Verdacht'), 'Das Ticket bekommt den Tag „Virus-Verdacht“');
$notes = (string) $pdo->query("SELECT GROUP_CONCAT(message, ' | ') FROM ticket_threads WHERE ticket_id = $ticketId AND author_type = 'system'")->fetchColumn();
$check(str_contains($notes, 'als gefährlich eingestuft'), 'Im Ticket steht ein Hinweis auf die Sperre');
// ── Schwelle ──
$a3 = $attach('grenzfall.bin', 'grenzfall');
plugin_hook('ticket.replied', ['ticketId' => $ticketId]);
$vt['files'][hash('sha256', 'grenzfall')] = [200, $stats(2)];
$tick();
$check($rowOf($a3)['status'] === 'clean', 'Zwei Funde bei Schwelle 3 sind kein Grund zur Sperre (Fehlalarme)');
$a4 = $attach('grenzfall2.bin', 'grenzfall2');
plugin_hook('ticket.replied', ['ticketId' => $ticketId]);
plugin_set_setting('virustotal', 'threshold', '2');
$vt['files'][hash('sha256', 'grenzfall2')] = [200, $stats(2)];
$tick();
$check($rowOf($a4)['status'] === 'bad', 'Bei Schwelle 2 werden zwei Funde gesperrt');
plugin_set_setting('virustotal', 'threshold', '3');
// ── Unbekannte Dateien ──
$a5 = $attach('neu.bin', 'ganz neu');
plugin_hook('ticket.replied', ['ticketId' => $ticketId]);
$vt['calls'] = [];
$tick();
$row = $rowOf($a5);
$check($row['status'] === 'unknown' && $attOf($a5)['stored_name'] === 'gesperrt' && $vt['uploads'] === [], 'Unbekannte Datei bleibt zurückgehalten und wird nicht hochgeladen (Standard)');
// Von Hand freigeben
$check(vt_release_manual($pdo, (int) $row['id']) && $rowOf($a5)['status'] === 'released' && $attOf($a5)['original_name'] === 'neu.bin' && is_file($uploads . '/' . $row['stored_name']), 'Von Hand freigegeben: Datei und Name sind zurück');
// Erneut prüfen
$a6 = $attach('neu2.bin', 'ganz neu 2');
plugin_hook('ticket.replied', ['ticketId' => $ticketId]);
$tick();
$check($rowOf($a6)['status'] === 'unknown', 'Zweite unbekannte Datei wird zurückgehalten');
$vt['files'][hash('sha256', 'ganz neu 2')] = [200, $stats(0)];
$check(vt_recheck($pdo, (int) $rowOf($a6)['id']) && $rowOf($a6)['status'] === 'pending', 'Erneut prüfen setzt auf „wartet“');
$tick();
$check($rowOf($a6)['status'] === 'clean', 'Nach der erneuten Prüfung (inzwischen bekannt) wird sie freigegeben');
// Freigeben per Einstellung
plugin_set_setting('virustotal', 'unknown', 'allow');
$a7 = $attach('neu3.bin', 'ganz neu 3');
plugin_hook('ticket.replied', ['ticketId' => $ticketId]);
$tick();
$check($rowOf($a7)['status'] === 'released' && $attOf($a7)['stored_name'] !== 'gesperrt', 'Mit „Unbekannte freigeben“ wird sie ohne Warten freigegeben');
plugin_set_setting('virustotal', 'unknown', 'hold');
// Datei ohne Analyse (VirusTotal kennt den Hash, hat aber noch nichts ausgewertet)
$a8 = $attach('leer.bin', 'ohne analyse');
plugin_hook('ticket.replied', ['ticketId' => $ticketId]);
$vt['files'][hash('sha256', 'ohne analyse')] = [200, json_encode(['data' => ['attributes' => ['last_analysis_stats' => ['malicious' => 0, 'suspicious' => 0, 'undetected' => 0, 'harmless' => 0]]]])];
$tick();
$check($rowOf($a8)['status'] === 'unknown', 'Ein Treffer ohne Analyse-Ergebnis zählt nicht als sauber, sondern als unbekannt');
// ── Hochladen (ausdrücklich eingeschaltet) ──
plugin_set_setting('virustotal', 'upload', '1');
$a9 = $attach('hochladen.bin', 'bitte analysieren');
plugin_hook('ticket.replied', ['ticketId' => $ticketId]);
$vt['uploads'] = [];
$tick();
$row = $rowOf($a9);
$check($row['status'] === 'analyzing' && $row['analysis_id'] === 'ana-1' && count($vt['uploads']) === 1 && $vt['uploads'][0]['name'] === 'hochladen.bin' && str_starts_with(str_replace('\\', '/', $vt['uploads'][0]['file']), str_replace('\\', '/', vt_hold_dir())), 'Unbekannte Datei wird (eingeschaltet) hochgeladen und wartet auf die Analyse: ' . json_encode($row['status']));
$due = fn() => $pdo->exec("UPDATE plugin_virustotal_files SET next_try = '2000-01-01 00:00:00' WHERE status IN ('pending','analyzing')");
$vt['analyses']['ana-1'] = [200, json_encode(['data' => ['attributes' => ['status' => 'queued']]])];
$due();
$tick();
$check($rowOf($a9)['status'] === 'analyzing', 'Läuft die Analyse noch, wird weiter gewartet');
$vt['analyses']['ana-1'] = [200, json_encode(['data' => ['attributes' => ['status' => 'completed', 'stats' => ['malicious' => 0, 'suspicious' => 0, 'undetected' => 12, 'harmless' => 55]]]])];
$due();
$tick();
$check($rowOf($a9)['status'] === 'clean' && $attOf($a9)['original_name'] === 'hochladen.bin', 'Fertige Analyse ohne Funde: freigegeben');
$a10 = $attach('hochladen-boese.bin', 'bitte analysieren boese');
plugin_hook('ticket.replied', ['ticketId' => $ticketId]);
$vt['upload'] = [200, json_encode(['data' => ['type' => 'analysis', 'id' => 'ana-2']])];
$tick();
$vt['analyses']['ana-2'] = [200, json_encode(['data' => ['attributes' => ['status' => 'completed', 'stats' => ['malicious' => 9, 'suspicious' => 0, 'undetected' => 12, 'harmless' => 40]]]])];
$due();
$tick();
$check($rowOf($a10)['status'] === 'bad', 'Fertige Analyse mit Funden: gesperrt');
// Zu groß für den Upload
$a11 = $attach('riesig.bin', 'klein');
$pdo->prepare('UPDATE ticket_attachments SET filesize = ? WHERE id = ?')->execute([40 * 1048576, $a11]);
plugin_hook('ticket.replied', ['ticketId' => $ticketId]);
$vt['uploads'] = [];
$p = vt_hold_dir() . '/' . $rowOf($a11)['stored_name'];
$fh = fopen($p, 'wb');
ftruncate($fh, VT_UPLOAD_MAX + 1024);
fclose($fh);
$tick();
$check($rowOf($a11)['status'] === 'unknown' && $vt['uploads'] === [] && str_contains((string) $rowOf($a11)['detail'], '32 MB'), 'Dateien über 32 MB werden nicht hochgeladen, sondern zurückgehalten');
plugin_set_setting('virustotal', 'upload', '0');
// ── Limits und Fehler ──
// 4 pro Lauf
$ids = [];
for ($i = 0; $i < 6; $i++) {
$ids[] = $attach("viele$i.bin", "viele $i");
$vt['files'][hash('sha256', "viele $i")] = [200, $stats(0)];
}
plugin_hook('ticket.replied', ['ticketId' => $ticketId]);
$check($tick() === 4, 'Höchstens 4 Abfragen pro Lauf (Grenze des kostenlosen Schlüssels)');
$check($tick() === 2, 'Der nächste Lauf erledigt den Rest');
// Minutenlimit (429): Lauf endet, Eintrag bleibt
$a12 = $attach('limit.bin', 'limit');
$a13 = $attach('limit2.bin', 'limit2');
plugin_hook('ticket.replied', ['ticketId' => $ticketId]);
$vt['files'][hash('sha256', 'limit')] = [429, '{}'];
$vt['calls'] = [];
$tick();
$check($rowOf($a12)['status'] === 'pending' && count($vt['calls']) === 1 && strtotime((string) $rowOf($a12)['next_try']) > time() + 30, 'Bei 429 endet der Lauf und die Datei wird in einer Minute noch einmal versucht');
$vt['files'][hash('sha256', 'limit')] = [200, $stats(0)];
$vt['files'][hash('sha256', 'limit2')] = [200, $stats(0)];
$pdo->exec("UPDATE plugin_virustotal_files SET next_try = '2000-01-01 00:00:00'");
$tick();
$check($rowOf($a12)['status'] === 'clean' && $rowOf($a13)['status'] === 'clean', 'Danach klappt es');
// Schlüssel abgelehnt
$a14 = $attach('schluessel.bin', 'schluessel');
plugin_hook('ticket.replied', ['ticketId' => $ticketId]);
plugin_set_setting('virustotal', 'key_enc', encrypt_secret('b' . str_repeat('2', 63)));
$tick();
$check($rowOf($a14)['status'] === 'pending' && str_contains((string) plugin_setting('virustotal', 'last_error', ''), 'abgelehnt'), 'Ein abgelehnter Schlüssel hält die Datei zurück und wird gemeldet');
plugin_set_setting('virustotal', 'key_enc', encrypt_secret($key));
plugin_set_setting('virustotal', 'last_error', '');
$vt['files'][hash('sha256', 'schluessel')] = [200, $stats(0)];
$tick();
$check($rowOf($a14)['status'] === 'clean', 'Mit dem richtigen Schlüssel geht es weiter');
// Fehler: Wiederholungen, dann zurückhalten
$a15 = $attach('fehler.bin', 'fehler');
plugin_hook('ticket.replied', ['ticketId' => $ticketId]);
$vt['files'][hash('sha256', 'fehler')] = [500, '{}'];
for ($i = 0; $i < 6; $i++) {
$pdo->exec("UPDATE plugin_virustotal_files SET next_try = '2000-01-01 00:00:00' WHERE status = 'pending'");
$tick();
if ($i === 0) $check($rowOf($a15)['status'] === 'pending' && (int) $rowOf($a15)['tries'] === 1, 'Ein Fehler wird später wiederholt');
}
$check($rowOf($a15)['status'] === 'error' && $attOf($a15)['stored_name'] === 'gesperrt', 'Nach sechs Fehlern bleibt die Datei zurückgehalten (Einstellung „zurückhalten“)');
$a16 = $attach('fehler2.bin', 'fehler2');
plugin_set_setting('virustotal', 'on_error', 'allow');
plugin_hook('ticket.replied', ['ticketId' => $ticketId]);
$vt['files'][hash('sha256', 'fehler2')] = [500, '{}'];
for ($i = 0; $i < 6; $i++) {
$pdo->exec("UPDATE plugin_virustotal_files SET next_try = '2000-01-01 00:00:00' WHERE status = 'pending'");
$tick();
}
$check($rowOf($a16)['status'] === 'released', 'Mit „bei Fehler freigeben“ wird die Datei nach den Versuchen freigegeben');
plugin_set_setting('virustotal', 'on_error', 'hold');
// Tageslimit
plugin_set_setting('virustotal', 'day', date('Y-m-d'));
plugin_set_setting('virustotal', 'day_count', (string) VT_DAY_LIMIT);
$a17 = $attach('tageslimit.bin', 'tageslimit');
plugin_hook('ticket.replied', ['ticketId' => $ticketId]);
$vt['files'][hash('sha256', 'tageslimit')] = [200, $stats(0)];
$vt['calls'] = [];
$check($tick() === 0 && $vt['calls'] === [] && $rowOf($a17)['status'] === 'pending' && str_contains((string) plugin_setting('virustotal', 'last_error', ''), 'Tageslimit'), 'Ist das Tageslimit erreicht, wird nicht mehr abgefragt (und es wird gemeldet)');
plugin_set_setting('virustotal', 'day_count', '0');
$check($tick() === 1 && $rowOf($a17)['status'] === 'clean', 'Am nächsten Tag geht es weiter');
// Zwei Läufe überlappen nicht
$a18 = $attach('doppelt.bin', 'doppelt');
plugin_hook('ticket.replied', ['ticketId' => $ticketId]);
$vt['files'][hash('sha256', 'doppelt')] = [200, $stats(0)];
plugin_set_setting('virustotal', 'tick_at', '0');
$check(vt_tick($pdo) === 1 && vt_tick($pdo) === 0, 'Ein zweiter Lauf direkt danach tut nichts (keine Überlappung)');
// ── Dateien, die nicht zurückgehalten werden können ──
$gone = $attach('weg.bin', 'weg');
@unlink($uploads . '/' . $attOf($gone)['stored_name']);
plugin_hook('ticket.replied', ['ticketId' => $ticketId]);
$check($rowOf($gone)['status'] === 'error' && str_contains((string) $rowOf($gone)['detail'], 'nicht gefunden'), 'Fehlt die Datei, steht ein Fehler im Eintrag (und es geht nichts kaputt)');
// ── Löschen ──
$bad = $rowOf($a2);
$check(vt_delete($pdo, (int) $bad['id']) && $rowOf($a2)['status'] === 'deleted' && !is_file(vt_hold_dir() . '/' . $bad['stored_name']) && str_starts_with($attOf($a2)['original_name'], '[GELÖSCHT]'), 'Löschen entfernt die Datei und vermerkt es');
$check(!vt_delete($pdo, (int) $rowOf($a1)['id']) && !vt_release_manual($pdo, (int) $rowOf($a1)['id']), 'Freigegebene Dateien lassen sich nicht noch einmal löschen oder freigeben');
// ── Schlüsseltest ──
$vt['files'][VT_EICAR_SHA256] = [200, $stats(60, 5, 5)];
$t = vt_test_key($key, 3);
$check($t['ok'] && str_contains($t['message'], 'EICAR') && str_contains($t['message'], '60 von 70'), 'Der Schlüsseltest erkennt EICAR bei VirusTotal');
$check(!vt_test_key('b' . str_repeat('2', 63), 3)['ok'] && !vt_test_key('', 3)['ok'], 'Falscher oder fehlender Schlüssel: Test schlägt fehl');
// ── Seite ──
$render = function (array $staffRow, array $post = []) use ($pluginDir) {
global $staff;
$staff = $staffRow;
$_SERVER['REQUEST_METHOD'] = $post ? 'POST' : 'GET';
$_POST = $post ? $post + ['csrf_token' => csrf_token()] : [];
ob_start();
include $pluginDir . '/virustotal/pages/virustotal.php';
return (string) ob_get_clean();
};
$admin = ['id' => 1, 'role' => 'admin', 'name' => 'Admin'];
$page = $render($admin);
$check(str_contains($page, 'VirusTotal') && str_contains($page, 'Aktiv') && str_contains($page, 'Zurückgehaltene und gesperrte Anhänge') && !str_contains($page, $key), 'Seite zeigt den Zustand und nennt den Schlüssel nicht');
$denied = $render(['id' => 2, 'role' => 'agent', 'name' => 'A']);
$check(str_contains($denied, 'Keine Berechtigung') && !str_contains($denied, 'Einstellungen'), 'Die Seite ist nur für Administratoren');
$page = $render($admin, ['action' => 'save', 'key' => 'zu-kurz', 'threshold' => '3', 'unknown' => 'hold']);
$check(str_contains($page, '64 Zeichen') && vt_config()['key'] === $key, 'Ein ungültiger Schlüssel wird abgelehnt, der alte bleibt');
$page = $render($admin, ['action' => 'save', 'key' => '', 'threshold' => '99', 'unknown' => 'allow', 'on_error' => 'allow', 'upload' => '1', 'notify' => '1']);
$check(vt_config()['threshold'] === 30 && vt_config()['unknown'] === 'allow' && vt_config()['upload'] === true && vt_config()['key'] === $key, 'Speichern setzt die Einstellungen (Schwelle begrenzt) und lässt den Schlüssel unverändert');
$check(str_contains((string) $pdo->query("SELECT GROUP_CONCAT(action, ' | ') FROM admin_audit_log WHERE action LIKE 'VirusTotal: Einstellungen%'")->fetchColumn(), 'Upload unbekannter Dateien an'), 'Das Einschalten des Uploads steht im Admin-Protokoll');
plugin_set_setting('virustotal', 'unknown', 'hold');
plugin_set_setting('virustotal', 'upload', '0');
plugin_set_setting('virustotal', 'threshold', '3');
$page = $render($admin, ['action' => 'test']);
$check(str_contains($page, 'Der Schlüssel funktioniert'), 'Die Seite führt den Schlüsseltest aus');
$page = $render($admin, ['action' => 'release', 'id' => (string) $rowOf($a5)['id']]);
$check(str_contains($page, 'ließ sich nicht freigeben'), 'Schon freigegebene Dateien lassen sich über die Seite nicht noch einmal freigeben');
$a19 = $attach('seite.bin', 'seite');
plugin_hook('ticket.replied', ['ticketId' => $ticketId]);
$tick();
$page = $render($admin);
$check(str_contains($page, 'seite.bin') && str_contains($page, 'Unbekannt (zurückgehalten)') && str_contains($page, 'value="release"'), 'Zurückgehaltene Dateien erscheinen mit Knöpfen');
$page = $render($admin, ['action' => 'release', 'id' => (string) $rowOf($a19)['id']]);
$check(str_contains($page, 'Die Datei ist freigegeben') && $rowOf($a19)['status'] === 'released', 'Freigeben über die Seite');
$check(str_contains((string) $pdo->query("SELECT GROUP_CONCAT(action, ' | ') FROM admin_audit_log WHERE action LIKE 'VirusTotal: Anhang%'")->fetchColumn(), 'von Hand freigegeben'), 'Freigabe steht im Admin-Protokoll');
foreach ($created as $f) { @unlink($uploads . '/' . $f); @unlink(vt_hold_dir() . '/' . $f); }
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 "VirusTotal plugin checks passed.\n";