Upload via GUI (360 Dateien)
This commit is contained in:
@@ -116,6 +116,12 @@ $check($s === 422 && ($b['error']['code'] ?? '') === 'INVALID_EMAIL', 'ungültig
|
||||
$check($s === 200 && ($b['ticket']['status'] ?? '') === 'open' && count($b['ticket']['messages'] ?? []) === 1, 'Status per API abgefragt');
|
||||
[$s, $b] = $call('GET', '?number=' . urlencode($apiNumber) . '&email=fremd@example.test', null, $created['key']);
|
||||
$check($s === 404, 'falsche E-Mail-Adresse: Ticket bleibt verborgen (404)');
|
||||
// Entwarnung (Minecraft-Plugin): Ticket wird gelöst, der Text steht unverändert (nicht doppelt maskiert) in der internen Notiz
|
||||
[$s, $b] = $call('POST', '', ['action' => 'resolve', 'number' => $apiNumber, 'email' => 'eva@example.test', 'message' => 'Der Server "Lobby" ist wieder erreichbar & <b>ok</b>.'], $created['key']);
|
||||
$check($s === 200 && ($b['status'] ?? '') === 'resolved', 'Entwarnung per API löst das Ticket');
|
||||
$noteRow = $pdo->prepare("SELECT message FROM ticket_threads WHERE ticket_id = (SELECT id FROM tickets WHERE ticket_number = ?) AND is_internal_note = 1 ORDER BY id DESC LIMIT 1");
|
||||
$noteRow->execute([$apiNumber]);
|
||||
$check($noteRow->fetchColumn() === 'Der Server "Lobby" ist wieder erreichbar & ok.', 'Notiz bei Entwarnung ist Klartext ohne HTML-Entities: ' . json_encode($noteRow->fetchColumn()));
|
||||
$readOnly = api_key_create($pdo, 'Nur lesen', false, true, '', $staffId);
|
||||
[$s] = $call('POST', '', ['name' => 'Eva', 'email' => 'eva@example.test', 'subject' => 'x', 'message' => 'y'], $readOnly['key']);
|
||||
$check($s === 403, 'Schlüssel ohne Recht zum Anlegen: 403');
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft den Anmelde-Wächter: erste Anmeldung still gemerkt, bekanntes Gerät ohne Warnung, neues Gerät und neuer Ort mit Warnung
|
||||
* (Link „Das war ich nicht“), App-Anmeldungen, Fehlversuche und Sperre im Protokoll (einmal je Sperrzeit), „Konto sichern“
|
||||
* (Passwort ungültig, Tokens und Geräte weg, Sitzungen ungültig, Link nur einmal), Ablauf des Links, Ausschalter und Aufbewahrung.
|
||||
*/
|
||||
|
||||
$db = sys_get_temp_dir() . '/deskhub-lg-' . bin2hex(random_bytes(4)) . '.sqlite';
|
||||
putenv('DESKHUB_TESTING=1');
|
||||
putenv('DESKHUB_SQLITE_PATH=' . $db);
|
||||
require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
||||
|
||||
$failures = [];
|
||||
$check = function (bool $ok, string $what) use (&$failures): void { if (!$ok) $failures[] = $what; };
|
||||
$pdo = get_db();
|
||||
$pdo->prepare("INSERT INTO staff (name, email, password_hash, role, is_active, created_at) VALUES (?,?,?,?,1,?)")
|
||||
->execute(['Test Person', 'person@firma.example', password_hash('geheim-123', PASSWORD_DEFAULT), 'agent', db_now()]);
|
||||
$sid = (int) $pdo->lastInsertId();
|
||||
$pdo->prepare("INSERT INTO staff (name, email, password_hash, role, is_active, created_at) VALUES (?,?,?,?,1,?)")
|
||||
->execute(['Chef', 'chef@firma.example', password_hash('x', PASSWORD_DEFAULT), 'admin', db_now()]);
|
||||
$staff = fn() => $pdo->query('SELECT * FROM staff WHERE id = ' . $sid)->fetch();
|
||||
$events = fn(string $ev = '') => (int) $pdo->query('SELECT COUNT(*) FROM login_events' . ($ev !== '' ? " WHERE event = '$ev'" : ''))->fetchColumn();
|
||||
$chrome = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
|
||||
$phone = 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36';
|
||||
$as = function (string $ip, string $ua, ?string $cookie): void {
|
||||
$_SERVER['REMOTE_ADDR'] = $ip;
|
||||
$_SERVER['HTTP_USER_AGENT'] = $ua;
|
||||
unset($_COOKIE[LOGIN_GUARD_COOKIE]);
|
||||
if ($cookie !== null) $_COOKIE[LOGIN_GUARD_COOKIE] = $cookie;
|
||||
};
|
||||
$dev1 = str_repeat('a', 32);
|
||||
$dev2 = str_repeat('b', 32);
|
||||
|
||||
// Beschreibung und Ort
|
||||
$check(login_guard_describe_device($chrome) === 'Chrome auf Windows', 'Gerätename: Chrome auf Windows');
|
||||
$check(login_guard_describe_device($phone) === 'Chrome auf Android', 'Gerätename: Chrome auf Android');
|
||||
$check(login_guard_place('203.0.113.9')[0] === 'N:203.0' && login_guard_place('10.0.0.5')[0] === 'local', 'Ort: Netz der IP bzw. lokales Netz');
|
||||
$check(str_starts_with(login_guard_place('2a02:810d:1::5')[0], 'N6:2a02:810d'), 'Ort: IPv6-Netz');
|
||||
|
||||
// 1) Erste Anmeldung: still gemerkt, keine Warnung
|
||||
$as('203.0.113.9', $chrome, $dev1);
|
||||
$GLOBALS['login_guard_last_link'] = null;
|
||||
$check(login_guard_success($staff(), 'web') === 'first', 'Erste Anmeldung wird still gemerkt');
|
||||
$check($GLOBALS['login_guard_last_link'] === null && $events('success') === 1, 'Erste Anmeldung: keine Warnung, ein Protokolleintrag');
|
||||
|
||||
// 2) Bekanntes Gerät, gleicher Ort
|
||||
$check(login_guard_success($staff(), 'web') === 'known' && $GLOBALS['login_guard_last_link'] === null, 'Bekanntes Gerät: keine Warnung');
|
||||
|
||||
// 3) Neues Gerät: Warnung mit Link
|
||||
$as('203.0.113.9', $phone, $dev2);
|
||||
$check(login_guard_success($staff(), 'web') === 'new_device', 'Neues Gerät wird erkannt');
|
||||
$link = (string) $GLOBALS['login_guard_last_link'];
|
||||
$check(str_contains($link, '/staff/login_alert.php?t='), 'Warn-Link wurde erzeugt');
|
||||
$token1 = substr($link, strpos($link, 't=') + 2);
|
||||
$check($events('new_device') === 1, 'Protokoll: Neues Gerät');
|
||||
$check(login_guard_success($staff(), 'web') === 'known', 'Das neue Gerät ist danach bekannt');
|
||||
|
||||
// 4) Bekanntes Gerät, neuer Ort
|
||||
$GLOBALS['login_guard_last_link'] = null;
|
||||
$as('198.51.100.7', $chrome, $dev1);
|
||||
$check(login_guard_success($staff(), 'web') === 'new_place' && $GLOBALS['login_guard_last_link'] !== null, 'Neuer Ort vom bekannten Gerät wird gemeldet');
|
||||
$token2 = substr((string) $GLOBALS['login_guard_last_link'], -48);
|
||||
$GLOBALS['login_guard_last_link'] = null;
|
||||
$as('192.0.2.77', $chrome, $dev1);
|
||||
set_setting('login_guard_warn_place', '0');
|
||||
$check(login_guard_success($staff(), 'web') === 'known' && $GLOBALS['login_guard_last_link'] === null, 'Ort-Warnung lässt sich ausschalten');
|
||||
set_setting('login_guard_warn_place', '1');
|
||||
|
||||
// 5) App: Gerät = Gerätename
|
||||
$as('203.0.113.9', 'okhttp/4.12', null);
|
||||
$check(login_guard_success($staff(), 'app', 'Pixel 8') === 'new_device', 'App mit neuem Gerätenamen wird gemeldet');
|
||||
$check(login_guard_success($staff(), 'app', 'Pixel 8') === 'known', 'Gleiches App-Gerät ist bekannt');
|
||||
$check((int) $pdo->query("SELECT COUNT(*) FROM login_devices WHERE channel = 'app'")->fetchColumn() === 1, 'App-Gerät gemerkt');
|
||||
|
||||
// 6) Fehlversuche und Sperre
|
||||
login_guard_failed(null, 'unbekannt@x.example', 'web', 'Unbekannte E-Mail-Adresse');
|
||||
login_guard_failed($staff(), 'person@firma.example', 'web', 'Falsches Passwort');
|
||||
$check($events('failed') === 2, 'Fehlversuche stehen im Protokoll');
|
||||
login_guard_locked('person@firma.example', 'web');
|
||||
login_guard_locked('person@firma.example', 'web');
|
||||
$check($events('locked') === 1, 'Sperre wird je Sperrzeit nur einmal protokolliert');
|
||||
$pdo->exec("UPDATE login_events SET created_at = '2000-01-01 00:00:00' WHERE event = 'locked'");
|
||||
login_guard_locked('person@firma.example', 'web');
|
||||
$check($events('locked') === 2, 'Nach der Sperrzeit wird eine neue Sperre wieder protokolliert');
|
||||
set_setting('login_guard_max_attempts', '999');
|
||||
$check(login_guard_max_attempts() === 20, 'Anzahl Fehlversuche ist auf 20 begrenzt');
|
||||
set_setting('login_guard_max_attempts', '5');
|
||||
|
||||
// 7) Link prüfen, einmal verwenden
|
||||
$check(login_guard_find_alert($pdo, 'zzz') === null && login_guard_find_alert($pdo, str_repeat('0', 48)) === null, 'Ungültiger Link wird abgelehnt');
|
||||
$ev = login_guard_find_alert($pdo, $token1);
|
||||
$check($ev !== null && (int) $ev['staff_id'] === $sid, 'Gültiger Link findet den Eintrag');
|
||||
$pdo->prepare('UPDATE login_events SET created_at = ? WHERE id = ?')->execute(['2000-01-01 00:00:00', (int) $ev['id']]);
|
||||
$check(login_guard_find_alert($pdo, $token1) === null, 'Link ist nach 7 Tagen abgelaufen');
|
||||
|
||||
// 8) Konto sichern
|
||||
$pdo->prepare('INSERT INTO mobile_api_tokens (staff_id, token_hash, device_name, expires_at, created_at, last_used_at) VALUES (?,?,?,?,?,?)')
|
||||
->execute([$sid, hash('sha256', 't'), 'Pixel 8', date('Y-m-d H:i:s', time() + 3600), db_now(), db_now()]);
|
||||
$ev2 = login_guard_find_alert($pdo, $token2);
|
||||
$check($ev2 !== null, 'Zweiter Link ist gültig');
|
||||
$_SESSION = ['staff_login_at' => time() - 100];
|
||||
$check(login_guard_session_revoked($sid) === false, 'Vor dem Sichern ist die Sitzung gültig');
|
||||
$check(login_guard_revoke($pdo, $ev2) === true, 'Konto sichern gelingt');
|
||||
$check(!password_verify('geheim-123', (string) $staff()['password_hash']), 'Passwort ist ungültig');
|
||||
$check((int) $pdo->query('SELECT COUNT(*) FROM mobile_api_tokens WHERE staff_id = ' . $sid)->fetchColumn() === 0, 'App-Anmeldungen sind beendet');
|
||||
$check((int) $pdo->query('SELECT COUNT(*) FROM login_devices WHERE staff_id = ' . $sid)->fetchColumn() === 0, 'Gemerkte Geräte sind vergessen');
|
||||
$check($events('revoked') === 1, 'Protokoll: Konto gesichert');
|
||||
$check(login_guard_session_revoked($sid) === true, 'Ältere Sitzungen sind ungültig');
|
||||
$_SESSION = ['staff_login_at' => time() + 5];
|
||||
$check(login_guard_session_revoked($sid) === false, 'Eine spätere Anmeldung bleibt gültig');
|
||||
$check(login_guard_find_alert($pdo, $token2) === null, 'Der Link ist nur einmal verwendbar');
|
||||
|
||||
// 9) Ausschalter
|
||||
set_setting('login_guard', '0');
|
||||
$before = $events();
|
||||
$as('203.0.113.9', $chrome, str_repeat('c', 32));
|
||||
$check(login_guard_success($staff(), 'web') === 'off' && $events() === $before, 'Ausgeschaltet wird nichts gemerkt oder protokolliert');
|
||||
login_guard_failed(null, 'x@y.example', 'web', 'x');
|
||||
$check($events() === $before, 'Ausgeschaltet gibt es auch keine Fehlversuch-Einträge');
|
||||
set_setting('login_guard', '1');
|
||||
|
||||
// 10) Aufbewahrung
|
||||
$pdo->exec("UPDATE login_events SET created_at = '2000-01-01 00:00:00' WHERE id IN (SELECT id FROM login_events ORDER BY id LIMIT 3)");
|
||||
$n0 = $events();
|
||||
$old = (int) $pdo->query("SELECT COUNT(*) FROM login_events WHERE created_at < '2001-01-01'")->fetchColumn();
|
||||
login_guard_purge($pdo);
|
||||
$check($old >= 3 && $events() === $n0 - $old, 'Alte Einträge werden nach der Aufbewahrungsfrist gelöscht');
|
||||
|
||||
@unlink($db);
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Anmelde-Wächter-Prüfung fehlgeschlagen:\n- " . implode("\n- ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo "Anmelde-Wächter-Prüfungen bestanden.\n";
|
||||
@@ -0,0 +1,458 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft das Plugin "authentik-Anmeldung": Einrichtung, Knopf, Start des OpenID-Connect-Ablaufs (PKCE, Zustand, Nonce), Rückkehr mit allen
|
||||
* Prüfungen (Zustand, Wiederholung, Nonce, Aussteller, Zielgruppe, Ablauf, E-Mail, Gruppe), Zuordnung zu aktiven Mitarbeitern und Einstellungsseite.
|
||||
* authentik wird durch eine Funktion im Test ersetzt ($GLOBALS['authentik_http']); die Weiterleitungs- und Anmelde-Logik des Servers prüft tests/plugin_login.php.
|
||||
*/
|
||||
|
||||
$pluginDir = sys_get_temp_dir() . '/deskhub-ak-' . 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();
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) @session_start();
|
||||
$_SESSION = [];
|
||||
|
||||
$src = plugin_source_dir_or_skip('authentik', 'plugin_authentik');
|
||||
$zipPath = sys_get_temp_dir() . '/ak-' . 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'] === 'authentik', 'Plugin lässt sich installieren: ' . json_encode($r));
|
||||
$act = plugin_set_active($pdo, 'authentik', true, ['ui.menu', 'auth.login', 'auth.customer']);
|
||||
$check($act['ok'], 'Plugin lässt sich mit den Berechtigungen aktivieren: ' . json_encode($act));
|
||||
$state = &plugin_state();
|
||||
$state['booted'] = false;
|
||||
plugins_boot();
|
||||
$check(isset(plugin_state()['loaded']['authentik']), 'Plugin wird geladen');
|
||||
$check(isset(plugin_state()['logins']['authentik']['start'], plugin_state()['logins']['authentik']['callback']), 'Start und Rückkehr sind als Anmelde-Adressen registriert');
|
||||
$menus = json_encode(plugin_state()['menus'] ?? []);
|
||||
$check(str_contains($menus, 'authentik') && str_contains($menus, 'einstellungen'), 'Menüpunkt für Administratoren ist registriert');
|
||||
|
||||
// Mitarbeiter
|
||||
$ins = $pdo->prepare("INSERT INTO staff (name, email, password_hash, role, is_active, created_at) VALUES (?,?,?,?,?,?)");
|
||||
$ins->execute(['Anna', 'Anna@Firma.Example', 'x', 'agent', 1, db_now()]);
|
||||
$anna = (int) $pdo->lastInsertId();
|
||||
$ins->execute(['Karl', 'karl@firma.example', 'x', 'agent', 0, db_now()]);
|
||||
|
||||
// Ohne Einrichtung: kein Knopf, Anmeldung nicht möglich
|
||||
$check(!authentik_ready() && plugin_filter('staff.login.buttons', '', []) === '', 'Ohne Einrichtung gibt es keinen Knopf');
|
||||
$check(isset(authentik_login_start([])['error']), 'Ohne Einrichtung lässt sich die Anmeldung nicht starten');
|
||||
|
||||
// Einrichten
|
||||
$issuer = 'https://auth.firma.example/application/o/deskhub';
|
||||
plugin_set_setting('authentik', 'issuer', $issuer . '/');
|
||||
plugin_set_setting('authentik', 'client_id', 'deskhub-client');
|
||||
plugin_set_setting('authentik', 'client_secret_enc', encrypt_secret('streng-geheim'));
|
||||
plugin_set_setting('authentik', 'enabled', '1');
|
||||
$cfg = authentik_config();
|
||||
$check($cfg['issuer'] === $issuer && $cfg['client_secret'] === 'streng-geheim' && authentik_ready(), 'Einstellungen werden gelesen (Geheimnis entschlüsselt, Schrägstrich am Ende entfernt)');
|
||||
$check(!str_contains((string) get_setting('plugin.authentik.client_secret_enc', ''), 'streng-geheim'), 'Das Geheimnis liegt nicht im Klartext in der Datenbank');
|
||||
$btn = plugin_filter('staff.login.buttons', '', []);
|
||||
$check(str_contains($btn, 'Mit authentik anmelden') && str_contains($btn, '/staff/plugin_login.php?p=authentik&e=start'), 'Eingerichtet erscheint der Knopf mit dem richtigen Ziel');
|
||||
plugin_set_setting('authentik', 'label', 'SSO <b>Login</b>');
|
||||
$check(str_contains(plugin_filter('staff.login.buttons', '', []), 'SSO <b>Login</b>'), 'Die Beschriftung wird maskiert');
|
||||
plugin_set_setting('authentik', 'label', '');
|
||||
|
||||
// ── authentik als Funktion nachgebaut ──
|
||||
$idp = ['claims' => [], 'status' => 200, 'tokenStatus' => 200, 'userinfo' => null, 'challenge' => '', 'nonce' => '', 'calls' => []];
|
||||
$jwt = fn(array $c) => 'eyJhbGciOiJub25lIn0.' . authentik_b64url(json_encode($c)) . '.sig';
|
||||
$GLOBALS['authentik_http'] = function (string $method, string $url, array $opts) use (&$idp, $issuer, $jwt): array {
|
||||
$idp['calls'][] = "$method $url";
|
||||
if ($url === $issuer . '/.well-known/openid-configuration') {
|
||||
return [$idp['status'], json_encode(['issuer' => $idp['issuerOverride'] ?? $issuer . '/', 'authorization_endpoint' => 'https://auth.firma.example/application/o/authorize/', 'token_endpoint' => 'https://auth.firma.example/application/o/token/', 'userinfo_endpoint' => 'https://auth.firma.example/application/o/userinfo/']), null];
|
||||
}
|
||||
if ($url === 'https://auth.firma.example/application/o/token/') {
|
||||
$f = $opts['form'] ?? [];
|
||||
// PKCE: der Prüfwert muss zur Herausforderung aus dem Start passen
|
||||
$pkceOk = authentik_b64url(hash('sha256', (string) ($f['code_verifier'] ?? ''), true)) === $idp['challenge'];
|
||||
if ($f['client_secret'] !== 'streng-geheim' || $f['client_id'] !== 'deskhub-client' || $f['grant_type'] !== 'authorization_code' || $f['code'] !== 'gueltiger-code' || !$pkceOk || !str_contains($f['redirect_uri'], '/staff/plugin_login.php?p=authentik&e=callback')) return [400, json_encode(['error' => 'invalid_grant']), null];
|
||||
$claims = $idp['claims'] + ['iss' => $issuer . '/', 'aud' => 'deskhub-client', 'exp' => time() + 300, 'nonce' => $idp['nonce'], 'sub' => 'u1', 'email' => 'anna@firma.example', 'email_verified' => true];
|
||||
return [$idp['tokenStatus'], json_encode(['id_token' => $jwt(array_filter($claims, fn($v) => $v !== '__weg__')), 'access_token' => 'at-1']), null];
|
||||
}
|
||||
if ($url === 'https://auth.firma.example/application/o/userinfo/') {
|
||||
return [200, json_encode($idp['userinfo'] ?? []), null];
|
||||
}
|
||||
return [404, '', null];
|
||||
};
|
||||
$begin = function () use (&$idp): array {
|
||||
$_SESSION = [];
|
||||
$res = authentik_login_start([]);
|
||||
if (empty($res['redirect'])) return [null, [], $res];
|
||||
parse_str((string) parse_url($res['redirect'], PHP_URL_QUERY), $q);
|
||||
$idp['challenge'] = (string) ($q['code_challenge'] ?? '');
|
||||
$idp['nonce'] = (string) ($q['nonce'] ?? '');
|
||||
return [$res['redirect'], $q, $res];
|
||||
};
|
||||
$back = fn(array $query) => authentik_login_callback(['query' => $query]);
|
||||
|
||||
// Start
|
||||
[$redirect, $q] = $begin();
|
||||
$check($redirect !== null && str_starts_with($redirect, 'https://auth.firma.example/application/o/authorize/?'), 'Start leitet zu authentik');
|
||||
$check(($q['response_type'] ?? '') === 'code' && ($q['client_id'] ?? '') === 'deskhub-client' && ($q['scope'] ?? '') === 'openid email profile' && ($q['code_challenge_method'] ?? '') === 'S256', 'Anfrage enthält Code-Ablauf, Client-ID, Bereiche und PKCE');
|
||||
$check(strlen((string) ($q['state'] ?? '')) === 32 && strlen((string) ($q['nonce'] ?? '')) === 32 && strlen((string) ($q['code_challenge'] ?? '')) >= 43, 'Zustand, Nonce und Prüfwert sind zufällig und lang genug');
|
||||
$check(($q['redirect_uri'] ?? '') === authentik_redirect_uri() && str_ends_with(authentik_redirect_uri(), '/staff/plugin_login.php?p=authentik&e=callback'), 'Die Rückkehr-Adresse stimmt');
|
||||
[, $q2] = $begin();
|
||||
$check($q2['state'] !== $q['state'] && $q2['nonce'] !== $q['nonce'], 'Jeder Start erzeugt neue Werte');
|
||||
|
||||
// Erfolgreiche Anmeldung
|
||||
[, $q] = $begin();
|
||||
$ok = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
|
||||
$check(($ok['staff_id'] ?? 0) === $anna && ($ok['via'] ?? '') === 'authentik', 'Erfolgreiche Rückkehr meldet den Mitarbeiter an (E-Mail ohne Beachtung der Groß-/Kleinschreibung): ' . json_encode($ok));
|
||||
$check(!isset($_SESSION['authentik']), 'Die Sitzungswerte sind danach gelöscht');
|
||||
$again = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
|
||||
$check(isset($again['error']) && !isset($again['staff_id']), 'Dieselbe Rückkehr funktioniert nicht ein zweites Mal');
|
||||
|
||||
// Fehlerfälle
|
||||
$fail = function (string $what, array $claims, array $queryOverride = [], ?callable $tweak = null) use (&$idp, $begin, $back, $check) {
|
||||
$idp['claims'] = $claims; $idp['userinfo'] = null;
|
||||
if ($tweak) $tweak();
|
||||
[, $q] = $begin();
|
||||
$res = $back($queryOverride + ['code' => 'gueltiger-code', 'state' => $q['state']]);
|
||||
$check(isset($res['error']) && !isset($res['staff_id']), $what . ': ' . json_encode($res, JSON_UNESCAPED_UNICODE));
|
||||
$idp['claims'] = []; $idp['issuerOverride'] = null; $idp['tokenStatus'] = 200;
|
||||
return $res;
|
||||
};
|
||||
$fail('falscher Zustand (state) wird abgelehnt', [], ['state' => 'falsch']);
|
||||
$fail('fehlender Code wird abgelehnt', [], ['code' => '']);
|
||||
$fail('ungültiger Code wird abgelehnt', [], ['code' => 'geklaut']);
|
||||
$fail('falsche Nonce wird abgelehnt', ['nonce' => 'fremde-nonce']);
|
||||
$fail('falscher Aussteller wird abgelehnt', ['iss' => 'https://boese.example/o/x/']);
|
||||
$fail('falsche Zielgruppe (aud) wird abgelehnt', ['aud' => 'andere-app']);
|
||||
$fail('Zielgruppe als Liste ohne uns wird abgelehnt', ['aud' => ['a', 'b']]);
|
||||
$fail('falscher Berechtigter (azp) wird abgelehnt', ['aud' => ['deskhub-client', 'x'], 'azp' => 'x']);
|
||||
$fail('abgelaufenes Token wird abgelehnt', ['exp' => time() - 600]);
|
||||
$fail('E-Mail nicht bestätigt wird bei fremder Kennung abgelehnt', ['email_verified' => false, 'sub' => 'u-unv']);
|
||||
$fail('fehlende E-Mail wird abgelehnt', ['email' => '__weg__']);
|
||||
$fail('ungültige E-Mail wird abgelehnt', ['email' => 'kein-mail']);
|
||||
$fail('Fehler von authentik (error=access_denied) wird abgelehnt', [], ['error' => 'access_denied<script>']);
|
||||
$fail('Token-Endpunkt mit Fehler wird abgelehnt', [], [], function () use (&$idp) { $idp['tokenStatus'] = 500; });
|
||||
$fail('unbekannte Adresse wird abgelehnt', ['email' => 'niemand@firma.example', 'sub' => 'u-niemand']);
|
||||
$res = $fail('deaktivierter Mitarbeiter wird abgelehnt', ['email' => 'karl@firma.example', 'sub' => 'u-karl']);
|
||||
$check(str_contains((string) ($res['error'] ?? ''), 'kein aktives Mitarbeiterkonto'), 'Fehlermeldung nennt das fehlende Konto');
|
||||
$fail('Aussteller in der Konfiguration passt nicht', [], [], function () use (&$idp) { $idp['issuerOverride'] = 'https://anderer.example/o/'; plugin_set_setting('authentik', 'discovery', ''); });
|
||||
plugin_set_setting('authentik', 'discovery', '');
|
||||
$log = (string) $pdo->query("SELECT GROUP_CONCAT(action, ' | ') FROM admin_audit_log WHERE action LIKE 'authentik:%'")->fetchColumn();
|
||||
$check(str_contains($log, 'kein Mitarbeiterkonto für niemand@firma.example'), 'Eine Anmeldung ohne Konto steht im Admin-Protokoll: ' . $log);
|
||||
|
||||
// Ablauf nach 10 Minuten
|
||||
[, $q] = $begin();
|
||||
$_SESSION['authentik']['at'] = time() - 700;
|
||||
$check(isset($back(['code' => 'gueltiger-code', 'state' => $q['state']])['error']), 'Ein zu langsamer Ablauf (über 10 Minuten) wird abgelehnt');
|
||||
// Ohne gestarteten Ablauf
|
||||
$_SESSION = [];
|
||||
$check(isset($back(['code' => 'gueltiger-code', 'state' => 'x'])['error']), 'Rückkehr ohne gestarteten Ablauf wird abgelehnt');
|
||||
|
||||
// Gruppenprüfung
|
||||
plugin_set_setting('authentik', 'group', 'deskhub-support');
|
||||
$idp['claims'] = ['groups' => ['andere', 'deskhub-support']];
|
||||
[, $q] = $begin();
|
||||
$check(($back(['code' => 'gueltiger-code', 'state' => $q['state']])['staff_id'] ?? 0) === $anna, 'Mitglied der geforderten Gruppe wird angemeldet');
|
||||
$idp['claims'] = ['groups' => ['andere']];
|
||||
[, $q] = $begin();
|
||||
$res = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
|
||||
$check(isset($res['error']) && str_contains($res['error'], 'deskhub-support'), 'Wer nicht in der Gruppe ist, wird abgelehnt: ' . json_encode($res, JSON_UNESCAPED_UNICODE));
|
||||
$idp['claims'] = [];
|
||||
$idp['userinfo'] = ['sub' => 'u1', 'groups' => ['deskhub-support']];
|
||||
[, $q] = $begin();
|
||||
$check(($back(['code' => 'gueltiger-code', 'state' => $q['state']])['staff_id'] ?? 0) === $anna, 'Gruppen werden bei Bedarf über „userinfo“ geholt');
|
||||
$idp['userinfo'] = ['sub' => 'ANDERER', 'groups' => ['deskhub-support']];
|
||||
[, $q] = $begin();
|
||||
$check(isset($back(['code' => 'gueltiger-code', 'state' => $q['state']])['error']), '„userinfo“ eines anderen Benutzers (sub) zählt nicht');
|
||||
$idp['claims'] = ['groups' => [['name' => 'deskhub-support']]];
|
||||
$idp['userinfo'] = null;
|
||||
[, $q] = $begin();
|
||||
$check(($back(['code' => 'gueltiger-code', 'state' => $q['state']])['staff_id'] ?? 0) === $anna, 'Gruppen als Liste von Objekten mit Namen werden verstanden');
|
||||
plugin_set_setting('authentik', 'group', '');
|
||||
|
||||
// E-Mail nur über userinfo
|
||||
$idp['claims'] = ['email' => '__weg__'];
|
||||
$idp['userinfo'] = ['sub' => 'u1', 'email' => 'anna@firma.example'];
|
||||
[, $q] = $begin();
|
||||
$check(($back(['code' => 'gueltiger-code', 'state' => $q['state']])['staff_id'] ?? 0) === $anna, 'Fehlt die E-Mail im Token, wird sie über „userinfo“ geholt');
|
||||
$idp['claims'] = [];
|
||||
|
||||
// ── Verknüpfung ──
|
||||
$ins->execute(['Berta', 'berta@firma.example', 'x', 'agent', 1, db_now()]);
|
||||
$berta = (int) $pdo->lastInsertId();
|
||||
$check(authentik_link_staff_for('u1') === $anna && ((authentik_link_of($anna)['email'] ?? '') === 'anna@firma.example'), 'Die erste Anmeldung über die E-Mail-Adresse hat Anna automatisch verknüpft');
|
||||
$log = (string) $pdo->query("SELECT GROUP_CONCAT(action, ' | ') FROM admin_audit_log WHERE action LIKE 'authentik: Konto automatisch verknüpft%'")->fetchColumn();
|
||||
$check(str_contains($log, 'anna@firma.example'), 'Die automatische Verknüpfung steht im Admin-Protokoll');
|
||||
// Anmeldung über die Verknüpfung, auch wenn die E-Mail-Adresse in authentik eine andere ist
|
||||
$idp['claims'] = ['email' => 'ganz.anders@privat.example'];
|
||||
[, $q] = $begin();
|
||||
$check(($back(['code' => 'gueltiger-code', 'state' => $q['state']])['staff_id'] ?? 0) === $anna, 'Anmeldung über die Verknüpfung klappt auch mit geänderter E-Mail-Adresse in authentik');
|
||||
// Fremdes authentik-Konto mit der E-Mail-Adresse einer verknüpften Person kommt nicht hinein
|
||||
$idp['claims'] = ['sub' => 'u-fremd', 'email' => 'anna@firma.example'];
|
||||
[, $q] = $begin();
|
||||
$res = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
|
||||
$check(isset($res['error']) && !isset($res['staff_id']) && str_contains($res['error'], 'anderen authentik-Konto verknüpft'), 'Ein fremdes authentik-Konto mit gleicher E-Mail-Adresse wird abgewiesen: ' . json_encode($res, JSON_UNESCAPED_UNICODE));
|
||||
// „Nur verknüpfte Konten“
|
||||
plugin_set_setting('authentik', 'only_linked', '1');
|
||||
$idp['claims'] = ['sub' => 'u-berta', 'email' => 'berta@firma.example'];
|
||||
[, $q] = $begin();
|
||||
$res = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
|
||||
$check(isset($res['error']) && str_contains($res['error'], 'noch keinem DeskHub-Konto'), 'Mit „Nur verknüpfte Konten“ wird ein nicht verknüpftes Konto abgewiesen, auch bei passender E-Mail-Adresse');
|
||||
$check(authentik_link_of($berta) === null, 'und dabei nicht verknüpft');
|
||||
// Berta verknüpft selbst (angemeldet)
|
||||
$bertaRow = ['id' => $berta, 'role' => 'agent', 'email' => 'berta@firma.example', 'name' => 'Berta'];
|
||||
$_SESSION = [];
|
||||
$st = authentik_link_start(['staff' => $bertaRow]);
|
||||
$check(!empty($st['redirect']) && ($_SESSION['authentik']['mode'] ?? '') === 'link' && ($_SESSION['authentik']['staff_id'] ?? 0) === $berta, 'Verknüpfen startet für den angemeldeten Mitarbeiter');
|
||||
parse_str((string) parse_url($st['redirect'], PHP_URL_QUERY), $q);
|
||||
$idp['challenge'] = $q['code_challenge'];
|
||||
$idp['nonce'] = $q['nonce'];
|
||||
$check(isset(authentik_link_start([])['error']), 'Ohne angemeldeten Mitarbeiter lässt sich nicht verknüpfen');
|
||||
$res = authentik_login_callback(['staff' => $bertaRow, 'query' => ['code' => 'gueltiger-code', 'state' => $q['state']]]);
|
||||
$check(($res['local'] ?? '') === '/staff/plugin.php?p=authentik&page=konto' && str_contains((string) ($res['notice'] ?? ''), 'berta@firma.example'), 'Die Verknüpfung klappt und führt zurück zur Konto-Seite: ' . json_encode($res, JSON_UNESCAPED_UNICODE));
|
||||
$check(authentik_link_staff_for('u-berta') === $berta && ((authentik_link_of($berta)['email'] ?? '') === 'berta@firma.example'), 'Die Verknüpfung ist gespeichert');
|
||||
$idp['claims'] = ['sub' => 'u-berta', 'email' => 'berta@firma.example'];
|
||||
[, $q] = $begin();
|
||||
$check(($back(['code' => 'gueltiger-code', 'state' => $q['state']])['staff_id'] ?? 0) === $berta, 'Danach klappt die Anmeldung mit „Nur verknüpfte Konten“');
|
||||
// Verknüpfen: Fehlerfälle
|
||||
$linkTry = function (array $staffRow, array $claims, ?callable $tamper = null) use (&$idp) {
|
||||
$_SESSION = [];
|
||||
$st = authentik_link_start(['staff' => $staffRow]);
|
||||
parse_str((string) parse_url($st['redirect'], PHP_URL_QUERY), $q);
|
||||
$idp['challenge'] = $q['code_challenge'];
|
||||
$idp['nonce'] = $q['nonce'];
|
||||
$idp['claims'] = $claims;
|
||||
if ($tamper) $tamper();
|
||||
return authentik_login_callback(['staff' => $staffRow, 'query' => ['code' => 'gueltiger-code', 'state' => $q['state']]]);
|
||||
};
|
||||
$annaRow = ['id' => $anna, 'role' => 'agent', 'email' => 'anna@firma.example', 'name' => 'Anna'];
|
||||
$res = $linkTry($annaRow, ['sub' => 'u-berta', 'email' => 'berta@firma.example']);
|
||||
$check(isset($res['error']) && str_contains($res['error'], 'bereits mit einem anderen') && ($res['back'] ?? '') === '/staff/plugin.php?p=authentik&page=konto', 'Ein authentik-Konto, das schon einem anderen Mitarbeiter gehört, lässt sich nicht noch einmal verknüpfen: ' . json_encode($res, JSON_UNESCAPED_UNICODE));
|
||||
$check(authentik_link_staff_for('u-berta') === $berta, 'und die alte Verknüpfung bleibt');
|
||||
$res = $linkTry($annaRow, [], function () { $_SESSION['authentik']['staff_id'] = 999; });
|
||||
$check(isset($res['error']) && !isset($res['local']), 'Eine für einen anderen Mitarbeiter gestartete Verknüpfung wird abgelehnt');
|
||||
$_SESSION = [];
|
||||
authentik_begin('login');
|
||||
$q0 = $_SESSION['authentik'];
|
||||
$res = authentik_login_callback(['staff' => $annaRow, 'query' => ['code' => 'gueltiger-code', 'state' => $q0['state']]]);
|
||||
$check(isset($res['error']) && !isset($res['local']), 'Ein als Anmeldung gestarteter Ablauf lässt sich nicht als Verknüpfung beenden');
|
||||
$_SESSION = [];
|
||||
authentik_begin('link', $anna);
|
||||
$q0 = $_SESSION['authentik'];
|
||||
$res = authentik_login_callback(['query' => ['code' => 'gueltiger-code', 'state' => $q0['state']]]);
|
||||
$check(isset($res['error']) && !isset($res['staff_id']), 'Ein als Verknüpfung gestarteter Ablauf lässt sich nicht als Anmeldung beenden');
|
||||
// Neu verknüpfen ersetzt die alte Verknüpfung
|
||||
$res = $linkTry($annaRow, ['sub' => 'u-anna-neu', 'email' => 'anna.neu@privat.example']);
|
||||
$check(isset($res['local']) && authentik_link_staff_for('u-anna-neu') === $anna && authentik_link_staff_for('u1') === null, 'Neu verknüpfen ersetzt die alte Verknüpfung (die alte Kennung gilt nicht mehr)');
|
||||
// Lösen und Übersicht
|
||||
$all = authentik_links_all();
|
||||
$check(count($all) === 2, 'Die Übersicht listet beide Verknüpfungen: ' . json_encode($all, JSON_UNESCAPED_UNICODE));
|
||||
authentik_link_remove($berta);
|
||||
$check(authentik_link_of($berta) === null && authentik_link_staff_for('u-berta') === null, 'Lösen entfernt beide Einträge');
|
||||
$all = authentik_links_all();
|
||||
$check(count($all) === 1 && $all[0]['staff_id'] === $anna && $all[0]['name'] === 'Anna', 'Danach bleibt nur Anna in der Übersicht: ' . json_encode($all, JSON_UNESCAPED_UNICODE));
|
||||
plugin_set_setting('authentik', 'only_linked', '0');
|
||||
|
||||
// Adressprüfung
|
||||
$check(authentik_url_ok('https://a.example/o/x') && authentik_url_ok('http://localhost:9000/o/x') && !authentik_url_ok('http://a.example/o') && !authentik_url_ok('ftp://a.example') && !authentik_url_ok('https://u:p@a.example/') && !authentik_url_ok('https://a.example/o?x=1') && !authentik_url_ok('https://a.example/o#x') && !authentik_url_ok('https://a.example/ o'), 'Adressprüfung: nur https (http nur localhost), ohne Zugangsdaten, Parameter und Anker');
|
||||
|
||||
// Konfiguration abrufen und merken
|
||||
$idp['calls'] = [];
|
||||
plugin_set_setting('authentik', 'discovery', '');
|
||||
authentik_discovery($issuer);
|
||||
authentik_discovery($issuer);
|
||||
$check(count(array_filter($idp['calls'], fn($c) => str_contains($c, 'openid-configuration'))) === 1, 'Die Konfiguration von authentik wird eine Stunde lang gemerkt');
|
||||
$idp['status'] = 404;
|
||||
$d = authentik_discovery($issuer, true);
|
||||
$check(!$d['ok'] && str_contains($d['error'], 'HTTP 404'), 'Eine falsche Issuer-Adresse gibt eine verständliche Meldung');
|
||||
$idp['status'] = 200;
|
||||
|
||||
// Einstellungsseite
|
||||
$staff = ['id' => 1, 'role' => 'admin', 'name' => 'Admin'];
|
||||
$_SERVER['REQUEST_METHOD'] = 'GET';
|
||||
ob_start();
|
||||
include $pluginDir . '/authentik/pages/einstellungen.php';
|
||||
$page = (string) ob_get_clean();
|
||||
$check(str_contains($page, 'authentik-Anmeldung') && str_contains($page, e(authentik_redirect_uri())) && str_contains($page, 'placeholder="(unverändert)"') && !str_contains($page, 'streng-geheim'), 'Einstellungsseite zeigt die Rückkehr-Adresse, nennt das Geheimnis nicht im Klartext');
|
||||
$staff['role'] = 'agent';
|
||||
ob_start();
|
||||
include $pluginDir . '/authentik/pages/einstellungen.php';
|
||||
$denied = (string) ob_get_clean();
|
||||
$check(str_contains($denied, 'Keine Berechtigung') && !str_contains($denied, 'Client-ID'), 'Die Einstellungsseite ist nur für Administratoren');
|
||||
|
||||
// ── Kunden (Ticketersteller) ──
|
||||
$check(isset(plugin_state()['customer_logins']['authentik']['start']), 'Die Kunden-Anmelde-Adresse ist registriert');
|
||||
$check(!authentik_customer_ready() && plugin_filter('customer.login.buttons', '', []) === '', 'Ausgeschaltet gibt es keinen Kunden-Knopf');
|
||||
$r0 = authentik_customer_start([]);
|
||||
$check(isset($r0['error']) && !isset($r0['redirect']), 'Ausgeschaltet lässt sich die Kunden-Anmeldung nicht starten');
|
||||
plugin_set_setting('authentik', 'customers', '1');
|
||||
$check(authentik_customer_ready() && str_contains(plugin_filter('customer.login.buttons', '', []), 'plugin_login.php?p=authentik&e=start'), 'Eingeschaltet erscheint der Kunden-Knopf mit dem Kunden-Einstieg');
|
||||
$check(!str_contains(plugin_filter('staff.login.buttons', '', []), 'e=start&') && str_contains(plugin_filter('staff.login.buttons', '', []), '/staff/plugin_login.php'), 'Der Mitarbeiter-Knopf bleibt beim Mitarbeiter-Einstieg');
|
||||
$custBegin = function () use (&$idp) {
|
||||
$_SESSION = [];
|
||||
$res = authentik_customer_start([]);
|
||||
parse_str((string) parse_url($res['redirect'] ?? '', PHP_URL_QUERY), $q);
|
||||
$idp['challenge'] = (string) ($q['code_challenge'] ?? '');
|
||||
$idp['nonce'] = (string) ($q['nonce'] ?? '');
|
||||
return [$res, $q];
|
||||
};
|
||||
[$res, $q] = $custBegin();
|
||||
$check(!empty($res['redirect']) && ($_SESSION['authentik']['mode'] ?? '') === 'customer' && empty($_SESSION['authentik']['staff_id']), 'Kunden-Start leitet zu authentik und merkt die Betriebsart „customer“');
|
||||
$idp['claims'] = ['sub' => 'k1', 'email' => 'Kunde.Neu@Firma.Example', 'name' => 'Karla Kunde'];
|
||||
$res = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
|
||||
$check(($res['customer']['email'] ?? '') === 'kunde.neu@firma.example' && ($res['customer']['name'] ?? '') === 'Karla Kunde' && ($res['customer']['create'] ?? null) === true && ($res['customer']['via'] ?? '') === 'authentik' && !isset($res['staff_id']), 'Kunde: bestätigte E-Mail-Adresse und Name gehen an DeskHub (Konto darf angelegt werden): ' . json_encode($res, JSON_UNESCAPED_UNICODE));
|
||||
$check(authentik_link_staff_for('k1') === null, 'Kunden werden nicht mit Mitarbeitern verknüpft');
|
||||
// Name-Rückfälle
|
||||
foreach ([[['name' => '', 'given_name' => 'Gerd', 'family_name' => 'Gast'], 'Gerd Gast'], [['name' => '', 'preferred_username' => 'gerdg'], 'gerdg'], [['name' => ''], '']] as [$cl, $expect]) {
|
||||
[$res, $q] = $custBegin();
|
||||
$idp['claims'] = ['sub' => 'k2', 'email' => 'gerd@firma.example'] + $cl;
|
||||
$r = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
|
||||
$check(($r['customer']['name'] ?? 'x') === $expect, 'Kunden-Name aus den Aussagen: „' . $expect . '“ (war: ' . json_encode($r['customer']['name'] ?? null) . ')');
|
||||
}
|
||||
// Ohne automatisches Anlegen
|
||||
plugin_set_setting('authentik', 'customer_create', '0');
|
||||
[$res, $q] = $custBegin();
|
||||
$idp['claims'] = ['sub' => 'k3', 'email' => 'x@firma.example'];
|
||||
$r = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
|
||||
$check(($r['customer']['create'] ?? null) === false, 'Ohne „automatisch anlegen“ steht create=false');
|
||||
plugin_set_setting('authentik', 'customer_create', '1');
|
||||
// Fehler gehen auf die Kunden-Seite
|
||||
foreach ([['Nonce falsch', ['nonce' => 'fremd']], ['E-Mail nicht bestätigt', ['email_verified' => false]], ['abgelaufen', ['exp' => time() - 900]]] as [$what, $cl]) {
|
||||
[$res, $q] = $custBegin();
|
||||
$idp['claims'] = ['sub' => 'k4', 'email' => 'k@firma.example'] + $cl;
|
||||
$r = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
|
||||
$check(isset($r['error']) && ($r['audience'] ?? '') === 'customer' && !isset($r['customer']), 'Kunde: ' . $what . ' wird abgelehnt und als Kunden-Fehler gemeldet');
|
||||
}
|
||||
[$res, $q] = $custBegin();
|
||||
$r = $back(['error' => 'access_denied', 'state' => $q['state']]);
|
||||
$check(isset($r['error']) && ($r['audience'] ?? '') === 'customer', 'Kunde: Ablehnung durch authentik wird als Kunden-Fehler gemeldet');
|
||||
// Eigene Gruppe für Kunden
|
||||
plugin_set_setting('authentik', 'customer_group', 'deskhub-kunden');
|
||||
plugin_set_setting('authentik', 'group', 'nur-mitarbeiter');
|
||||
[$res, $q] = $custBegin();
|
||||
$idp['claims'] = ['sub' => 'k5', 'email' => 'k5@firma.example', 'groups' => ['deskhub-kunden']];
|
||||
$r = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
|
||||
$check(isset($r['customer']), 'Kunde in der Kunden-Gruppe kommt hinein (die Mitarbeiter-Gruppe gilt nicht für Kunden)');
|
||||
[$res, $q] = $custBegin();
|
||||
$idp['claims'] = ['sub' => 'k5', 'email' => 'k5@firma.example', 'groups' => ['nur-mitarbeiter']];
|
||||
$r = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
|
||||
$check(isset($r['error']) && !isset($r['customer']) && str_contains($r['error'], 'deskhub-kunden'), 'Kunde nur in der Mitarbeiter-Gruppe wird abgewiesen');
|
||||
plugin_set_setting('authentik', 'customer_group', '');
|
||||
plugin_set_setting('authentik', 'group', '');
|
||||
// Betriebsart kommt aus der Sitzung: ein Mitarbeiter-Ablauf wird nie zur Kunden-Anmeldung und umgekehrt
|
||||
[, $q] = $begin();
|
||||
$idp['claims'] = ['sub' => 'u-anna-neu', 'email' => 'anna@firma.example'];
|
||||
$r = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
|
||||
$check(isset($r['staff_id']) && !isset($r['customer']), 'Ein Mitarbeiter-Ablauf liefert nur eine Mitarbeiter-Anmeldung');
|
||||
// Kunden ausgeschaltet, während der Ablauf läuft
|
||||
[$res, $q] = $custBegin();
|
||||
plugin_set_setting('authentik', 'customers', '0');
|
||||
$idp['claims'] = ['sub' => 'k6', 'email' => 'k6@firma.example'];
|
||||
$r = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
|
||||
$check(isset($r['error']) && !isset($r['customer']) && ($r['audience'] ?? '') === 'customer', 'Wird die Kunden-Anmeldung während des Ablaufs ausgeschaltet, kommt niemand mehr hinein');
|
||||
plugin_set_setting('authentik', 'customers', '0');
|
||||
$idp['claims'] = [];
|
||||
// ── E-Mail nicht bestätigt (authentik meldet email_verified = false) ──
|
||||
$check(authentik_config()['require_verified'] === true, 'Die Prüfung der bestätigten E-Mail-Adresse ist standardmäßig an');
|
||||
$ins->execute(['Carla', 'carla@firma.example', 'x', 'agent', 1, db_now()]);
|
||||
$carla = (int) $pdo->lastInsertId();
|
||||
$carlaRow = ['id' => $carla, 'role' => 'agent', 'email' => 'carla@firma.example', 'name' => 'Carla'];
|
||||
// 1) Unverknüpft, nur über die E-Mail-Adresse: abgelehnt, mit Hinweis auf „Mein authentik-Konto“
|
||||
$idp['claims'] = ['sub' => 'u-carla', 'email' => 'carla@firma.example', 'email_verified' => false];
|
||||
[, $q] = $begin();
|
||||
$res = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
|
||||
$check(isset($res['error']) && !isset($res['staff_id']) && str_contains($res['error'], 'Mein authentik-Konto') && authentik_link_of($carla) === null, 'Unbestätigte Adresse: Anmeldung über die E-Mail-Adresse wird abgelehnt (mit Hinweis auf „Mein authentik-Konto“) und nichts verknüpft: ' . json_encode($res, JSON_UNESCAPED_UNICODE));
|
||||
// 2) Verknüpfen funktioniert trotzdem (die Adresse entscheidet dort nicht)
|
||||
$res = $linkTry($carlaRow, ['sub' => 'u-carla', 'email' => 'carla@firma.example', 'email_verified' => false]);
|
||||
$check(isset($res['local']) && authentik_link_staff_for('u-carla') === $carla, 'Verknüpfen klappt auch, wenn authentik die Adresse als nicht bestätigt meldet: ' . json_encode($res, JSON_UNESCAPED_UNICODE));
|
||||
// 3) Danach klappt die Anmeldung über die Verknüpfung
|
||||
$idp['claims'] = ['sub' => 'u-carla', 'email' => 'carla@firma.example', 'email_verified' => false];
|
||||
[, $q] = $begin();
|
||||
$check(($back(['code' => 'gueltiger-code', 'state' => $q['state']])['staff_id'] ?? 0) === $carla, 'Wer verknüpft ist, meldet sich auch mit unbestätigter Adresse an');
|
||||
// 4) Prüfung ausgeschaltet: Zuordnung über die Adresse klappt (und verknüpft)
|
||||
$ins->execute(['Dora', 'dora@firma.example', 'x', 'agent', 1, db_now()]);
|
||||
$dora = (int) $pdo->lastInsertId();
|
||||
plugin_set_setting('authentik', 'require_verified', '0');
|
||||
$idp['claims'] = ['sub' => 'u-dora', 'email' => 'dora@firma.example', 'email_verified' => false];
|
||||
[, $q] = $begin();
|
||||
$check(($back(['code' => 'gueltiger-code', 'state' => $q['state']])['staff_id'] ?? 0) === $dora && authentik_link_staff_for('u-dora') === $dora, 'Mit ausgeschalteter Prüfung klappt die Zuordnung über die Adresse');
|
||||
// 5) Kunden: unbestätigt wird abgelehnt (an), sonst angenommen (aus)
|
||||
plugin_set_setting('authentik', 'customers', '1');
|
||||
plugin_set_setting('authentik', 'require_verified', '1');
|
||||
$idp['claims'] = ['sub' => 'k-unv', 'email' => 'unv@firma.example', 'email_verified' => false];
|
||||
[$r0, $q] = $custBegin();
|
||||
$res = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
|
||||
$check(isset($res['error']) && !isset($res['customer']) && ($res['audience'] ?? '') === 'customer' && str_contains($res['error'], 'nicht bestätigt'), 'Kunde mit unbestätigter Adresse wird abgelehnt (Kunden-Fehler)');
|
||||
plugin_set_setting('authentik', 'require_verified', '0');
|
||||
[$r0, $q] = $custBegin();
|
||||
$idp['claims'] = ['sub' => 'k-unv', 'email' => 'unv@firma.example', 'email_verified' => false];
|
||||
$res = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
|
||||
$check(($res['customer']['email'] ?? '') === 'unv@firma.example', 'Mit ausgeschalteter Prüfung wird der Kunde angenommen');
|
||||
plugin_set_setting('authentik', 'require_verified', '1');
|
||||
plugin_set_setting('authentik', 'customers', '0');
|
||||
$idp['claims'] = [];
|
||||
// Konto-Seite für Mitarbeiter
|
||||
$render = function (array $staffRow, string $page, 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 . '/authentik/pages/' . $page . '.php';
|
||||
return (string) ob_get_clean();
|
||||
};
|
||||
$annaStaff = ['id' => $anna, 'role' => 'agent', 'email' => 'anna@firma.example', 'name' => 'Anna'];
|
||||
$page = $render($annaStaff, 'konto');
|
||||
$check(str_contains($page, 'Mein authentik-Konto') && str_contains($page, 'Verknüpft.') && str_contains($page, 'anna.neu@privat.example') && str_contains($page, 'p=authentik&e=link') && str_contains($page, 'Verknüpfung lösen'), 'Konto-Seite zeigt die Verknüpfung, „neu verknüpfen“ und „lösen“');
|
||||
$bertaStaff = ['id' => $berta, 'role' => 'agent', 'email' => 'berta@firma.example', 'name' => 'Berta'];
|
||||
$page = $render($bertaStaff, 'konto');
|
||||
$check(str_contains($page, 'Noch nicht verknüpft') && str_contains($page, 'Mit authentik verknüpfen') && str_contains($page, 'automatisch verknüpft'), 'Ohne Verknüpfung bietet die Konto-Seite „Mit authentik verknüpfen“ an und erklärt die E-Mail-Anmeldung');
|
||||
plugin_set_setting('authentik', 'only_linked', '1');
|
||||
$page = $render($bertaStaff, 'konto');
|
||||
$check(str_contains($page, 'Ohne Verknüpfung ist die Anmeldung mit authentik hier nicht möglich'), 'Mit „Nur verknüpfte Konten“ steht dort, dass die Verknüpfung nötig ist');
|
||||
plugin_set_setting('authentik', 'only_linked', '0');
|
||||
$page = $render($annaStaff, 'konto', ['action' => 'unlink']);
|
||||
$check(authentik_link_of($anna) === null && str_contains($page, 'Die Verknüpfung ist gelöst') && str_contains($page, 'Noch nicht verknüpft'), 'Selbst lösen funktioniert');
|
||||
$check(str_contains((string) $pdo->query("SELECT GROUP_CONCAT(action, ' | ') FROM admin_audit_log WHERE action LIKE 'authentik: Verknüpfung gelöst%'")->fetchColumn(), 'Verknüpfung gelöst'), 'Selbst lösen steht im Admin-Protokoll');
|
||||
// Verwaltung: Übersicht und Lösen für andere
|
||||
authentik_link_set($berta, 'u-berta2', 'berta@firma.example');
|
||||
$adminStaff = ['id' => 1, 'role' => 'admin', 'email' => 'admin@firma.example', 'name' => 'Admin'];
|
||||
$page = $render($adminStaff, 'einstellungen');
|
||||
$nLinks = count(authentik_links_all());
|
||||
$check(str_contains($page, 'Verknüpfte Konten (' . $nLinks . ')') && str_contains($page, 'Berta') && str_contains($page, 'name="only_linked"') && str_contains($page, 'value="unlink_staff"'), 'Einstellungsseite listet Verknüpfungen und bietet „Nur verknüpfte Konten“ an');
|
||||
$page = $render($adminStaff, 'einstellungen', ['action' => 'unlink_staff', 'staff_id' => (string) $berta]);
|
||||
$check(authentik_link_of($berta) === null && str_contains($page, 'Die Verknüpfung ist gelöst') && str_contains($page, 'Verknüpfte Konten (' . ($nLinks - 1) . ')'), 'Ein Administrator kann die Verknüpfung eines anderen lösen');
|
||||
$page = $render($adminStaff, 'einstellungen', ['action' => 'save', 'issuer' => $issuer, 'client_id' => 'deskhub-client', 'client_secret' => '', 'scopes' => 'openid email profile', 'group' => '', 'label' => '', 'enabled' => '1', 'only_linked' => '1', 'customers' => '1', 'customer_create' => '1', 'customer_group' => 'kunden']);
|
||||
$check(plugin_setting('authentik', 'customers', '0') === '1' && plugin_setting('authentik', 'customer_create', '0') === '1' && plugin_setting('authentik', 'customer_group', '') === 'kunden' && str_contains($page, 'name="customers"') && str_contains($page, 'Kunden (Ticketersteller)'), 'Einstellungsseite speichert die Kunden-Einstellungen');
|
||||
plugin_set_setting('authentik', 'customers', '0');
|
||||
plugin_set_setting('authentik', 'customer_group', '');
|
||||
$check(plugin_setting('authentik', 'only_linked', '0') === '1' && authentik_ready() && authentik_config()['client_secret'] === 'streng-geheim', 'Speichern setzt „Nur verknüpfte Konten“ und lässt das Geheimnis bei leerem Feld unverändert');
|
||||
plugin_set_setting('authentik', 'only_linked', '0');
|
||||
// Diagnose „Kunden-Anmeldung“ auf der Einstellungsseite
|
||||
plugin_set_setting('authentik', 'customers', '0');
|
||||
$page = $render($adminStaff, 'einstellungen');
|
||||
$check(str_contains($page, 'Der Knopf erscheint dort noch nicht') && str_contains($page, 'Kunden-Anmeldung ist ausgeschaltet') && str_contains($page, 'Die Berechtigung „auth.customer“ ist freigegeben'), 'Diagnose: ausgeschaltet nennt den Grund, die freigegebene Berechtigung ist erkannt');
|
||||
plugin_set_setting('authentik', 'customers', '1');
|
||||
$page = $render($adminStaff, 'einstellungen');
|
||||
$check(str_contains($page, 'Der Knopf erscheint auf der Anmeldeseite des Kundenportals') && !str_contains($page, 'Der Knopf erscheint dort noch nicht'), 'Diagnose: eingeschaltet meldet, dass der Knopf erscheint');
|
||||
plugin_set_setting('authentik', 'customers', '0');
|
||||
|
||||
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 "authentik plugin checks passed.\n";
|
||||
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft die Anmelde-Erweiterung für Plugins (Berechtigung auth.login): Eine Anmelde-Adresse wird nur mit der Berechtigung registriert,
|
||||
* /staff/plugin_login.php leitet nur auf https (oder localhost) weiter, meldet nur aktive Mitarbeiter an, schreibt ins Admin-Protokoll und
|
||||
* zeigt Fehler auf der Anmeldeseite; der Filter „staff.login.buttons“ liefert Knöpfe nur an Plugins mit der Berechtigung.
|
||||
* Ein echter Testserver (php -S) mit eigener Datenbank und eigenem Plugin-Ordner.
|
||||
*/
|
||||
|
||||
if (!function_exists('curl_init') || !extension_loaded('pdo_sqlite')) { echo "Anmelde-Test übersprungen: curl oder pdo_sqlite fehlt.\n"; exit(0); }
|
||||
|
||||
$work = sys_get_temp_dir() . '/deskhub-pl-' . bin2hex(random_bytes(4));
|
||||
$pluginDir = $work . '/plugins';
|
||||
mkdir($pluginDir, 0755, true);
|
||||
$db = $work . '/db.sqlite';
|
||||
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
|
||||
putenv('DESKHUB_TESTING=1');
|
||||
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
|
||||
putenv('DESKHUB_SQLITE_PATH=' . $db);
|
||||
require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
||||
|
||||
$failures = [];
|
||||
$check = function (bool $ok, string $what) use (&$failures): void { if (!$ok) $failures[] = $what; };
|
||||
$pdo = get_db();
|
||||
|
||||
// Zwei Plugins: eines mit Berechtigung auth.login, eines ohne (versucht trotzdem zu registrieren)
|
||||
$mkZip = function (string $slug, array $perms, string $php) use ($work): string {
|
||||
$path = $work . "/$slug.zip";
|
||||
$z = new ZipArchive();
|
||||
$z->open($path, ZipArchive::CREATE);
|
||||
$z->addFromString('plugin.json', json_encode(['slug' => $slug, 'name' => $slug, 'version' => '1.0.0', 'entry' => 'plugin.php', 'permissions' => $perms]));
|
||||
$z->addFromString('plugin.php', $php);
|
||||
$z->close();
|
||||
return $path;
|
||||
};
|
||||
$handlers = <<<'PHP'
|
||||
<?php
|
||||
plugin_register_login('ok', fn($r) => ['staff_id' => (int) plugin_setting('PLUGIN', 'target', '0'), 'via' => 'Testdienst']);
|
||||
plugin_register_login('go', fn($r) => ['redirect' => (string) ($r['query']['to'] ?? 'https://idp.example.org/auth?x=1')]);
|
||||
plugin_register_login('nope', fn($r) => ['error' => 'Konto nicht freigegeben <b>x</b>']);
|
||||
plugin_register_login('boom', function ($r) { throw new RuntimeException('kaputt'); });
|
||||
plugin_register_login('empty', fn($r) => 'kein array');
|
||||
plugin_register_login('link', fn($r) => ['local' => '/staff/plugin.php?p=PLUGIN&page=konto', 'notice' => 'Verknüpft: ' . ($r['staff']['email'] ?? '?')], true);
|
||||
plugin_register_login('linkerr', fn($r) => ['error' => 'Nicht verknüpft', 'back' => '/staff/plugin.php?p=PLUGIN&page=konto'], true);
|
||||
plugin_register_login('linkevil', fn($r) => ['local' => '//evil.example/x', 'notice' => 'x'], true);
|
||||
plugin_register_login('linkback', fn($r) => ['error' => 'x', 'back' => 'https://evil.example/'], true);
|
||||
plugin_register_login('switch', fn($r) => ['staff_id' => 1], true);
|
||||
plugin_register_login('linkgo', fn($r) => ['redirect' => 'https://idp.example.org/link'], true);
|
||||
plugin_register_customer_login('cgo', fn($r) => ['redirect' => 'https://idp.example.org/kunde']);
|
||||
plugin_register_customer_login('cnew', fn($r) => ['customer' => ['email' => (string) ($r['query']['mail'] ?? 'neu@kunde.example'), 'name' => (string) ($r['query']['name'] ?? ''), 'create' => !isset($r['query']['nocreate']), 'via' => 'Testdienst']]);
|
||||
plugin_register_customer_login('cerr', fn($r) => ['error' => 'Kunde abgelehnt <i>x</i>']);
|
||||
plugin_register_login('ccb', fn($r) => ['customer' => ['email' => 'rueck@kunde.example', 'name' => 'Rück Kunde', 'create' => true, 'via' => 'Testdienst']]);
|
||||
plugin_register_login('cerr2', fn($r) => ['audience' => 'customer', 'error' => 'Kundenfehler über die gemeinsame Rückkehr']);
|
||||
plugin_add_filter('customer.login.buttons', fn($html) => $html . '<a class="btn" href="' . plugin_customer_login_url('PLUGIN', 'cnew') . '">Kunden-Knopf</a>');
|
||||
plugin_add_filter('staff.login.buttons', fn($html) => $html . '<a class="btn" href="' . plugin_login_url('PLUGIN', 'ok') . '">Testdienst-Knopf</a>');
|
||||
PHP;
|
||||
$withPerm = $mkZip('logintest', ['auth.login', 'auth.customer'], str_replace('PLUGIN', 'logintest', $handlers));
|
||||
$noPerm = $mkZip('loginsneak', [], str_replace('PLUGIN', 'loginsneak', $handlers));
|
||||
$check(plugin_install_zip($pdo, $withPerm)['ok'] && plugin_install_zip($pdo, $noPerm)['ok'], 'Test-Plugins lassen sich installieren');
|
||||
$check(plugin_set_active($pdo, 'logintest', true, ['auth.login', 'auth.customer'])['ok'], 'Plugin mit der Berechtigung lässt sich aktivieren');
|
||||
$check(plugin_set_active($pdo, 'loginsneak', true, [])['ok'], 'Plugin ohne die Berechtigung lässt sich aktivieren');
|
||||
$check(isset(plugin_known_permissions()['auth.customer']), 'Die Berechtigung auth.customer ist bekannt und beschrieben');
|
||||
$check(isset(plugin_known_permissions()['auth.login']), 'Die Berechtigung auth.login ist bekannt und beschrieben');
|
||||
|
||||
// Mitarbeiter: aktiv und deaktiviert
|
||||
$ins = $pdo->prepare("INSERT INTO staff (name, email, password_hash, role, is_active, created_at) VALUES (?,?,?,?,?,?)");
|
||||
$ins->execute(['Anna Aktiv', 'anna@firma.example', password_hash('Geheim-1234567', PASSWORD_DEFAULT), 'agent', 1, db_now()]);
|
||||
$annaId = (int) $pdo->lastInsertId();
|
||||
$ins->execute(['Karl Kaputt', 'karl@firma.example', password_hash('Geheim-1234567', PASSWORD_DEFAULT), 'agent', 0, db_now()]);
|
||||
$karlId = (int) $pdo->lastInsertId();
|
||||
plugin_set_setting('logintest', 'target', (string) $annaId);
|
||||
plugin_set_setting('loginsneak', 'target', (string) $annaId);
|
||||
|
||||
// Nur das Plugin mit Berechtigung hat Anmelde-Adressen und Filter
|
||||
$pdo = null;
|
||||
$port = random_int(34000, 34499);
|
||||
$env = array_merge(getenv(), ['DESKHUB_PLUGINS_DIR' => $pluginDir, 'DESKHUB_TESTING' => '1', 'DESKHUB_SQLITE_PATH' => $db, 'DESKHUB_CATALOG_DEFAULTS' => __DIR__ . '/fixtures/no-catalog.php']);
|
||||
$proc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$port", '-t', dirname(__DIR__)], [1 => ['file', $work . '/srv.out', 'w'], 2 => ['file', $work . '/srv.err', 'w']], $pipes, dirname(__DIR__), $env);
|
||||
for ($i = 0; $i < 60; $i++) { $s = @fsockopen('127.0.0.1', $port, $en, $es, 0.2); if ($s) { fclose($s); break; } usleep(100000); }
|
||||
|
||||
$jar = $work . '/jar';
|
||||
$http = function (string $path, array $opts = []) use ($port, $jar): array {
|
||||
$c = curl_init("http://127.0.0.1:$port$path");
|
||||
curl_setopt_array($c, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 20, CURLOPT_COOKIEJAR => $jar, CURLOPT_COOKIEFILE => $jar]);
|
||||
$raw = (string) curl_exec($c);
|
||||
$status = (int) curl_getinfo($c, CURLINFO_RESPONSE_CODE);
|
||||
$hs = (int) curl_getinfo($c, CURLINFO_HEADER_SIZE);
|
||||
curl_close($c);
|
||||
$head = substr($raw, 0, $hs);
|
||||
return [$status, substr($raw, $hs), preg_match('/^Location:\s*(.+)$/mi', $head, $m) ? trim($m[1]) : ''];
|
||||
};
|
||||
$fresh = function () use ($jar): void { @unlink($jar); };
|
||||
|
||||
try {
|
||||
// Anmeldeseite: Knopf nur vom Plugin mit Berechtigung
|
||||
[$s, $body] = $http('/staff/login.php');
|
||||
$check($s === 200 && str_contains($body, 'Testdienst-Knopf') && str_contains($body, 'plugin_login.php?p=logintest&e=ok'), 'Anmeldeseite zeigt den Knopf des Plugins mit Berechtigung');
|
||||
$check(substr_count($body, 'Testdienst-Knopf') === 1, 'Das Plugin ohne Berechtigung bekommt keinen Knopf auf die Anmeldeseite');
|
||||
|
||||
// Anmelde-Adressen: ohne Berechtigung nicht registriert
|
||||
[$s] = $http('/staff/plugin_login.php?p=loginsneak&e=ok');
|
||||
$check($s === 404, 'Anmelde-Adresse eines Plugins ohne Berechtigung gibt es nicht (404)');
|
||||
[$s] = $http('/staff/plugin_login.php?p=logintest&e=gibtesnicht');
|
||||
$check($s === 404, 'Unbekannte Adresse: 404');
|
||||
[$s] = $http('/staff/plugin_login.php');
|
||||
$check($s === 404, 'Ohne Angaben: 404');
|
||||
|
||||
// Weiterleitung
|
||||
[$s, , $loc] = $http('/staff/plugin_login.php?p=logintest&e=go');
|
||||
$check($s === 302 && $loc === 'https://idp.example.org/auth?x=1', 'Weiterleitung auf https wird ausgeführt: ' . $loc);
|
||||
foreach (['javascript:alert(1)', 'http://evil.example/x', 'https://', '//evil.example', 'https://a.example/x y', 'ftp://a.example/'] as $bad) {
|
||||
[$s, , $loc] = $http('/staff/plugin_login.php?p=logintest&e=go&to=' . rawurlencode($bad));
|
||||
$check($s === 302 && str_ends_with($loc, '/staff/login.php'), 'Ungültiges Weiterleitungsziel wird abgelehnt: ' . $bad . ' -> ' . $loc);
|
||||
}
|
||||
[$s, , $loc] = $http('/staff/plugin_login.php?p=logintest&e=go&to=' . rawurlencode('http://localhost:9000/auth'));
|
||||
$check($s === 302 && $loc === 'http://localhost:9000/auth', 'http ist nur zu localhost erlaubt');
|
||||
|
||||
// Fehler und Absturz: zurück zur Anmeldeseite mit Meldung (maskiert)
|
||||
$fresh();
|
||||
[$s, , $loc] = $http('/staff/plugin_login.php?p=logintest&e=nope');
|
||||
$check($s === 302 && str_ends_with($loc, '/staff/login.php'), 'Fehler führt zurück zur Anmeldeseite');
|
||||
[$s, $body] = $http('/staff/login.php');
|
||||
$check(str_contains($body, 'Konto nicht freigegeben <b>x</b>') && !str_contains($body, '<b>x</b>'), 'Fehlermeldung wird angezeigt und maskiert');
|
||||
foreach (['boom', 'empty'] as $e) {
|
||||
[$s, , $loc] = $http('/staff/plugin_login.php?p=logintest&e=' . $e);
|
||||
$check($s === 302 && str_ends_with($loc, '/staff/login.php'), "Plugin-Fehler ($e) führt zurück zur Anmeldeseite");
|
||||
}
|
||||
|
||||
// Anmeldung: nicht angemeldet → Weiterleitung auf die Anmeldeseite; nach Plugin-Anmeldung eingeloggt
|
||||
$fresh();
|
||||
[$s, , $loc] = $http('/staff/reports.php');
|
||||
$check($s === 302 && str_contains($loc, 'login'), 'ohne Anmeldung ist der Mitarbeiterbereich gesperrt');
|
||||
[$s, , $loc] = $http('/staff/plugin_login.php?p=logintest&e=ok');
|
||||
$check($s === 302 && str_ends_with($loc, '/staff/reports.php'), 'Geprüfter Mitarbeiter wird angemeldet und weitergeleitet: ' . $loc);
|
||||
[$s, $body] = $http('/staff/reports.php');
|
||||
$check($s === 200 && !str_contains($body, 'name="password"'), 'danach ist der Mitarbeiterbereich offen');
|
||||
$log = (string) get_db()->query("SELECT GROUP_CONCAT(action, ' | ') FROM admin_audit_log WHERE action LIKE 'Anmeldung über%'")->fetchColumn();
|
||||
$check(str_contains($log, 'Anmeldung über Testdienst (Plugin logintest)'), 'Die Anmeldung steht im Admin-Protokoll: ' . $log);
|
||||
|
||||
// Schon angemeldet: normale Anmelde-Adressen führen nur zurück in den Bereich; Adressen mit „whenLoggedIn“ laufen
|
||||
[$s, , $loc] = $http('/staff/plugin_login.php?p=logintest&e=ok');
|
||||
$check($s === 302 && str_ends_with($loc, '/staff/reports.php'), 'Angemeldet: eine normale Anmelde-Adresse meldet nicht noch einmal an');
|
||||
[$s, , $loc] = $http('/staff/plugin_login.php?p=logintest&e=go');
|
||||
$check($s === 302 && str_ends_with($loc, '/staff/reports.php'), 'Angemeldet: auch eine Weiterleitungs-Adresse ohne Freigabe wird ignoriert');
|
||||
[$s, , $loc] = $http('/staff/plugin_login.php?p=logintest&e=linkgo');
|
||||
$check($s === 302 && $loc === 'https://idp.example.org/link', 'Angemeldet: freigegebene Adresse darf weiter zum Anmeldedienst leiten');
|
||||
[$s, , $loc] = $http('/staff/plugin_login.php?p=logintest&e=link');
|
||||
$check($s === 302 && str_ends_with($loc, '/staff/plugin.php?p=logintest&page=konto'), 'Angemeldet: „local“ führt zurück in den Mitarbeiterbereich: ' . $loc);
|
||||
[$s, $body] = $http('/staff/reports.php');
|
||||
$check(str_contains($body, 'Verknüpft: anna@firma.example'), 'Der Hinweis erscheint und das Plugin kennt den angemeldeten Mitarbeiter');
|
||||
[$s, , $loc] = $http('/staff/plugin_login.php?p=logintest&e=linkevil');
|
||||
$check($s === 302 && str_ends_with($loc, '/staff/reports.php') && !str_contains($loc, 'evil'), 'Ein ungültiges lokales Ziel (//evil) wird nicht angenommen: ' . $loc);
|
||||
[$s, , $loc] = $http('/staff/plugin_login.php?p=logintest&e=linkerr');
|
||||
$check($s === 302 && str_ends_with($loc, '/staff/plugin.php?p=logintest&page=konto'), 'Fehler mit „back“ führt zur angegebenen Seite zurück: ' . $loc);
|
||||
[$s, $body] = $http('/staff/reports.php');
|
||||
$check(str_contains($body, 'Nicht verknüpft'), 'Die Fehlermeldung wird angezeigt');
|
||||
[$s, , $loc] = $http('/staff/plugin_login.php?p=logintest&e=linkback');
|
||||
$check($s === 302 && str_ends_with($loc, '/staff/reports.php'), 'Ein fremdes „back“-Ziel wird nicht angenommen: ' . $loc);
|
||||
[$s, , $loc] = $http('/staff/plugin_login.php?p=logintest&e=switch');
|
||||
$check($s === 302 && str_ends_with($loc, '/staff/reports.php'), 'Angemeldet kann nie ein anderer Mitarbeiter angemeldet werden');
|
||||
[$s, $body] = $http('/staff/reports.php');
|
||||
$check(str_contains($body, 'keinen anderen Mitarbeiter') && str_contains($body, 'Anna Aktiv') || str_contains($body, 'anna@firma.example') || $s === 200, 'Danach bleibt Anna angemeldet');
|
||||
|
||||
// Deaktivierter Mitarbeiter wird nie angemeldet
|
||||
$fresh();
|
||||
plugin_set_setting('logintest', 'target', (string) $karlId);
|
||||
[$s, , $loc] = $http('/staff/plugin_login.php?p=logintest&e=ok');
|
||||
$check($s === 302 && str_ends_with($loc, '/staff/login.php'), 'Deaktivierter Mitarbeiter kommt nicht hinein');
|
||||
[$s] = $http('/staff/reports.php');
|
||||
$check($s === 302, 'und bleibt ausgesperrt');
|
||||
plugin_set_setting('logintest', 'target', '999999');
|
||||
[$s, , $loc] = $http('/staff/plugin_login.php?p=logintest&e=ok');
|
||||
$check($s === 302 && str_ends_with($loc, '/staff/login.php'), 'Unbekannte Mitarbeiter-ID wird abgelehnt');
|
||||
|
||||
// ── Kunden ──
|
||||
$fresh();
|
||||
[$s, $body] = $http('/login.php');
|
||||
$check($s === 200 && str_contains($body, 'Kunden-Knopf') && str_contains($body, '/plugin_login.php?p=logintest&e=cnew') && substr_count($body, 'Kunden-Knopf') === 1, 'Kunden-Anmeldeseite zeigt nur den Knopf des Plugins mit auth.customer');
|
||||
$check(!str_contains($body, 'Testdienst-Knopf'), 'Mitarbeiter-Knöpfe erscheinen dort nicht');
|
||||
[$s, $body] = $http('/staff/login.php');
|
||||
$check(!str_contains($body, 'Kunden-Knopf'), 'Und Kunden-Knöpfe nicht bei den Mitarbeitern');
|
||||
[$s] = $http('/plugin_login.php?p=loginsneak&e=cnew');
|
||||
$check($s === 404, 'Kunden-Adresse eines Plugins ohne auth.customer gibt es nicht');
|
||||
[$s] = $http('/plugin_login.php?p=logintest&e=ok');
|
||||
$check($s === 404, 'Eine Mitarbeiter-Adresse ist über den Kunden-Einstieg nicht erreichbar');
|
||||
[$s, , $loc] = $http('/plugin_login.php?p=logintest&e=cgo');
|
||||
$check($s === 302 && $loc === 'https://idp.example.org/kunde', 'Kunden-Weiterleitung auf https wird ausgeführt');
|
||||
// Neues Konto wird angelegt und angemeldet
|
||||
[$s, , $loc] = $http('/plugin_login.php?p=logintest&e=cnew&mail=' . rawurlencode('Neu.Kunde@Kunde.Example') . '&name=' . rawurlencode('<b>Nora</b> Neu'));
|
||||
$check($s === 302 && str_ends_with($loc, '/home.php'), 'Neuer Kunde wird angemeldet: ' . $loc);
|
||||
$u = get_db()->query("SELECT * FROM users WHERE email = 'neu.kunde@kunde.example'")->fetch();
|
||||
$check($u && $u['name'] === 'Nora Neu' && strlen((string) $u['password_hash']) > 20, 'Kundenkonto wurde angelegt (E-Mail klein geschrieben, Name ohne HTML, Passwort zufällig)');
|
||||
[$s, $body] = $http('/home.php');
|
||||
$check(str_contains($body, 'Ihr Konto wurde erstellt'), 'Kunde sieht den Willkommens-Hinweis');
|
||||
$log = (string) get_db()->query("SELECT GROUP_CONCAT(action, ' | ') FROM admin_audit_log WHERE action LIKE 'Kundenkonto über%'")->fetchColumn();
|
||||
$check(str_contains($log, 'Kundenkonto über Testdienst angelegt: neu.kunde@kunde.example'), 'Das Anlegen steht im Admin-Protokoll: ' . $log);
|
||||
// Zweites Mal: vorhandenes Konto, kein zweites
|
||||
$fresh();
|
||||
$http('/plugin_login.php?p=logintest&e=cnew&mail=' . rawurlencode('neu.kunde@kunde.example'));
|
||||
$check((int) get_db()->query("SELECT COUNT(*) FROM users WHERE email = 'neu.kunde@kunde.example'")->fetchColumn() === 1, 'Ein vorhandenes Konto wird nicht doppelt angelegt');
|
||||
// Ohne Erlaubnis zum Anlegen
|
||||
$fresh();
|
||||
[$s, , $loc] = $http('/plugin_login.php?p=logintest&e=cnew&nocreate=1&mail=' . rawurlencode('unbekannt@kunde.example'));
|
||||
$check($s === 302 && str_ends_with($loc, '/login.php') && (int) get_db()->query("SELECT COUNT(*) FROM users WHERE email = 'unbekannt@kunde.example'")->fetchColumn() === 0, 'Ohne „create“ wird kein Konto angelegt');
|
||||
[$s, $body] = $http('/login.php');
|
||||
$check(str_contains($body, 'kein Kundenkonto'), 'und die Meldung erscheint');
|
||||
// Ungültige und gesperrte Adressen
|
||||
foreach (['kein-mail', 'a@b', str_repeat('x', 160) . '@kunde.example'] as $bad) {
|
||||
[$s, , $loc] = $http('/plugin_login.php?p=logintest&e=cnew&mail=' . rawurlencode($bad));
|
||||
$check($s === 302 && str_ends_with($loc, '/login.php'), 'Ungültige E-Mail-Adresse wird abgelehnt: ' . substr($bad, 0, 20));
|
||||
}
|
||||
get_db()->prepare("INSERT INTO banlist (match_type, value, reason, created_at) VALUES ('email', ?, 'Test', ?)")->execute(['gesperrt@kunde.example', db_now()]);
|
||||
[$s, , $loc] = $http('/plugin_login.php?p=logintest&e=cnew&mail=' . rawurlencode('gesperrt@kunde.example'));
|
||||
$check($s === 302 && str_ends_with($loc, '/login.php') && (int) get_db()->query("SELECT COUNT(*) FROM users WHERE email = 'gesperrt@kunde.example'")->fetchColumn() === 0, 'Eine gesperrte E-Mail-Adresse kommt nicht hinein und bekommt kein Konto');
|
||||
// Fehler (maskiert)
|
||||
[$s, , $loc] = $http('/plugin_login.php?p=logintest&e=cerr');
|
||||
[$s, $body] = $http('/login.php');
|
||||
$check(str_contains($body, 'Kunde abgelehnt <i>x</i>') && !str_contains($body, '<i>x</i>'), 'Fehlermeldung für Kunden wird maskiert angezeigt');
|
||||
// Rückkehr über die gemeinsame Adresse (staff/plugin_login.php)
|
||||
$fresh();
|
||||
[$s, , $loc] = $http('/staff/plugin_login.php?p=logintest&e=ccb');
|
||||
$check($s === 302 && str_ends_with($loc, '/home.php') && (int) get_db()->query("SELECT COUNT(*) FROM users WHERE email = 'rueck@kunde.example'")->fetchColumn() === 1, 'Die gemeinsame Rückkehr-Adresse kann eine Kunden-Anmeldung beenden und legt das Konto an');
|
||||
[$s, $body] = $http('/staff/reports.php');
|
||||
$check($s === 302, 'und macht dabei niemanden zum Mitarbeiter');
|
||||
$fresh();
|
||||
[$s, , $loc] = $http('/staff/plugin_login.php?p=logintest&e=cerr2');
|
||||
$check($s === 302 && str_ends_with($loc, '/login.php') && !str_contains($loc, 'staff'), 'Kunden-Fehler über die gemeinsame Adresse führen auf die Kunden-Anmeldeseite');
|
||||
// Plugin ohne auth.customer darf keine Kunden anmelden, auch nicht über die gemeinsame Adresse
|
||||
$fresh();
|
||||
// (loginsneak hat keine Mitarbeiter-Adressen registriert, daher gibt es hier nichts zu erreichen: 404 ist die richtige Antwort)
|
||||
[$s] = $http('/staff/plugin_login.php?p=loginsneak&e=ccb');
|
||||
$check($s === 404, 'Ein Plugin ohne die Berechtigungen kommt nicht einmal bis zum Ergebnis');
|
||||
$st0 = &plugin_state();
|
||||
$st0['booted'] = false;
|
||||
plugins_boot();
|
||||
$check(!in_array('auth.customer', plugin_state()['perms']['loginsneak'] ?? [], true) && in_array('auth.customer', plugin_state()['perms']['logintest'] ?? [], true), 'Nur das Plugin mit Freigabe hat auth.customer in seinen Rechten');
|
||||
|
||||
// Begrenzung
|
||||
$fresh();
|
||||
$limited = false;
|
||||
for ($i = 0; $i < 60 && !$limited; $i++) {
|
||||
[$s, , $loc] = $http('/staff/plugin_login.php?p=logintest&e=go');
|
||||
if (str_ends_with($loc, '/staff/login.php')) $limited = true;
|
||||
}
|
||||
$check($limited, 'Zu viele Aufrufe werden begrenzt');
|
||||
} finally {
|
||||
proc_terminate($proc);
|
||||
proc_close($proc);
|
||||
}
|
||||
|
||||
usleep(300000);
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($work, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $f) {
|
||||
$f->isDir() ? @rmdir($f->getPathname()) : @unlink($f->getPathname());
|
||||
}
|
||||
@rmdir($work);
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo "Plugin login checks passed.\n";
|
||||
@@ -0,0 +1,349 @@
|
||||
<?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";
|
||||
Reference in New Issue
Block a user