Upload via GUI (452 Dateien)
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft Passkeys (includes/webauthn.php) mit einem nachgebauten Authenticator (ES256 und RS256): Registrierung, Anmeldung und alle
|
||||
* Ablehnungsfälle (falsche Challenge/Origin/Domain, fehlende Nutzerprüfung, falsche Signatur, Zähler, fremdes Konto, inaktives Konto),
|
||||
* Entfernen durch den Mitarbeiter und durch Administratoren, „Das war ich nicht“.
|
||||
* Nicht geprüft werden echte Geräte (Windows Hello, Handy, Sicherheitsschlüssel): Das Zusammenspiel mit dem Browser braucht einen Test von Hand.
|
||||
*/
|
||||
|
||||
if (!extension_loaded('pdo_sqlite') || !extension_loaded('openssl')) { echo "Passkey-Test übersprungen: pdo_sqlite oder openssl fehlt.\n"; exit(0); }
|
||||
|
||||
putenv('DESKHUB_TESTING=1');
|
||||
putenv('DESKHUB_SQLITE_PATH=:memory:');
|
||||
require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
||||
require_once dirname(__DIR__) . '/includes/webauthn.php';
|
||||
|
||||
$failures = [];
|
||||
$total = 0;
|
||||
$check = function (bool $ok, string $what) use (&$failures, &$total): void {
|
||||
$total++;
|
||||
if (!$ok) $failures[] = $what;
|
||||
};
|
||||
|
||||
$pdo = get_db();
|
||||
set_setting('site_url', 'https://support.test');
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$dept = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
|
||||
$pdo->exec('DELETE FROM staff');
|
||||
$ins = $pdo->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)');
|
||||
$ins->execute(['Eva', 'eva@test.de', 'x', 'agent', $dept, $now]); $eva = (int) $pdo->lastInsertId();
|
||||
$ins->execute(['Max', 'max@test.de', 'x', 'agent', $dept, $now]); $max = (int) $pdo->lastInsertId();
|
||||
$staffRow = fn(int $id) => $pdo->query('SELECT * FROM staff WHERE id = ' . $id)->fetch();
|
||||
|
||||
require_once __DIR__ . '/fixtures/fake_authenticator.php';
|
||||
$GLOBALS['fa_rp'] = 'support.test';
|
||||
$GLOBALS['fa_origin'] = 'https://support.test';
|
||||
$bin = fn(string $b) => "\0BIN" . $b;
|
||||
|
||||
$check(webauthn_cbor_decode(hex2bin('a201020326')) === [1 => 2, 3 => -7], 'CBOR-Leser liest eine COSE-Tabelle');
|
||||
$m = webauthn_cbor_decode(t_cbor(['fmt' => 'none', 'n' => 300, 'neg' => -5, 'b' => $bin("\x01\x02")]));
|
||||
$check($m['fmt'] === 'none' && $m['n'] === 300 && $m['neg'] === -5 && $m['b'] === "\x01\x02", 'CBOR: Text, Zahlen, negative Zahlen und Bytes werden gelesen');
|
||||
$bad = false;
|
||||
try { webauthn_cbor_decode("\x5f\x41"); } catch (Throwable $e) { $bad = true; }
|
||||
$check($bad, 'CBOR: unbestimmte Länge und abgeschnittene Daten werden abgelehnt');
|
||||
$check(webauthn_b64u_decode(webauthn_b64u_encode("\xfb\xff\x00abc")) === "\xfb\xff\x00abc", 'base64url hin und zurück');
|
||||
|
||||
$rp = webauthn_rp();
|
||||
$check($rp['id'] === 'support.test' && $rp['origin'] === 'https://support.test' && $rp['secure'] === true, 'Relying Party stammt aus der Website-URL');
|
||||
|
||||
$register = function (FakeKey $k, int $sid, array $o = [], string $name = 'Laptop') use ($pdo, $staffRow): array {
|
||||
$opt = webauthn_creation_options($pdo, $staffRow($sid));
|
||||
return webauthn_register_finish($pdo, $staffRow($sid), t_register($k, $opt, $o), $name);
|
||||
};
|
||||
$login = function (FakeKey $k, int $sid, array $o = []) use ($pdo): array {
|
||||
$opt = webauthn_request_options();
|
||||
return webauthn_login_finish($pdo, t_login($k, $opt, $sid, $o));
|
||||
};
|
||||
|
||||
// Subdomain: Wird DeskHub unter einer Subdomain der Website-URL aufgerufen, gilt diese Adresse (der Browser verlangt genau sie)
|
||||
$oldHost = $_SERVER['HTTP_HOST'] ?? null;
|
||||
set_setting('site_url', 'https://windelweb.org');
|
||||
foreach ([
|
||||
['support.windelweb.org', 'support.windelweb.org', 'https://support.windelweb.org'],
|
||||
['SUPPORT.WindelWeb.org:8443', 'support.windelweb.org', 'https://support.windelweb.org:8443'],
|
||||
['support.windelweb.org:443', 'support.windelweb.org', 'https://support.windelweb.org'],
|
||||
['windelweb.org', 'windelweb.org', 'https://windelweb.org'],
|
||||
['evil.example', 'windelweb.org', 'https://windelweb.org'],
|
||||
['windelweb.org.evil.example', 'windelweb.org', 'https://windelweb.org'],
|
||||
['evilwindelweb.org', 'windelweb.org', 'https://windelweb.org'],
|
||||
['support.windelweb.org/../x', 'windelweb.org', 'https://windelweb.org'],
|
||||
['', 'windelweb.org', 'https://windelweb.org'],
|
||||
] as [$h, $id, $origin]) {
|
||||
$_SERVER['HTTP_HOST'] = $h;
|
||||
$r = webauthn_rp();
|
||||
$check($r['id'] === $id && $r['origin'] === $origin, "Domain bei Host „{$h}“: {$r['id']} / {$r['origin']}");
|
||||
}
|
||||
// Reverse-Proxy verändert den Host-Kopf: der Origin-Kopf des Browsers entscheidet
|
||||
$_SERVER['HTTP_HOST'] = 'deskhub-intern:8080';
|
||||
$_SERVER['HTTP_ORIGIN'] = 'https://support.windelweb.org';
|
||||
$r = webauthn_rp();
|
||||
$check($r['id'] === 'support.windelweb.org' && $r['origin'] === 'https://support.windelweb.org', 'Origin-Kopf des Browsers gilt, wenn der Host-Kopf intern umgeschrieben wurde');
|
||||
$_SERVER['HTTP_ORIGIN'] = 'https://evil.example';
|
||||
$r = webauthn_rp();
|
||||
$check($r['id'] === 'windelweb.org', 'Fremder Origin-Kopf wird ignoriert');
|
||||
unset($_SERVER['HTTP_ORIGIN']);
|
||||
if ($oldHost === null) unset($_SERVER['HTTP_HOST']); else $_SERVER['HTTP_HOST'] = $oldHost;
|
||||
set_setting('site_url', 'https://support.test');
|
||||
|
||||
// ── Optionen ───────────────────────────────────────────────────────────────────
|
||||
$opt = webauthn_creation_options($pdo, $staffRow($eva));
|
||||
$check($opt['rp']['id'] === 'support.test' && $opt['authenticatorSelection']['userVerification'] === 'required' && $opt['authenticatorSelection']['residentKey'] === 'required', 'Registrierung verlangt Nutzerprüfung und einen auffindbaren Schlüssel');
|
||||
$check(strlen(webauthn_b64u_decode($opt['challenge'])) === 32 && $opt['user']['id'] !== '' && $opt['user']['name'] === 'eva@test.de' && $opt['user']['displayName'] === 'Eva', 'Challenge hat 32 Bytes, die Benutzerkennung ist gesetzt');
|
||||
$check(webauthn_user_handle($eva) !== webauthn_user_handle($max) && strlen(webauthn_user_handle($eva)) === 32, 'Benutzerkennungen sind je Mitarbeiter verschieden');
|
||||
|
||||
// ── Registrierung ──────────────────────────────────────────────────────────────
|
||||
$ec = new FakeKey('ec');
|
||||
$r = $register($ec, $eva);
|
||||
$check(!empty($r['ok']), 'Registrierung mit ES256 klappt: ' . json_encode($r));
|
||||
$check(webauthn_count_passkeys($pdo, $eva) === 1 && webauthn_count_passkeys($pdo, $max) === 0, 'Der Passkey gehört nur Eva');
|
||||
$row = webauthn_staff_passkeys($pdo, $eva)[0];
|
||||
$check($row['name'] === 'Laptop' && (int) $row['alg'] === -7 && str_contains($row['public_key'], 'BEGIN PUBLIC KEY'), 'Name, Algorithmus und Schlüssel sind gespeichert');
|
||||
$r = $register($ec, $max);
|
||||
$check(empty($r['ok']) && str_contains((string) $r['error'], 'schon registriert'), 'Derselbe Passkey lässt sich nicht für ein zweites Konto eintragen');
|
||||
foreach ([
|
||||
'falsche Challenge' => ['challenge' => webauthn_b64u_encode(random_bytes(32))],
|
||||
'falscher Origin' => ['origin' => 'https://evil.test'],
|
||||
'falsche Domain im Passkey' => ['rpId' => 'evil.test'],
|
||||
'ohne Nutzerprüfung' => ['flags' => 0x41],
|
||||
'ohne Anwesenheit' => ['flags' => 0x44],
|
||||
'falscher Vorgang' => ['type' => 'webauthn.get'],
|
||||
] as $label => $o) {
|
||||
$k = new FakeKey('ec');
|
||||
$r = $register($k, $max, $o);
|
||||
$check(empty($r['ok']), 'Registrierung abgelehnt: ' . $label);
|
||||
}
|
||||
$check(webauthn_count_passkeys($pdo, $max) === 0, 'Abgelehnte Registrierungen hinterlassen nichts');
|
||||
// Challenge nur einmal verwendbar
|
||||
$opt = webauthn_creation_options($pdo, $staffRow($max));
|
||||
$k = new FakeKey('ec');
|
||||
$cred = t_register($k, $opt);
|
||||
$first = webauthn_register_finish($pdo, $staffRow($max), $cred, 'Handy');
|
||||
$k2 = new FakeKey('ec');
|
||||
$second = webauthn_register_finish($pdo, $staffRow($max), t_register($k2, $opt), 'Zweit');
|
||||
$check(!empty($first['ok']) && empty($second['ok']), 'Eine Challenge gilt nur für einen Versuch');
|
||||
$opt = webauthn_creation_options($pdo, $staffRow($eva));
|
||||
$check(count($opt['excludeCredentials']) === 1, 'Schon eingetragene Passkeys werden beim Registrieren ausgeschlossen');
|
||||
// Challenge eines anderen Mitarbeiters
|
||||
$opt = webauthn_creation_options($pdo, $staffRow($eva));
|
||||
$r = webauthn_register_finish($pdo, $staffRow($max), t_register(new FakeKey('ec'), $opt), 'Fremd');
|
||||
$check(empty($r['ok']), 'Challenge eines anderen Mitarbeiters wird nicht angenommen');
|
||||
$_SESSION['webauthn_challenge'] = ['v' => 'x', 'type' => 'create', 'exp' => time() - 1, 'staff' => $eva];
|
||||
$check(webauthn_challenge_take('create', $eva) === null, 'Abgelaufene Challenge wird abgelehnt');
|
||||
|
||||
// ── Anmeldung ──────────────────────────────────────────────────────────────────
|
||||
$r = $login($ec, $eva);
|
||||
$check(!empty($r['ok']) && (int) $r['staff']['id'] === $eva, 'Anmeldung mit ES256 klappt und liefert das richtige Konto: ' . json_encode($r['error'] ?? ''));
|
||||
$check(webauthn_staff_passkeys($pdo, $eva)[0]['last_used_at'] !== null && (int) webauthn_staff_passkeys($pdo, $eva)[0]['sign_count'] === 1, 'Zähler und „zuletzt benutzt“ werden gespeichert');
|
||||
$r = $login($ec, $eva, ['count' => 1]);
|
||||
$check(empty($r['ok']) && str_contains((string) $r['error'], 'kopiert'), 'Zähler, der nicht wächst, wird als Kopie abgelehnt');
|
||||
$r = $login($ec, $eva);
|
||||
$check(!empty($r['ok']), 'Danach klappt es mit wachsendem Zähler wieder');
|
||||
$login($ec, $eva, ['count' => 50]);
|
||||
foreach ([
|
||||
'falsche Challenge' => ['challenge' => webauthn_b64u_encode(random_bytes(32))],
|
||||
'falscher Origin' => ['origin' => 'https://evil.test'],
|
||||
'falsche Domain' => ['rpId' => 'evil.test'],
|
||||
'ohne Nutzerprüfung' => ['flags' => 0x01],
|
||||
'fremde Signatur' => ['signWith' => (new FakeKey('ec'))->res],
|
||||
'fremde Benutzerkennung' => ['userHandle' => webauthn_b64u_encode(webauthn_user_handle($max))],
|
||||
'falscher Vorgang' => ['type' => 'webauthn.create'],
|
||||
] as $label => $o) {
|
||||
$r = $login($ec, $eva, $o + ['count' => (int) $ec->count + 5]);
|
||||
$check(empty($r['ok']), 'Anmeldung abgelehnt: ' . $label);
|
||||
}
|
||||
$r = $login(new FakeKey('ec'), $eva);
|
||||
$check(empty($r['ok']), 'Unbekannter Passkey wird abgelehnt');
|
||||
$opt = webauthn_request_options();
|
||||
$cred = t_login($ec, $opt, $eva, ['count' => 200]);
|
||||
$r1 = webauthn_login_finish($pdo, $cred);
|
||||
$r2 = webauthn_login_finish($pdo, $cred);
|
||||
$check(!empty($r1['ok']) && empty($r2['ok']), 'Dieselbe Antwort lässt sich nicht noch einmal verwenden');
|
||||
|
||||
// RS256
|
||||
$rsa = new FakeKey('rsa');
|
||||
$r = $register($rsa, $max, [], 'Sicherheitsschlüssel');
|
||||
$check(!empty($r['ok']), 'Registrierung mit RS256 klappt: ' . json_encode($r));
|
||||
$r = $login($rsa, $max);
|
||||
$check(!empty($r['ok']) && (int) $r['staff']['id'] === $max, 'Anmeldung mit RS256 klappt');
|
||||
// Zähler 0 bei Geräten ohne Zähler (Sicherheitsschlüssel/iCloud): bleibt erlaubt
|
||||
$zero = new FakeKey('ec');
|
||||
$register($zero, $max, [], 'Ohne Zähler');
|
||||
$r = $login($zero, $max, ['count' => 0]);
|
||||
$r2 = $login($zero, $max, ['count' => 0]);
|
||||
$check(!empty($r['ok']) && !empty($r2['ok']), 'Geräte, die immer 0 melden, bleiben anmeldbar');
|
||||
|
||||
// Inaktives Konto
|
||||
$pdo->prepare('UPDATE staff SET is_active = 0 WHERE id = ?')->execute([$max]);
|
||||
$r = $login($rsa, $max);
|
||||
$check(empty($r['ok']), 'Ein deaktiviertes Konto kann sich nicht mit Passkey anmelden');
|
||||
$pdo->prepare('UPDATE staff SET is_active = 1 WHERE id = ?')->execute([$max]);
|
||||
|
||||
// Nicht unterstützter Schlüssel
|
||||
$bad = false;
|
||||
try { webauthn_cose_to_pem([1 => 1, 3 => -8]); } catch (Throwable $e) { $bad = true; }
|
||||
$check($bad, 'Nicht unterstützte Schlüsselarten werden abgelehnt');
|
||||
|
||||
// ── Entfernen ──────────────────────────────────────────────────────────────────
|
||||
$n = webauthn_remove_all($pdo, $max);
|
||||
$check($n === 3 && webauthn_count_passkeys($pdo, $max) === 0 && webauthn_count_passkeys($pdo, $eva) === 1, 'Administrator-Entfernen löscht alle Passkeys des Mitarbeiters und nur seine');
|
||||
$r = $login($rsa, $max);
|
||||
$check(empty($r['ok']), 'Nach dem Entfernen ist die Anmeldung mit diesem Passkey nicht mehr möglich');
|
||||
$check(webauthn_remove_all($pdo, $max) === 0, 'Nochmaliges Entfernen ohne Passkeys ist harmlos');
|
||||
|
||||
// „Das war ich nicht“ entfernt Passkeys ebenfalls
|
||||
$pdo->prepare('INSERT INTO login_events (event, staff_id, email, channel, ip, device, place, created_at, token_hash, token_used) VALUES (?,?,?,?,?,?,?,?,?,0)')
|
||||
->execute(['new_device', $eva, 'eva@test.de', 'web', '203.0.113.5', 'Test', 'Testort', $now, hash('sha256', 'x')]);
|
||||
$evt = $pdo->query('SELECT * FROM login_events ORDER BY id DESC LIMIT 1')->fetch();
|
||||
login_guard_revoke($pdo, $evt);
|
||||
$check(webauthn_count_passkeys($pdo, $eva) === 0, '„Das war ich nicht“ entfernt auch die Passkeys');
|
||||
|
||||
// Protokoll-Beschriftungen
|
||||
$labels = login_guard_event_labels();
|
||||
$check(isset($labels['passkey_added'], $labels['passkey_removed']), 'Protokoll kennt „Passkey hinzugefügt/entfernt“');
|
||||
|
||||
echo $failures ? "FEHLGESCHLAGEN:\n - " . implode("\n - ", $failures) . "\n" : '';
|
||||
echo "Passkey-Test: " . ($total - count($failures)) . " von {$total} Prüfungen in Ordnung.\n";
|
||||
exit($failures ? 1 : 0);
|
||||
@@ -0,0 +1,282 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft Passkeys über die echte Weboberfläche (Anmeldeseite, staff/passkey.php, Profil, Mitarbeiterverwaltung) mit einem nachgebauten Authenticator:
|
||||
* Registrieren (mit Passwortbestätigung), Anmelden ohne Passwort, Ablehnungen, Entfernen durch den Mitarbeiter und durch einen Administrator.
|
||||
*/
|
||||
|
||||
if (!extension_loaded('pdo_sqlite') || !function_exists('curl_init')) { echo "Abmelde-Web-Test übersprungen: pdo_sqlite oder curl fehlt.\n"; exit(0); }
|
||||
|
||||
date_default_timezone_set('Europe/Berlin');
|
||||
$root = dirname(__DIR__);
|
||||
$work = sys_get_temp_dir() . '/deskhub-pkw-' . bin2hex(random_bytes(4));
|
||||
$tmp = $work . '/inst';
|
||||
mkdir($tmp, 0755, true);
|
||||
$webPort = random_int(35000, 35499);
|
||||
$skip = '#^(config/config\.php|database/[^/]+\.sqlite[^/]*|backups|uploads|tests|plugins/[^/]+|\.git)(/|$)#';
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST) as $f) {
|
||||
$rel = str_replace(chr(92), '/', substr($f->getPathname(), strlen($root) + 1));
|
||||
if (preg_match($skip, $rel) && !preg_match('#^(backups|uploads|plugins)/(\.htaccess|README\.md)$#', $rel)) continue;
|
||||
if ($f->isDir()) { @mkdir($tmp . '/' . $rel, 0755, true); continue; }
|
||||
@mkdir(dirname($tmp . '/' . $rel), 0755, true);
|
||||
copy($f->getPathname(), $tmp . '/' . $rel);
|
||||
}
|
||||
@mkdir($tmp . '/uploads');
|
||||
@mkdir($tmp . '/config');
|
||||
file_put_contents($tmp . '/config/config.php', '<?php return ' . var_export([
|
||||
'app_key' => bin2hex(random_bytes(32)),
|
||||
'db' => ['driver' => 'sqlite', 'sqlite_path' => $tmp . '/database/deskhub.sqlite'],
|
||||
'site' => ['name' => 'Test', 'url' => 'https://127.0.0.1:' . $webPort, 'admin_email' => 'a@test.de', 'timezone' => 'Europe/Berlin'],
|
||||
'uploads' => ['max_size_bytes' => 10485760, 'allowed_extensions' => ['png'], 'path' => $tmp . '/uploads'],
|
||||
], true) . ';');
|
||||
|
||||
putenv('DESKHUB_CONFIG_JSON');
|
||||
putenv('DESKHUB_SQLITE_PATH');
|
||||
putenv('DESKHUB_TESTING');
|
||||
putenv('DESKHUB_API_ALLOW_INSECURE=1');
|
||||
require_once $root . '/database/sqlite_schema.php';
|
||||
require_once $root . '/database/migrate.php';
|
||||
$db = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
|
||||
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
$db->exec('PRAGMA foreign_keys = ON');
|
||||
$db->exec(convert_schema_to_sqlite((string) file_get_contents($root . '/database/schema.sql')));
|
||||
run_migrations($db, true);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$dept = (int) $db->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
|
||||
$db->exec('DELETE FROM staff');
|
||||
$ins = $db->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)');
|
||||
foreach ([['Chef', 'chef@test.de', 'admin'], ['Eva', 'eva@test.de', 'agent'], ['Max', 'max@test.de', 'agent']] as [$n, $e, $r]) {
|
||||
$ins->execute([$n, $e, password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT), $r, $dept, $now]);
|
||||
}
|
||||
$evaId = (int) $db->query("SELECT id FROM staff WHERE email = 'eva@test.de'")->fetchColumn();
|
||||
$db = null;
|
||||
|
||||
$failures = [];
|
||||
$total = 0;
|
||||
$check = function (bool $ok, string $what) use (&$failures, &$total): void { $total++; if (!$ok) $failures[] = $what; };
|
||||
$webProc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$webPort", '-t', $tmp], [['pipe', 'r'], ['file', $work . '/web.out', 'w'], ['file', $work . '/web.out', 'a']], $wp, $tmp);
|
||||
|
||||
try {
|
||||
for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $webPort)) break; usleep(100000); }
|
||||
$pdo = function () use ($tmp): PDO {
|
||||
$p = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
|
||||
$p->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$p->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
return $p;
|
||||
};
|
||||
$call = function (string $method, string $path, array $form, string $jar, ?string $bearer = null, ?array $json = null) use ($webPort): array {
|
||||
$ch = curl_init("http://127.0.0.1:$webPort$path");
|
||||
$h = ['Sec-Fetch-Mode: navigate'];
|
||||
if ($json !== null) $h[] = 'Content-Type: application/json';
|
||||
if ($bearer) $h[] = 'Authorization: Bearer ' . $bearer;
|
||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 60, CURLOPT_HEADER => true, CURLOPT_HTTPHEADER => $h]);
|
||||
if ($jar !== '') { curl_setopt($ch, CURLOPT_COOKIEJAR, $jar); curl_setopt($ch, CURLOPT_COOKIEFILE, $jar); }
|
||||
if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $json !== null ? json_encode($json) : http_build_query($form)); }
|
||||
$raw = (string) curl_exec($ch);
|
||||
$s = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$hs = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||
curl_close($ch);
|
||||
return [$s, substr($raw, $hs), substr($raw, 0, $hs)];
|
||||
};
|
||||
$login = function (string $email) use ($call): string {
|
||||
$jar = tempnam(sys_get_temp_dir(), 'sow');
|
||||
[, $lp] = $call('GET', '/staff/login.php', [], $jar);
|
||||
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $lp, $m);
|
||||
$call('POST', '/staff/login.php', ['csrf_token' => $m[1] ?? '', 'email' => $email, 'password' => 'Sehr-Sicher-1234'], $jar);
|
||||
return $jar;
|
||||
};
|
||||
$csrf = function (string $path, string $jar) use ($call): string {
|
||||
[, $p] = $call('GET', $path, [], $jar);
|
||||
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $p, $mm);
|
||||
return $mm[1] ?? '';
|
||||
};
|
||||
$loggedIn = function (string $jar) use ($call): bool {
|
||||
[$s, $b] = $call('GET', '/staff/profile.php?tab=security', [], $jar);
|
||||
return $s === 200 && str_contains($b, 'Von allen Geräten abmelden');
|
||||
};
|
||||
$apiToken = function (string $email) use ($call): string {
|
||||
[, $b] = $call('POST', '/api/staff/v1/login.php', [], '', null, ['email' => $email, 'password' => 'Sehr-Sicher-1234', 'deviceName' => 'Test-Gerät']);
|
||||
return (string) (json_decode($b, true)['token'] ?? '');
|
||||
};
|
||||
$apiOk = fn(string $tok) => $call('GET', '/api/staff/v1/me.php', [], '', $tok)[0];
|
||||
|
||||
require_once $root . '/includes/webauthn.php';
|
||||
require_once __DIR__ . '/fixtures/fake_authenticator.php';
|
||||
$GLOBALS['fa_rp'] = '127.0.0.1';
|
||||
$GLOBALS['fa_origin'] = 'https://127.0.0.1:' . $webPort;
|
||||
$chefId = (int) $pdo()->query("SELECT id FROM staff WHERE email = 'chef@test.de'")->fetchColumn();
|
||||
$maxId = (int) $pdo()->query("SELECT id FROM staff WHERE email = 'max@test.de'")->fetchColumn();
|
||||
$count = fn(int $sid) => (int) $pdo()->query("SELECT COUNT(*) FROM staff_passkeys WHERE staff_id = {$sid}")->fetchColumn();
|
||||
$pk = function (string $jar, string $csrf, array $f) use ($call): array {
|
||||
[$s, $b] = $call('POST', '/staff/passkey.php', ['csrf_token' => $csrf] + $f, $jar);
|
||||
return [$s, json_decode($b, true) ?: []];
|
||||
};
|
||||
$addPasskey = function (string $jar, FakeKey $k, string $name = 'Laptop', string $pw = 'Sehr-Sicher-1234') use ($csrf, $pk): array {
|
||||
$t = $csrf('/staff/profile.php?tab=security', $jar);
|
||||
[$s, $o] = $pk($jar, $t, ['action' => 'register_options', 'password' => $pw]);
|
||||
if ($s !== 200) return [$s, $o];
|
||||
return $pk($jar, $t, ['action' => 'register_verify', 'name' => $name, 'credential' => json_encode(t_register($k, $o['options']))]);
|
||||
};
|
||||
$passkeyLogin = function (FakeKey $k, array $o = []) use ($call, $pk): array {
|
||||
$jar = tempnam(sys_get_temp_dir(), 'pkw');
|
||||
[, $lp] = $call('GET', '/staff/login.php', [], $jar);
|
||||
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $lp, $m);
|
||||
[$s, $opt] = $pk($jar, $m[1] ?? '', ['action' => 'login_options']);
|
||||
if ($s !== 200) return [$jar, $s, $opt];
|
||||
[$s, $r] = $pk($jar, $m[1], ['action' => 'login_verify', 'credential' => json_encode(t_login($k, $opt['options'], 0, $o + ['userHandle' => '']))]);
|
||||
return [$jar, $s, $r];
|
||||
};
|
||||
|
||||
$jarEva = $login('eva@test.de');
|
||||
$jarChef = $login('chef@test.de');
|
||||
$jarMax = $login('max@test.de');
|
||||
[, $page] = $call('GET', '/staff/profile.php?tab=security', [], $jarEva);
|
||||
$check(str_contains($page, 'Passkeys') && str_contains($page, 'Noch kein Passkey eingerichtet') && str_contains($page, '/assets/js/passkey.js'), 'Profil zeigt den Bereich „Passkeys“ ohne Eintrag');
|
||||
[, $lp] = $call('GET', '/staff/login.php', [], '');
|
||||
$check(str_contains($lp, 'Mit Passkey anmelden') && str_contains($lp, 'passkey.js'), 'Anmeldeseite hat den Knopf „Mit Passkey anmelden“');
|
||||
|
||||
$check(preg_match('/id="pk_name"[^>]*autocomplete="off"[^>]*data-bwignore/', $page) === 1, 'Namensfeld des Passkeys wird von Passwortmanagern nicht ausgefüllt');
|
||||
// Registrieren
|
||||
$evaKey = new FakeKey('ec');
|
||||
[$s, $r] = $addPasskey($jarEva, $evaKey, 'Laptop', 'falsches-Passwort');
|
||||
$check($s === 403 && empty($r['ok']) && $count($evaId) === 0, 'Registrierung ohne richtiges Passwort wird abgelehnt (HTTP ' . $s . ')');
|
||||
[$s, $r] = $pk($jarEva, '', ['action' => 'register_options', 'password' => 'Sehr-Sicher-1234']);
|
||||
$check($s === 403, 'ohne CSRF-Token wird nichts angenommen');
|
||||
[$s, $b] = $call('POST', '/staff/passkey.php', ['action' => 'register_options', 'password' => 'x'], '');
|
||||
$check($s === 403, 'ohne Sitzung wird nichts angenommen');
|
||||
$t = $csrf('/staff/profile.php?tab=security', $jarEva);
|
||||
[$s, $r] = $pk('', $t, ['action' => 'register_options', 'password' => 'Sehr-Sicher-1234']);
|
||||
$check($s === 403 || $s === 401, 'ohne Anmeldung gibt es keine Registrierungsoptionen (HTTP ' . $s . ')');
|
||||
[$s, $r] = $addPasskey($jarEva, $evaKey);
|
||||
$check($s === 200 && !empty($r['ok']) && $count($evaId) === 1, 'Registrierung mit richtigem Passwort klappt: ' . json_encode($r));
|
||||
[, $page] = $call('GET', '/staff/profile.php?tab=security', [], $jarEva);
|
||||
$check(str_contains($page, 'Laptop') && str_contains($page, 'Passkey hinzugefügt'), 'Profil zeigt den Passkey und die Erfolgsmeldung');
|
||||
[$s, $r] = $addPasskey($jarEva, $evaKey, 'Nochmal');
|
||||
$check(empty($r['ok']) && $count($evaId) === 1, 'Derselbe Passkey lässt sich nicht doppelt eintragen');
|
||||
|
||||
// Anmelden ohne Passwort
|
||||
[$jar, $s, $r] = $passkeyLogin($evaKey);
|
||||
$check($s === 200 && !empty($r['ok']) && $loggedIn($jar), 'Anmeldung mit Passkey klappt und die Sitzung ist gültig: ' . json_encode($r));
|
||||
$ev = $pdo()->query("SELECT COUNT(*) FROM login_events WHERE staff_id = {$evaId} AND event IN ('success','new_device','new_place')")->fetchColumn();
|
||||
$check((int) $ev >= 1, 'Protokoll: die Anmeldung steht im Anmelde-Wächter');
|
||||
@unlink($jar);
|
||||
[$jar, $s, $r] = $passkeyLogin($evaKey, ['count' => 1]);
|
||||
$check($s === 401 && !$loggedIn($jar), 'Zähler, der nicht wächst, wird abgelehnt (HTTP ' . $s . ')');
|
||||
@unlink($jar);
|
||||
[$jar, $s, $r] = $passkeyLogin(new FakeKey('ec'));
|
||||
$check($s === 401 && !$loggedIn($jar), 'unbekannter Passkey wird abgelehnt');
|
||||
@unlink($jar);
|
||||
[$jar, $s, $r] = $passkeyLogin($evaKey, ['signWith' => (new FakeKey('ec'))->res, 'count' => 20]);
|
||||
$check($s === 401 && !$loggedIn($jar), 'falsche Signatur wird abgelehnt');
|
||||
@unlink($jar);
|
||||
[$jar, $s, $r] = $passkeyLogin($evaKey, ['origin' => 'https://evil.example', 'count' => 30]);
|
||||
$check($s === 401 && !$loggedIn($jar), 'falsche Herkunft wird abgelehnt');
|
||||
@unlink($jar);
|
||||
// Antwort ohne vorherige Optionen
|
||||
$jarN = tempnam(sys_get_temp_dir(), 'pkw');
|
||||
[, $lp] = $call('GET', '/staff/login.php', [], $jarN);
|
||||
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $lp, $m);
|
||||
$fake = t_login($evaKey, ['challenge' => webauthn_b64u_encode(random_bytes(32))], 0, ['count' => 40, 'userHandle' => '']);
|
||||
[$s, $r] = $pk($jarN, $m[1], ['action' => 'login_verify', 'credential' => json_encode($fake)]);
|
||||
$check($s === 401 && !$loggedIn($jarN), 'ohne vorherige Anfrage (keine Challenge in der Sitzung) wird abgelehnt');
|
||||
@unlink($jarN);
|
||||
// Deaktiviertes Konto
|
||||
$p = $pdo(); $p->exec("UPDATE staff SET is_active = 0 WHERE id = {$evaId}");
|
||||
[$jar, $s] = $passkeyLogin($evaKey, ['count' => 50]);
|
||||
$check($s === 401 && !$loggedIn($jar), 'deaktiviertes Konto kann sich nicht mit Passkey anmelden');
|
||||
@unlink($jar);
|
||||
$p->exec("UPDATE staff SET is_active = 1 WHERE id = {$evaId}");
|
||||
|
||||
// Administrator entfernt die Passkeys eines Mitarbeiters
|
||||
[$s, $r] = $addPasskey($jarMax, new FakeKey('ec'), 'Max Handy');
|
||||
$check($count($maxId) === 1, 'Max hat einen Passkey');
|
||||
[, $page] = $call('GET', '/staff/staff_manage.php', [], $jarChef);
|
||||
$check(str_contains($page, 'Passkeys entfernen (1)') && str_contains($page, '🔑 1 Passkey'), 'Mitarbeiterverwaltung zeigt „Passkeys entfernen (1)“ und die Anzahl');
|
||||
// Ein normaler Mitarbeiter darf das nicht
|
||||
$t = $csrf('/staff/profile.php', $jarMax);
|
||||
$call('POST', '/staff/staff_manage.php', ['csrf_token' => $t, 'action' => 'remove_passkeys', 'id' => $evaId], $jarMax);
|
||||
$check($count($evaId) === 1, 'Ein normaler Mitarbeiter kann keine fremden Passkeys entfernen');
|
||||
$t = $csrf('/staff/staff_manage.php', $jarChef);
|
||||
[$s] = $call('POST', '/staff/staff_manage.php', ['csrf_token' => $t, 'action' => 'remove_passkeys', 'id' => $maxId], $jarChef);
|
||||
$check($s === 302 && $count($maxId) === 0 && $count($evaId) === 1, 'Administrator entfernt Max’ Passkeys, Evas bleiben (HTTP ' . $s . ')');
|
||||
$ev = $pdo()->query("SELECT detail FROM login_events WHERE event = 'passkey_removed' AND staff_id = {$maxId}")->fetchColumn();
|
||||
$check(str_contains((string) $ev, 'Administrator'), 'Protokoll nennt den Administrator');
|
||||
$aud = $pdo()->query("SELECT COUNT(*) FROM admin_audit_log WHERE action LIKE 'Passkeys von Mitarbeiter #{$maxId} entfernt%'")->fetchColumn();
|
||||
$check((int) $aud === 1, 'Admin-Protokoll hat den Eintrag');
|
||||
[, $page] = $call('GET', '/staff/staff_manage.php', [], $jarChef);
|
||||
$check(!str_contains($page, 'Passkeys entfernen (0)') && str_contains($page, 'Passkeys entfernen (1)'), 'Danach steht der Knopf nur noch bei Eva');
|
||||
$maxKey = new FakeKey('ec');
|
||||
$addPasskey($jarMax, $maxKey, 'Neu');
|
||||
[$jar, $s] = $passkeyLogin($maxKey);
|
||||
$check($s === 200 && $loggedIn($jar), 'Nach dem Entfernen kann der Mitarbeiter einen neuen Passkey einrichten und nutzen');
|
||||
@unlink($jar);
|
||||
|
||||
// Mitarbeiter entfernt den eigenen Passkey (Passwort nötig)
|
||||
$pid = (int) $pdo()->query("SELECT id FROM staff_passkeys WHERE staff_id = {$evaId}")->fetchColumn();
|
||||
$t = $csrf('/staff/profile.php?tab=security', $jarEva);
|
||||
$call('POST', '/staff/profile.php?tab=security', ['csrf_token' => $t, 'action' => 'delete_passkey', 'passkey_id' => $pid, 'confirm_password' => 'falsch'], $jarEva);
|
||||
$check($count($evaId) === 1, 'Eigener Passkey: mit falschem Passwort bleibt er bestehen');
|
||||
$call('POST', '/staff/profile.php?tab=security', ['csrf_token' => $t, 'action' => 'delete_passkey', 'passkey_id' => $pid, 'confirm_password' => 'Sehr-Sicher-1234'], $jarEva);
|
||||
$check($count($evaId) === 0, 'Eigener Passkey: mit richtigem Passwort wird er entfernt');
|
||||
[$jar, $s] = $passkeyLogin($evaKey, ['count' => 60]);
|
||||
$check($s === 401 && !$loggedIn($jar), 'Der entfernte Passkey meldet nicht mehr an');
|
||||
@unlink($jar);
|
||||
// Fremden Passkey kann man nicht über die eigene ID löschen
|
||||
$maxPid = (int) $pdo()->query("SELECT id FROM staff_passkeys WHERE staff_id = {$maxId}")->fetchColumn();
|
||||
$t = $csrf('/staff/profile.php?tab=security', $jarEva);
|
||||
$call('POST', '/staff/profile.php?tab=security', ['csrf_token' => $t, 'action' => 'delete_passkey', 'passkey_id' => $maxPid, 'confirm_password' => 'Sehr-Sicher-1234'], $jarEva);
|
||||
$check($count($maxId) === 1, 'Ein Passkey eines anderen Mitarbeiters lässt sich nicht löschen');
|
||||
|
||||
// Gestaltung: der Knopf steht im selben Block wie die Knöpfe der Anmelde-Plugins
|
||||
[, $lp] = $call('GET', '/staff/login.php', [], '');
|
||||
$check(preg_match('#id="login-alt"[^>]*>\s*<button type="button" id="passkey-login" class="btn secondary"#', $lp) === 1 && !str_contains($lp, 'passkey-box'), 'Anmeldeseite: Passkey-Knopf im Block der Anmeldeknöpfe, gleiche Klassen wie bei authentik');
|
||||
|
||||
// Administrator: einzelnen Passkey entfernen und Karte in der Bearbeiten-Ansicht
|
||||
$k1 = new FakeKey('ec'); $k2 = new FakeKey('ec');
|
||||
$addPasskey($jarMax, $k1, 'Erster'); $addPasskey($jarMax, $k2, 'Zweiter');
|
||||
[, $edit] = $call('GET', '/staff/staff_manage.php?edit=' . $maxId, [], $jarChef);
|
||||
$check(str_contains($edit, 'Passkeys von Max') && str_contains($edit, 'Erster') && str_contains($edit, 'Zweiter') && str_contains($edit, 'Alle Passkeys entfernen'), 'Bearbeiten-Ansicht zeigt die Passkeys des Mitarbeiters mit Entfernen');
|
||||
$firstId = (int) $pdo()->query("SELECT id FROM staff_passkeys WHERE staff_id = {$maxId} AND name = 'Erster'")->fetchColumn();
|
||||
$t = $csrf('/staff/staff_manage.php?edit=' . $maxId, $jarChef);
|
||||
[$s] = $call('POST', '/staff/staff_manage.php', ['csrf_token' => $t, 'action' => 'remove_passkey', 'id' => $maxId, 'passkey_id' => $firstId], $jarChef);
|
||||
$left = $pdo()->query("SELECT name FROM staff_passkeys WHERE staff_id = {$maxId}")->fetchAll(PDO::FETCH_COLUMN);
|
||||
$check($s === 302 && !in_array('Erster', $left, true) && in_array('Zweiter', $left, true) && count($left) === 2, 'Administrator entfernt einen einzelnen Passkey, die anderen bleiben');
|
||||
$evaOther = $pdo()->query("SELECT COUNT(*) FROM staff_passkeys WHERE staff_id = {$evaId}")->fetchColumn();
|
||||
$call('POST', '/staff/staff_manage.php', ['csrf_token' => $t, 'action' => 'remove_passkey', 'id' => $evaId, 'passkey_id' => (int) $pdo()->query("SELECT id FROM staff_passkeys WHERE staff_id = {$maxId}")->fetchColumn()], $jarChef);
|
||||
$check($count($maxId) === 2, 'Ein Passkey lässt sich nur über den richtigen Mitarbeiter entfernen');
|
||||
[$s, , $hdr] = $call('POST', '/staff/staff_manage.php', ['csrf_token' => $t, 'action' => 'remove_passkeys', 'id' => $maxId, 'back' => 'edit'], $jarChef);
|
||||
$check($s === 302 && $count($maxId) === 0 && str_contains($hdr, 'edit=' . $maxId), 'Alle entfernen aus der Bearbeiten-Ansicht führt dorthin zurück');
|
||||
|
||||
// Schalter: Passkeys ausschalten
|
||||
$kk = new FakeKey('ec');
|
||||
$addPasskey($jarMax, $kk, 'Handy');
|
||||
$t = $csrf('/staff/login_log.php', $jarChef);
|
||||
[, $lg] = $call('GET', '/staff/login_log.php', [], $jarChef);
|
||||
$check(str_contains($lg, 'name="passkeys_enabled"') && preg_match('/name="passkeys_enabled"[^>]*checked/', $lg) === 1, 'Anmelde-Wächter hat den Schalter „Passkeys erlauben“ (an)');
|
||||
$call('POST', '/staff/login_log.php', ['csrf_token' => $t, 'action' => 'save', 'login_guard' => '1', 'login_guard_max_attempts' => '5', 'login_guard_lock_minutes' => '15', 'login_guard_retention_days' => '180'], $jarChef);
|
||||
[, $lp] = $call('GET', '/staff/login.php', [], '');
|
||||
$check(!str_contains($lp, 'id="passkey-login"'), 'Ausgeschaltet: kein Passkey-Knopf auf der Anmeldeseite');
|
||||
[$jar, $s, $r] = $passkeyLogin($kk, ['count' => 5]);
|
||||
$check($s === 403 && !$loggedIn($jar), 'Ausgeschaltet: Anmeldung mit Passkey wird abgelehnt (HTTP ' . $s . ')');
|
||||
@unlink($jar);
|
||||
[, $page] = $call('GET', '/staff/profile.php?tab=security', [], $jarMax);
|
||||
$check(str_contains($page, 'zurzeit ausgeschaltet'), 'Ausgeschaltet: Profil weist darauf hin');
|
||||
$check($count($maxId) === 1, 'Ausgeschaltet: vorhandene Passkeys bleiben gespeichert');
|
||||
$call('POST', '/staff/login_log.php', ['csrf_token' => $t, 'action' => 'save', 'login_guard' => '1', 'passkeys_enabled' => '1', 'login_guard_max_attempts' => '5', 'login_guard_lock_minutes' => '15', 'login_guard_retention_days' => '180'], $jarChef);
|
||||
[$jar, $s] = $passkeyLogin($kk, ['count' => 6]);
|
||||
$check($s === 200 && $loggedIn($jar), 'Wieder eingeschaltet: derselbe Passkey funktioniert wieder');
|
||||
@unlink($jar);
|
||||
foreach ([$jarEva, $jarChef, $jarMax] as $j) @unlink($j);
|
||||
} finally {
|
||||
if (is_resource($webProc)) { proc_terminate($webProc); proc_close($webProc); }
|
||||
$rm = function (string $d) use (&$rm): void { foreach (glob($d . '/{,.}*', GLOB_BRACE) ?: [] as $e) { $b = basename($e); if ($b === '.' || $b === '..') continue; is_dir($e) ? $rm($e) : @unlink($e); } @rmdir($d); };
|
||||
$rm($work);
|
||||
}
|
||||
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo "Passkey web checks passed ({$total} Prüfungen).\n";
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft den Rechte-Katalog (includes/permissions.php) und has_permission(): vollständig und eindeutig, Standardwerte, Rückfall für Rechte, die in älteren
|
||||
* Rollen fehlen, Rolle pro Abteilung, deaktivierte Rollen und die Rollen-Vorlagen.
|
||||
*/
|
||||
|
||||
putenv('DESKHUB_TESTING=1');
|
||||
putenv('DESKHUB_SQLITE_PATH=:memory:');
|
||||
require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
||||
|
||||
$failures = [];
|
||||
$total = 0;
|
||||
$check = function (bool $ok, string $what) use (&$failures, &$total): void {
|
||||
$total++;
|
||||
if (!$ok) $failures[] = $what;
|
||||
};
|
||||
|
||||
// ── Katalog ────────────────────────────────────────────────────────────────────
|
||||
$catalog = permission_catalog();
|
||||
$all = [];
|
||||
$dupes = [];
|
||||
foreach ($catalog as $g) {
|
||||
$check(!empty($g['title']) && !empty($g['items']), 'Gruppe hat Titel und Rechte');
|
||||
foreach ($g['items'] as $k => $i) {
|
||||
if (isset($all[$k])) $dupes[] = $k;
|
||||
$all[$k] = $i;
|
||||
$check(preg_match('/^[a-z_]+$/', $k) === 1 && $i['label'] !== '' && $i['hint'] !== '' && is_bool($i['agent']) && is_bool($i['area']), "Recht {$k}: Schlüssel, Beschriftung, Hinweis und Flags");
|
||||
}
|
||||
}
|
||||
$check($dupes === [], 'Kein Recht kommt doppelt vor: ' . implode(',', $dupes));
|
||||
$check(array_keys(permission_labels()) === array_keys($all), 'permission_labels() entspricht dem Katalog');
|
||||
|
||||
// Alle bisherigen Rechte gibt es weiterhin (gespeicherte Rollen bleiben gültig)
|
||||
$old = ['reply_ticket', 'close_ticket', 'delete_ticket', 'transfer_ticket', 'post_note', 'assign_ticket', 'manage_canned', 'view_reports', 'manage_kb', 'create_ticket',
|
||||
'manage_departments', 'manage_priorities', 'manage_help_topics', 'manage_email_addresses', 'manage_email_templates', 'manage_organizations', 'manage_teams', 'manage_roles',
|
||||
'manage_staff', 'view_audit_log', 'manage_escalations', 'view_time_entries', 'manage_business_hours', 'manage_assistant'];
|
||||
$check(array_diff($old, array_keys($all)) === [], 'Alle bisherigen Rechte sind im Katalog');
|
||||
$check(count($all) >= 40, 'Der Katalog hat die neuen Rechte (' . count($all) . ')');
|
||||
|
||||
// Neue Rechte, die es bisher gar nicht gab
|
||||
foreach (['merge_split_ticket', 'link_tickets', 'manage_banlist', 'export_tickets', 'fetch_mail', 'share_views', 'schedule_reports', 'view_customers', 'manage_customers', 'manage_custom_fields',
|
||||
'manage_ticket_templates', 'manage_automation', 'manage_site_pages', 'manage_languages', 'manage_status_page', 'view_mail_queue', 'view_login_guard', 'manage_problems', 'manage_availability', 'view_help'] as $k) {
|
||||
$check(isset($all[$k]), "Neues Recht {$k} vorhanden");
|
||||
}
|
||||
|
||||
// Adminportal-Bereiche
|
||||
$areas = admin_section_permissions();
|
||||
$check(isset($areas['manage_canned'], $areas['manage_kb'], $areas['view_help'], $areas['view_login_guard']) && !isset($areas['reply_ticket'], $areas['manage_banlist'], $areas['export_tickets']), 'admin_section_permissions(): Bereiche ja, Ticket-Rechte nein');
|
||||
foreach (['settings', 'backup', 'update', 'plugins', 'api_keys', 'webhooks', 'import', 'security_check', 'passkeys'] as $needle) {
|
||||
$hit = array_filter(array_keys($all), fn($k) => str_contains($k, $needle));
|
||||
$check($hit === [], "Nicht delegierbar: kein Recht für {$needle}");
|
||||
}
|
||||
|
||||
// ── Standardwerte ──────────────────────────────────────────────────────────────
|
||||
$adm = default_permissions('admin');
|
||||
$agt = default_permissions('agent');
|
||||
$check(count(array_filter($adm)) === count($all), 'Administrator: alle Rechte');
|
||||
foreach (['reply_ticket', 'close_ticket', 'transfer_ticket', 'post_note', 'assign_ticket', 'create_ticket', 'view_reports', 'merge_split_ticket', 'link_tickets', 'manage_banlist', 'fetch_mail', 'share_views', 'schedule_reports', 'view_customers'] as $k) {
|
||||
$check($agt[$k] === true, "Standard-Mitarbeiter darf weiterhin: {$k}");
|
||||
}
|
||||
foreach (['delete_ticket', 'export_tickets'] as $k) $check($agt[$k] === false, "Standard-Mitarbeiter darf nicht: {$k}");
|
||||
foreach ($areas as $k => $_l) $check($agt[$k] === false, "Adminportal-Bereich standardmäßig aus: {$k}");
|
||||
|
||||
// ── has_permission ─────────────────────────────────────────────────────────────
|
||||
$pdo = get_db();
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$dept = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
|
||||
$pdo->prepare('INSERT INTO departments (name, email, is_active, created_at) VALUES (?,?,1,?)')->execute(['Zweite', 'zweite@test.de', $now]);
|
||||
$dept2 = (int) $pdo->lastInsertId();
|
||||
$pdo->exec('DELETE FROM staff');
|
||||
$mkRole = function (string $name, array $perms, int $active = 1) use ($pdo, $now): int {
|
||||
$pdo->prepare('INSERT INTO roles (name, permissions, is_active, created_at, updated_at) VALUES (?,?,?,?,?)')->execute([$name, json_encode($perms), $active, $now, $now]);
|
||||
return (int) $pdo->lastInsertId();
|
||||
};
|
||||
$mkStaff = function (string $mail, string $role, ?int $roleId) use ($pdo, $dept, $now): array {
|
||||
$pdo->prepare('INSERT INTO staff (name, email, password_hash, role, role_id, department_id, is_active, created_at) VALUES (?,?,?,?,?,?,1,?)')->execute([$mail, $mail . '@test.de', 'x', $role, $roleId, $dept, $now]);
|
||||
return $pdo->query('SELECT * FROM staff WHERE id = ' . (int) $pdo->lastInsertId())->fetch();
|
||||
};
|
||||
$admin = $mkStaff('chef', 'admin', null);
|
||||
$plain = $mkStaff('eva', 'agent', null);
|
||||
$check(has_permission($admin, 'manage_staff') && has_permission($admin, 'gibt_es_nicht'), 'Administrator hat immer alles');
|
||||
$check(has_permission($plain, 'reply_ticket') && has_permission($plain, 'merge_split_ticket') && !has_permission($plain, 'delete_ticket') && !has_permission($plain, 'manage_custom_fields'), 'Mitarbeiter ohne Rolle: Standardrechte');
|
||||
$check(!has_permission($plain, 'gibt_es_nicht'), 'Unbekanntes Recht: nein');
|
||||
|
||||
// Alte Rolle (gespeichert vor den neuen Rechten): neue Rechte gelten mit dem Standardwert
|
||||
$oldJson = array_fill_keys($old, false);
|
||||
$oldJson['reply_ticket'] = true;
|
||||
$oldJson['manage_departments'] = true;
|
||||
$roleOld = $mkRole('Alt', $oldJson);
|
||||
$s1 = $mkStaff('alt', 'agent', $roleOld);
|
||||
$check(has_permission($s1, 'reply_ticket') && has_permission($s1, 'manage_departments') && !has_permission($s1, 'close_ticket'), 'Alte Rolle: gespeicherte Werte gelten (auch „aus“)');
|
||||
$check(has_permission($s1, 'merge_split_ticket') && has_permission($s1, 'manage_banlist') && has_permission($s1, 'fetch_mail'), 'Alte Rolle: bisher offene Funktionen bleiben erlaubt (Standardwert)');
|
||||
$check(!has_permission($s1, 'manage_custom_fields') && !has_permission($s1, 'view_help') && !has_permission($s1, 'export_tickets'), 'Alte Rolle: neue Adminbereiche bleiben aus');
|
||||
// Altes Recht fehlt in einer gespeicherten Rolle: wie bisher „nein“
|
||||
$roleGap = $mkRole('Lücke', ['reply_ticket' => true]);
|
||||
$sg = $mkStaff('luecke', 'agent', $roleGap);
|
||||
$check(has_permission($sg, 'reply_ticket') && !has_permission($sg, 'post_note') && !has_permission($sg, 'close_ticket'), 'Fehlendes altes Recht in einer Rolle: nein (wie bisher)');
|
||||
$check(array_diff(permission_legacy_keys(), array_keys($all)) === [] && count(permission_legacy_keys()) === 24, 'Liste der alten Rechte ist vollständig');
|
||||
$eff = permission_effective(['reply_ticket' => true]);
|
||||
$check($eff['reply_ticket'] && !$eff['post_note'] && $eff['merge_split_ticket'] && !$eff['manage_custom_fields'] && count($eff) === count($all), 'permission_effective(): wie has_permission() für fehlende Rechte');
|
||||
// Neue Rolle mit ausdrücklich „aus“
|
||||
$roleNew = $mkRole('Neu', ['merge_split_ticket' => false, 'manage_custom_fields' => true]);
|
||||
$s2 = $mkStaff('neu', 'agent', $roleNew);
|
||||
$check(!has_permission($s2, 'merge_split_ticket') && has_permission($s2, 'manage_custom_fields'), 'Ausdrücklich gesetzte Werte schlagen den Standard');
|
||||
// Deaktivierte Rolle
|
||||
$roleOff = $mkRole('Aus', ['reply_ticket' => false, 'manage_custom_fields' => true], 0);
|
||||
$s3 = $mkStaff('aus', 'agent', $roleOff);
|
||||
$check(has_permission($s3, 'reply_ticket') && !has_permission($s3, 'manage_custom_fields'), 'Deaktivierte Rolle: Standardrechte');
|
||||
// Rolle pro Abteilung
|
||||
$pdo->prepare('INSERT INTO staff_department (staff_id, department_id, notify, role_id) VALUES (?,?,?,?)')->execute([$s1['id'], $dept2, 1, $roleNew]);
|
||||
$check(has_permission($s1, 'manage_custom_fields', $dept2) && !has_permission($s1, 'manage_custom_fields', $dept), 'Rolle pro Abteilung gilt nur in dieser Abteilung');
|
||||
|
||||
// ── Vorlagen ───────────────────────────────────────────────────────────────────
|
||||
$presets = permission_presets();
|
||||
foreach ($presets as $key => [$name, $desc, $perms]) {
|
||||
$check(array_keys($perms) === array_keys($all) || count(array_diff(array_keys($all), array_keys($perms))) === 0, "Vorlage {$key} nennt jedes Recht");
|
||||
$check(array_diff(array_keys($perms), array_keys($all)) === [], "Vorlage {$key} nennt keine unbekannten Rechte");
|
||||
}
|
||||
$check($presets['mitarbeiter'][2] == $agt, 'Vorlage „Mitarbeiter“ entspricht den Standardrechten');
|
||||
$check(!$presets['nur_lesen'][2]['reply_ticket'] && $presets['nur_lesen'][2]['view_customers'], 'Vorlage „Nur lesen“ darf nicht antworten');
|
||||
$check($presets['teamleitung'][2]['delete_ticket'] && !$presets['teamleitung'][2]['manage_staff'], 'Vorlage „Teamleitung“: löschen ja, Mitarbeiter verwalten nein');
|
||||
$check($presets['support_manager'][2]['manage_staff'] && !$presets['support_manager'][2]['manage_roles'], 'Vorlage „Support-Manager“: Mitarbeiter ja, Rollen nein');
|
||||
|
||||
echo $failures ? "FEHLGESCHLAGEN:\n - " . implode("\n - ", $failures) . "\n" : '';
|
||||
echo "Rechte-Test: " . ($total - count($failures)) . " von {$total} Prüfungen in Ordnung.\n";
|
||||
exit($failures ? 1 : 0);
|
||||
@@ -0,0 +1,285 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft die Rechte über die echte Weboberfläche und die App-Schnittstelle: Jede delegierbare Seite ist mit genau ihrem Recht erreichbar und ohne gesperrt,
|
||||
* Bereiche nur für Administratoren bleiben auch mit allen Rechten zu, Menü, Sammelaktionen, Verknüpfen, Teilen, Rollen (keine Hochstufung), App-Schnittstelle.
|
||||
*/
|
||||
|
||||
if (!extension_loaded('pdo_sqlite') || !function_exists('curl_init')) { echo "Abmelde-Web-Test übersprungen: pdo_sqlite oder curl fehlt.\n"; exit(0); }
|
||||
|
||||
date_default_timezone_set('Europe/Berlin');
|
||||
$root = dirname(__DIR__);
|
||||
$work = sys_get_temp_dir() . '/deskhub-perm-' . bin2hex(random_bytes(4));
|
||||
$tmp = $work . '/inst';
|
||||
mkdir($tmp, 0755, true);
|
||||
$webPort = random_int(36500, 36999);
|
||||
$skip = '#^(config/config\.php|database/[^/]+\.sqlite[^/]*|backups|uploads|tests|plugins/[^/]+|\.git)(/|$)#';
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST) as $f) {
|
||||
$rel = str_replace(chr(92), '/', substr($f->getPathname(), strlen($root) + 1));
|
||||
if (preg_match($skip, $rel) && !preg_match('#^(backups|uploads|plugins)/(\.htaccess|README\.md)$#', $rel)) continue;
|
||||
if ($f->isDir()) { @mkdir($tmp . '/' . $rel, 0755, true); continue; }
|
||||
@mkdir(dirname($tmp . '/' . $rel), 0755, true);
|
||||
copy($f->getPathname(), $tmp . '/' . $rel);
|
||||
}
|
||||
@mkdir($tmp . '/uploads');
|
||||
@mkdir($tmp . '/config');
|
||||
file_put_contents($tmp . '/config/config.php', '<?php return ' . var_export([
|
||||
'app_key' => bin2hex(random_bytes(32)),
|
||||
'db' => ['driver' => 'sqlite', 'sqlite_path' => $tmp . '/database/deskhub.sqlite'],
|
||||
'site' => ['name' => 'Test', 'url' => 'http://127.0.0.1', 'admin_email' => 'a@test.de', 'timezone' => 'Europe/Berlin'],
|
||||
'uploads' => ['max_size_bytes' => 10485760, 'allowed_extensions' => ['png'], 'path' => $tmp . '/uploads'],
|
||||
], true) . ';');
|
||||
|
||||
putenv('DESKHUB_CONFIG_JSON');
|
||||
putenv('DESKHUB_SQLITE_PATH');
|
||||
putenv('DESKHUB_TESTING');
|
||||
putenv('DESKHUB_API_ALLOW_INSECURE=1');
|
||||
require_once $root . '/database/sqlite_schema.php';
|
||||
require_once $root . '/database/migrate.php';
|
||||
$db = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
|
||||
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
$db->exec('PRAGMA foreign_keys = ON');
|
||||
$db->exec(convert_schema_to_sqlite((string) file_get_contents($root . '/database/schema.sql')));
|
||||
run_migrations($db, true);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$dept = (int) $db->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
|
||||
$db->exec('DELETE FROM staff');
|
||||
$ins = $db->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)');
|
||||
foreach ([['Chef', 'chef@test.de', 'admin'], ['Eva', 'eva@test.de', 'agent'], ['Max', 'max@test.de', 'agent']] as [$n, $e, $r]) {
|
||||
$ins->execute([$n, $e, password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT), $r, $dept, $now]);
|
||||
}
|
||||
$evaId = (int) $db->query("SELECT id FROM staff WHERE email = 'eva@test.de'")->fetchColumn();
|
||||
$db = null;
|
||||
|
||||
$failures = [];
|
||||
$total = 0;
|
||||
$check = function (bool $ok, string $what) use (&$failures, &$total): void { $total++; if (!$ok) $failures[] = $what; };
|
||||
$webProc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$webPort", '-t', $tmp], [['pipe', 'r'], ['file', $work . '/web.out', 'w'], ['file', $work . '/web.out', 'a']], $wp, $tmp);
|
||||
|
||||
try {
|
||||
for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $webPort)) break; usleep(100000); }
|
||||
$pdo = function () use ($tmp): PDO {
|
||||
$p = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
|
||||
$p->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$p->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
return $p;
|
||||
};
|
||||
$call = function (string $method, string $path, array $form, string $jar, ?string $bearer = null, ?array $json = null) use ($webPort): array {
|
||||
$ch = curl_init("http://127.0.0.1:$webPort$path");
|
||||
$h = ['Sec-Fetch-Mode: navigate'];
|
||||
if ($json !== null) $h[] = 'Content-Type: application/json';
|
||||
if ($bearer) $h[] = 'Authorization: Bearer ' . $bearer;
|
||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 60, CURLOPT_HEADER => true, CURLOPT_HTTPHEADER => $h]);
|
||||
if ($jar !== '') { curl_setopt($ch, CURLOPT_COOKIEJAR, $jar); curl_setopt($ch, CURLOPT_COOKIEFILE, $jar); }
|
||||
if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $json !== null ? json_encode($json) : http_build_query($form)); }
|
||||
$raw = (string) curl_exec($ch);
|
||||
$s = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$hs = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||
curl_close($ch);
|
||||
return [$s, substr($raw, $hs), substr($raw, 0, $hs)];
|
||||
};
|
||||
$login = function (string $email) use ($call): string {
|
||||
$jar = tempnam(sys_get_temp_dir(), 'sow');
|
||||
[, $lp] = $call('GET', '/staff/login.php', [], $jar);
|
||||
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $lp, $m);
|
||||
$call('POST', '/staff/login.php', ['csrf_token' => $m[1] ?? '', 'email' => $email, 'password' => 'Sehr-Sicher-1234'], $jar);
|
||||
return $jar;
|
||||
};
|
||||
$csrf = function (string $path, string $jar) use ($call): string {
|
||||
[, $p] = $call('GET', $path, [], $jar);
|
||||
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $p, $mm);
|
||||
return $mm[1] ?? '';
|
||||
};
|
||||
$loggedIn = function (string $jar) use ($call): bool {
|
||||
[$s, $b] = $call('GET', '/staff/profile.php?tab=security', [], $jar);
|
||||
return $s === 200 && str_contains($b, 'Von allen Geräten abmelden');
|
||||
};
|
||||
$apiToken = function (string $email) use ($call): string {
|
||||
[, $b] = $call('POST', '/api/staff/v1/login.php', [], '', null, ['email' => $email, 'password' => 'Sehr-Sicher-1234', 'deviceName' => 'Test-Gerät']);
|
||||
return (string) (json_decode($b, true)['token'] ?? '');
|
||||
};
|
||||
$apiOk = fn(string $tok) => $call('GET', '/api/staff/v1/me.php', [], '', $tok)[0];
|
||||
|
||||
require_once $root . '/includes/permissions.php';
|
||||
$all = array_keys(permission_labels());
|
||||
$maxId = (int) $pdo()->query("SELECT id FROM staff WHERE email = 'max@test.de'")->fetchColumn();
|
||||
$evaId = (int) $pdo()->query("SELECT id FROM staff WHERE email = 'eva@test.de'")->fetchColumn();
|
||||
$none = array_fill_keys($all, false);
|
||||
// Max bekommt eine Rolle mit genau diesen Rechten (Rolle wird wiederverwendet)
|
||||
$setRole = function (array $on, string $who = 'max@test.de') use ($pdo, $none): void {
|
||||
$p = $pdo();
|
||||
$perms = array_merge($none, array_fill_keys($on, true));
|
||||
$rid = (int) $p->query("SELECT id FROM roles WHERE name = 'T-" . ($who === 'max@test.de' ? 'Max' : 'Eva') . "'")->fetchColumn();
|
||||
if (!$rid) { $p->prepare("INSERT INTO roles (name, permissions, is_active, created_at, updated_at) VALUES (?,?,1,?,?)")->execute(['T-' . ($who === 'max@test.de' ? 'Max' : 'Eva'), '{}', date('Y-m-d H:i:s'), date('Y-m-d H:i:s')]); $rid = (int) $p->lastInsertId(); }
|
||||
$p->prepare('UPDATE roles SET permissions = ? WHERE id = ?')->execute([json_encode($perms), $rid]);
|
||||
$p->prepare('UPDATE staff SET role_id = ? WHERE email = ?')->execute([$rid, $who]);
|
||||
};
|
||||
$only = fn(string ...$k) => $k;
|
||||
$allExcept = fn(string $k) => array_values(array_diff($all, [$k]));
|
||||
$jarChef = $login('chef@test.de');
|
||||
$jarMax = $login('max@test.de');
|
||||
|
||||
// ── Seiten: mit genau dem Recht ja, ohne dieses Recht (alle anderen an) nein ───────────────
|
||||
$pages = [
|
||||
['/staff/canned_responses.php', 'manage_canned'], ['/staff/custom_fields.php', 'manage_custom_fields'], ['/staff/custom_lists.php', 'manage_custom_fields'],
|
||||
['/staff/ticket_templates.php', 'manage_ticket_templates'], ['/staff/ticket_rules.php', 'manage_automation'], ['/staff/recurring_tickets.php', 'manage_automation'],
|
||||
['/staff/site_pages.php', 'manage_site_pages'], ['/staff/languages.php', 'manage_languages'], ['/staff/status_page.php', 'manage_status_page'],
|
||||
['/staff/mail_queue.php', 'view_mail_queue'], ['/staff/login_log.php', 'view_login_guard'], ['/staff/problems.php', 'manage_problems'], ['/staff/help.php', 'view_help'],
|
||||
['/staff/export.php?type=tickets', 'export_tickets'], ['/staff/customers.php', 'manage_customers'], ['/staff/banlist.php', 'manage_banlist'],
|
||||
['/staff/departments.php', 'manage_departments'], ['/staff/staff_manage.php', 'manage_staff'], ['/staff/roles.php', 'manage_roles'], ['/staff/audit_log.php', 'view_audit_log'],
|
||||
];
|
||||
foreach ($pages as [$url, $perm]) {
|
||||
$setRole($only($perm));
|
||||
[$s] = $call('GET', $url, [], $jarMax);
|
||||
$with = $s;
|
||||
$setRole($allExcept($perm));
|
||||
[$s2] = $call('GET', $url, [], $jarMax);
|
||||
$check($with === 200 && $s2 === 302, "{$url}: mit „{$perm}“ erreichbar (HTTP {$with}), ohne dieses Recht gesperrt (HTTP {$s2})");
|
||||
}
|
||||
// Nur für echte Administratoren: auch mit allen Rechten gesperrt
|
||||
$setRole($all);
|
||||
foreach (['settings.php', 'backup.php', 'update.php', 'plugins.php', 'api_keys.php', 'webhooks.php', 'import_osticket.php', 'security_check.php'] as $f) {
|
||||
[$s] = $call('GET', '/staff/' . $f, [], $jarMax);
|
||||
$check($s === 302, "/staff/{$f}: bleibt echten Administratoren vorbehalten, auch mit allen Rechten (HTTP {$s})");
|
||||
[$s] = $call('GET', '/staff/' . $f, [], $jarChef);
|
||||
$check($s === 200, "/staff/{$f}: Administrator kommt hinein (HTTP {$s})");
|
||||
}
|
||||
|
||||
// ── Menü ──────────────────────────────────────────────────────────────────────
|
||||
$setRole($only('manage_custom_fields'));
|
||||
[, $nav] = $call('GET', '/staff/profile.php', [], $jarMax);
|
||||
$check(str_contains($nav, 'href="/staff/custom_fields.php"') && str_contains($nav, 'id="admin-block"') && !str_contains($nav, 'href="/staff/api_keys.php"') && !str_contains($nav, 'href="/staff/canned_responses.php"') && !str_contains($nav, 'href="/staff/help.php"') && !str_contains($nav, 'href="/staff/banlist.php"'), 'Menü zeigt nur, wofür das Recht da ist');
|
||||
$setRole([]);
|
||||
[, $nav] = $call('GET', '/staff/profile.php', [], $jarMax);
|
||||
$check(!str_contains($nav, 'id="admin-block"'), 'Ohne Adminportal-Rechte gibt es das Adminportal im Menü nicht');
|
||||
$setRole($all);
|
||||
[, $nav] = $call('GET', '/staff/profile.php', [], $jarMax);
|
||||
$check(!str_contains($nav, 'href="/staff/api_keys.php"') && !str_contains($nav, 'href="/staff/settings.php"') && str_contains($nav, 'href="/staff/help.php"'), 'Auch mit allen Rechten fehlen die Administrator-Bereiche im Menü');
|
||||
|
||||
// ── Aktionen ──────────────────────────────────────────────────────────────────
|
||||
$p = $pdo();
|
||||
$dept = (int) $p->query('SELECT department_id FROM staff WHERE id = ' . $maxId)->fetchColumn();
|
||||
$prio = (int) $p->query('SELECT id FROM priorities ORDER BY id LIMIT 1')->fetchColumn();
|
||||
$mk = function (string $n) use ($pdo, $dept, $prio): int {
|
||||
$q = $pdo(); $now = date('Y-m-d H:i:s');
|
||||
$q->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)')->execute([$n, 'Betreff ' . $n, 'K', 'k@example.org', $dept, $prio, 'open', 'web', $now, $now]);
|
||||
return (int) $q->lastInsertId();
|
||||
};
|
||||
$exists = fn(int $id) => (int) $pdo()->query("SELECT COUNT(*) FROM tickets WHERE id = {$id}")->fetchColumn() === 1;
|
||||
// Sammelaktion „Löschen“
|
||||
$t1 = $mk('P-1'); $t2 = $mk('P-2');
|
||||
$setRole(array_values(array_diff($all, ['delete_ticket'])));
|
||||
$tok = $csrf('/staff/tickets.php', $jarMax);
|
||||
$call('POST', '/staff/tickets.php', ['csrf_token' => $tok, 'bulk_action' => 'delete', 'ticket_ids' => [$t1]], $jarMax);
|
||||
$check($exists($t1), 'Sammelaktion „Löschen“ ohne das Recht löscht nichts');
|
||||
$setRole($only('delete_ticket'));
|
||||
$tok = $csrf('/staff/tickets.php', $jarMax);
|
||||
$call('POST', '/staff/tickets.php', ['csrf_token' => $tok, 'bulk_action' => 'delete', 'ticket_ids' => [$t1]], $jarMax);
|
||||
$check(!$exists($t1), 'Mit dem Recht „Tickets löschen“ wird gelöscht');
|
||||
// Sammelaktion „Absender sperren“
|
||||
$t3 = $mk('P-3');
|
||||
$setRole($allExcept('manage_banlist'));
|
||||
$tok = $csrf('/staff/tickets.php', $jarMax);
|
||||
$call('POST', '/staff/tickets.php', ['csrf_token' => $tok, 'bulk_action' => 'ban', 'ticket_ids' => [$t3]], $jarMax);
|
||||
$check((int) $pdo()->query("SELECT COUNT(*) FROM banlist")->fetchColumn() === 0, 'Absender sperren ohne das Recht: nichts gesperrt');
|
||||
$setRole($only('manage_banlist', 'close_ticket'));
|
||||
$tok = $csrf('/staff/tickets.php', $jarMax);
|
||||
$call('POST', '/staff/tickets.php', ['csrf_token' => $tok, 'bulk_action' => 'ban', 'ticket_ids' => [$t3]], $jarMax);
|
||||
$check((int) $pdo()->query("SELECT COUNT(*) FROM banlist")->fetchColumn() >= 1, 'Mit den Rechten wird gesperrt');
|
||||
// Sammelaktion „Zuweisen“
|
||||
$t4 = $mk('P-4');
|
||||
$setRole($allExcept('assign_ticket'));
|
||||
$tok = $csrf('/staff/tickets.php', $jarMax);
|
||||
$call('POST', '/staff/tickets.php', ['csrf_token' => $tok, 'bulk_action' => 'assign', 'ticket_ids' => [$t4], 'bulk_assigned_staff_id' => (string) $evaId], $jarMax);
|
||||
$check($pdo()->query("SELECT assigned_staff_id FROM tickets WHERE id = {$t4}")->fetchColumn() === null, 'Zuweisen per Sammelaktion ohne das Recht: unverändert');
|
||||
$setRole($only('assign_ticket'));
|
||||
$tok = $csrf('/staff/tickets.php', $jarMax);
|
||||
$call('POST', '/staff/tickets.php', ['csrf_token' => $tok, 'bulk_action' => 'assign', 'ticket_ids' => [$t4], 'bulk_assigned_staff_id' => (string) $evaId], $jarMax);
|
||||
$check((int) $pdo()->query("SELECT assigned_staff_id FROM tickets WHERE id = {$t4}")->fetchColumn() === $evaId, 'Mit dem Recht wird zugewiesen');
|
||||
// Verknüpfen
|
||||
$links = fn() => (int) $pdo()->query('SELECT COUNT(*) FROM ticket_links')->fetchColumn();
|
||||
$setRole($allExcept('link_tickets'));
|
||||
$tok = $csrf('/staff/ticket_view.php?n=P-2', $jarMax);
|
||||
$call('POST', '/staff/ticket_view.php?n=P-2', ['csrf_token' => $tok, 'action' => 'link_add', 'link_number' => 'P-4', 'link_type' => 'related'], $jarMax);
|
||||
$check($links() === 0, 'Verknüpfen ohne das Recht: keine Verknüpfung');
|
||||
$setRole($only('link_tickets'));
|
||||
$tok = $csrf('/staff/ticket_view.php?n=P-2', $jarMax);
|
||||
$call('POST', '/staff/ticket_view.php?n=P-2', ['csrf_token' => $tok, 'action' => 'link_add', 'link_number' => 'P-4', 'link_type' => 'related'], $jarMax);
|
||||
[, $lv] = $call('GET', '/staff/ticket_view.php?n=P-2', [], $jarMax);
|
||||
$check($links() === 1, 'Mit dem Recht wird verknüpft (' . (preg_match('#class="flash [a-z]+"[^>]*>([^<]+)#', $lv, $mm) ? trim($mm[1]) : 'keine Meldung') . ')');
|
||||
// Ansicht teilen
|
||||
$setRole($allExcept('share_views'));
|
||||
$tok = $csrf('/staff/tickets.php', $jarMax);
|
||||
$call('POST', '/staff/tickets.php', ['csrf_token' => $tok, 'queue_action' => 'save', 'queue_name' => 'Nicht geteilt', 'queue_shared' => '1', 'return_query' => ''], $jarMax);
|
||||
$setRole($only('share_views'));
|
||||
$call('POST', '/staff/tickets.php', ['csrf_token' => $tok, 'queue_action' => 'save', 'queue_name' => 'Geteilt', 'queue_shared' => '1', 'return_query' => ''], $jarMax);
|
||||
$sh = $pdo()->query("SELECT name, is_shared FROM saved_queues ORDER BY id")->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
$check(($sh['Nicht geteilt'] ?? null) == 0 && ($sh['Geteilt'] ?? null) == 1, 'Ansichten teilen nur mit dem Recht ' . json_encode($sh));
|
||||
// Anmelde-Wächter: Ansehen ja, Einstellungen nur Administrator
|
||||
$setRole($only('view_login_guard'));
|
||||
$tok = $csrf('/staff/login_log.php', $jarMax);
|
||||
$call('POST', '/staff/login_log.php', ['csrf_token' => $tok, 'action' => 'save', 'login_guard' => '1', 'login_guard_max_attempts' => '9'], $jarMax);
|
||||
$check((string) $pdo()->query("SELECT setting_value FROM settings WHERE setting_key = 'login_guard_max_attempts'")->fetchColumn() !== '9', 'Anmelde-Wächter: Einstellungen ändern bleibt Administratoren vorbehalten');
|
||||
// Hochstufen über Rollen verhindern
|
||||
$setRole($only('manage_roles'));
|
||||
$tok = $csrf('/staff/roles.php', $jarMax);
|
||||
$call('POST', '/staff/roles.php', ['csrf_token' => $tok, 'action' => 'create', 'name' => 'Hochstufung', 'status' => 'active', 'perm_manage_roles' => '1', 'perm_delete_ticket' => '1', 'perm_manage_staff' => '1'], $jarMax);
|
||||
$r = json_decode((string) $pdo()->query("SELECT permissions FROM roles WHERE name = 'Hochstufung'")->fetchColumn(), true) ?: [];
|
||||
$check(($r['manage_roles'] ?? false) === true && ($r['delete_ticket'] ?? true) === false && ($r['manage_staff'] ?? true) === false, 'Ein Co-Admin kann keine Rechte vergeben, die er selbst nicht hat');
|
||||
$tok = $csrf('/staff/roles.php', $jarChef);
|
||||
$call('POST', '/staff/roles.php', ['csrf_token' => $tok, 'action' => 'create', 'name' => 'Vom Admin', 'status' => 'active', 'perm_manage_staff' => '1', 'perm_delete_ticket' => '1'], $jarChef);
|
||||
$r = json_decode((string) $pdo()->query("SELECT permissions FROM roles WHERE name = 'Vom Admin'")->fetchColumn(), true) ?: [];
|
||||
$check(($r['manage_staff'] ?? false) === true && ($r['delete_ticket'] ?? false) === true && count($r) === count($all), 'Ein Administrator kann jedes Recht vergeben; alle Rechte werden gespeichert');
|
||||
[, $rolesPage] = $call('GET', '/staff/roles.php', [], $jarChef);
|
||||
$check(str_contains($rolesPage, 'Rechte-Übersicht') && str_contains($rolesPage, 'Vom Admin'), 'Rollen-Seite: Rechte-Übersicht mit den Rollen');
|
||||
[, $form] = $call('GET', '/staff/roles.php?new=1', [], $jarChef);
|
||||
$check(str_contains($form, 'Vorlage laden') && str_contains($form, 'data-preset="support_manager"') && str_contains($form, 'Tickets zusammenführen und teilen') && str_contains($form, 'name="perm_view_help"'), 'Rollen-Formular: Vorlagen, Gruppen und neue Rechte');
|
||||
|
||||
// Vordefinierte Rollen: Kennzeichnung, Knopf für fehlende, nur Administratoren legen an
|
||||
[, $rp] = $call('GET', '/staff/roles.php', [], $jarChef);
|
||||
$check(str_contains($rp, 'vordefiniert') && str_contains($rp, 'Co-Administrator') && str_contains($rp, 'IT / Technik') && !str_contains($rp, 'Fehlende Rollen anlegen'), 'Rollen-Seite: vordefinierte Rollen sind gekennzeichnet, nichts fehlt');
|
||||
$pdo()->exec("DELETE FROM roles WHERE name = 'Aushilfe / Praktikant'");
|
||||
[, $rp] = $call('GET', '/staff/roles.php', [], $jarChef);
|
||||
$check(str_contains($rp, 'Fehlende Rollen anlegen') && str_contains($rp, 'Aushilfe / Praktikant'), 'Rollen-Seite: fehlende vordefinierte Rolle wird angeboten');
|
||||
$setRole($only('manage_roles'));
|
||||
$tok = $csrf('/staff/roles.php', $jarMax);
|
||||
$call('POST', '/staff/roles.php', ['csrf_token' => $tok, 'action' => 'seed_defaults'], $jarMax);
|
||||
$check((int) $pdo()->query("SELECT COUNT(*) FROM roles WHERE name = 'Aushilfe / Praktikant'")->fetchColumn() === 0, 'Ein Co-Admin kann vordefinierte Rollen nicht anlegen');
|
||||
$tok = $csrf('/staff/roles.php', $jarChef);
|
||||
$call('POST', '/staff/roles.php', ['csrf_token' => $tok, 'action' => 'seed_defaults'], $jarChef);
|
||||
$check((int) $pdo()->query("SELECT COUNT(*) FROM roles WHERE name = 'Aushilfe / Praktikant'")->fetchColumn() === 1, 'Administrator legt die fehlende Rolle wieder an');
|
||||
|
||||
// ── App-Schnittstelle ─────────────────────────────────────────────────────────
|
||||
$setRole($allExcept('fetch_mail'));
|
||||
$tokMax = $apiToken('max@test.de');
|
||||
[$s, $b] = $call('GET', '/api/staff/v1/me.php', [], '', $tokMax);
|
||||
$perm = json_decode($b, true)['staff']['permissions'] ?? [];
|
||||
$check($s === 200 && array_key_exists('merge_split_ticket', $perm) && $perm['fetch_mail'] === false && $perm['view_help'] === true && count($perm) === count($all), 'App-Schnittstelle liefert alle Rechte (' . count($perm) . ')');
|
||||
[$s] = $call('POST', '/api/staff/v1/mail_fetch.php', [], '', $tokMax, []);
|
||||
$check($s === 403, 'App: E-Mail-Abruf ohne das Recht: 403 (HTTP ' . $s . ')');
|
||||
$setRole($only('manage_roles'));
|
||||
[$s, $b] = $call('GET', '/api/staff/v1/roles.php', [], '', $tokMax);
|
||||
$rj = json_decode($b, true) ?: [];
|
||||
$check($s === 200 && count($rj['permissionKeys'] ?? []) === count($all) && isset($rj['permissionLabels']['view_help']) && count($rj['permissionGroups'] ?? []) === 7, 'App: Rollen-Schnittstelle liefert alle Rechte, Beschriftungen und Gruppen');
|
||||
$firstRole = $rj['items'][0] ?? [];
|
||||
$check(count($firstRole['effectivePermissions'] ?? []) === count($all) && array_key_exists('merge_split_ticket', $firstRole['effectivePermissions'] ?? []), 'App: jede Rolle liefert die wirksamen Rechte (fehlende mit Standardwert)');
|
||||
// Eine ältere App schickt neue Rechte nicht mit: sie bleiben unverändert
|
||||
$rid = (int) $pdo()->query("SELECT id FROM roles WHERE name = 'Vom Admin'")->fetchColumn();
|
||||
$tokChef = $apiToken('chef@test.de');
|
||||
$call('POST', '/api/staff/v1/roles.php', [], '', $tokChef, ['action' => 'save', 'id' => $rid, 'name' => 'Vom Admin', 'permissions' => ['reply_ticket' => false, 'manage_staff' => true]]);
|
||||
$r = json_decode((string) $pdo()->query("SELECT permissions FROM roles WHERE id = {$rid}")->fetchColumn(), true) ?: [];
|
||||
$check($r['reply_ticket'] === false && $r['manage_staff'] === true && $r['delete_ticket'] === true, 'Ältere App: nicht mitgeschickte Rechte bleiben unverändert');
|
||||
foreach ([$jarChef, $jarMax] as $j) @unlink($j);
|
||||
} finally {
|
||||
if (is_resource($webProc)) { proc_terminate($webProc); proc_close($webProc); }
|
||||
$rm = function (string $d) use (&$rm): void { foreach (glob($d . '/{,.}*', GLOB_BRACE) ?: [] as $e) { $b = basename($e); if ($b === '.' || $b === '..') continue; is_dir($e) ? $rm($e) : @unlink($e); } @rmdir($d); };
|
||||
$rm($work);
|
||||
}
|
||||
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo "Permission web checks passed ({$total} Prüfungen).\n";
|
||||
@@ -15,6 +15,16 @@ putenv('PLUGIN_SITE_STORAGE=' . $tmp . '/storage');
|
||||
putenv('PLUGIN_SITE_BASE_URL=https://plugins.example.test');
|
||||
putenv('PLUGIN_SITE_TESTING=1');
|
||||
|
||||
$cnf = $tmp . '/cnf';
|
||||
file_put_contents($cnf, "[req]
|
||||
distinguished_name=req_dn
|
||||
[req_dn]
|
||||
");
|
||||
$kp = openssl_pkey_new(['config' => $cnf, 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
|
||||
openssl_pkey_export($kp, $privPem, null, ['config' => $cnf]);
|
||||
file_put_contents($tmp . '/sign.pem', $privPem);
|
||||
putenv('PLUGIN_SITE_SIGNING_KEY=' . $tmp . '/sign.pem');
|
||||
|
||||
require_once __DIR__ . '/fixtures/paths.php';
|
||||
$siteDir = website_dir_or_skip('plugin_website_apps');
|
||||
require_once $siteDir . '/inc/lib.php';
|
||||
@@ -60,6 +70,21 @@ $check(site_app_release('unbekannt') === null, 'unbekannte Plattform liefert nic
|
||||
$p = site_app_platforms();
|
||||
$check(isset($p['wordpress'], $p['chrome']) && $p['chrome']['file'] === 'deskhub-chrome-erweiterung.zip', 'Plattformliste enthält WordPress und Chrome');
|
||||
|
||||
// ── System-Update des Servers ────────────────────────────────────────────────
|
||||
$srv = fn(string $v) => $makeZip(['deskhub/version.php' => "<?php\ndefine('DESKHUB_VERSION', '{$v}');", 'deskhub/index.php' => '<?php // ' . $pad]);
|
||||
$check(site_zip_app_version('server', $srv('1.0.88')) === '1.0.88', 'Server-Paket: Version steht in version.php');
|
||||
$check(site_zip_app_version('server', $makeZip(['a.php' => '<?php // x'])) === null, 'ZIP ohne version.php ist kein DeskHub-Paket');
|
||||
$r = site_publish_app('server', $srv('1.0.88'), '', 'Neu: Plugin-Updates');
|
||||
$check($r['ok'] && $r['version'] === '1.0.88', 'System-Update wird veröffentlicht: ' . json_encode($r));
|
||||
$check(!site_publish_app('server', $makeZip(['a.php' => '<?php // ' . $pad]), '', '')['ok'], 'Paket ohne version.php wird abgelehnt');
|
||||
$check(site_publish_app('server', $srv('1.0.90'), '', '')['ok'] && site_publish_app('server', $srv('1.0.89'), '', '')['ok'], 'weitere Versionen werden angenommen');
|
||||
$vers = array_column(site_app_versions('server'), 'version');
|
||||
$check($vers === ['1.0.90', '1.0.89', '1.0.88'], 'Versionen werden neueste zuerst aufgelistet: ' . implode(',', $vers));
|
||||
$check(site_app_release('server')['version'] === '1.0.90', 'Aktuell ist die höchste Version');
|
||||
$sig = site_sign_update('v1.0.90', str_repeat('a', 64));
|
||||
$check($sig !== '' && openssl_verify("deskhub-update\n1.0.90\n" . str_repeat('a', 64), base64_decode($sig), openssl_pkey_get_details($kp)['key'], OPENSSL_ALGO_SHA256) === 1, 'Signatur passt zur Nachricht, die DeskHub prüft');
|
||||
$check(isset(site_app_platforms()['server']) && site_app_platforms()['server']['ext'] === 'zip', 'Plattformliste enthält den Server');
|
||||
|
||||
// ── Vorschaubild bleibt erhalten ─────────────────────────────────────────────
|
||||
$png = $tmp . '/vorschau.png';
|
||||
file_put_contents($png, base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='));
|
||||
|
||||
@@ -224,6 +224,38 @@ $check($r['ok'] && $r['signed'] && (int) $pdo->query("SELECT COUNT(*) FROM plugi
|
||||
$again = plugin_catalog_install($pdo, 'katalogplugin');
|
||||
$check(!$again['ok'] && str_contains($again['error'], 'bereits'), 'ein bereits installiertes Katalog-Plugin lässt sich nicht noch einmal installieren');
|
||||
$check(!plugin_catalog_install($pdo, 'gibtesnicht')['ok'], 'Plugin außerhalb des Katalogs kann nicht installiert werden');
|
||||
// Update aus dem Katalog mit einem Klick (ohne das Plugin erneut zu suchen): „Alle aktualisieren“ und das Update eines einzelnen Plugins
|
||||
$check(plugin_catalog_update_all($pdo) === ['updated' => [], 'failed' => []], 'Ohne neuere Version im Katalog gibt es nichts zu aktualisieren');
|
||||
$pkg11 = $makeZip(['plugin.json' => $manifest(['slug' => 'katalogplugin', 'name' => 'Aus dem Katalog', 'version' => '1.1.0', 'permissions' => ['ui.menu']]), 'plugin.php' => "<?php plugin_register_menu('Katalog', 'x');"]);
|
||||
$served11 = $served;
|
||||
$served11['plugins'][0] = array_merge($entry, ['version' => '1.1.0', 'downloadUrl' => 'https://plugins.example.de/dl/katalogplugin-1.1.0.zip', 'sha256' => hash_file('sha256', $pkg11), 'signature' => $sign($pkg11, 'katalogplugin', '1.1.0', $privKey)]);
|
||||
$GLOBALS['deskhub_http_get'] = function (string $url, int $max) use (&$served11, $pkg11): array {
|
||||
if (str_contains($url, 'catalog.php')) return [200, json_encode($served11), null];
|
||||
if (str_contains($url, 'katalogplugin-1.1.0.zip')) return [200, file_get_contents($pkg11), null];
|
||||
return [404, null, 'HTTP 404'];
|
||||
};
|
||||
plugin_catalog_cached(true);
|
||||
$ups = plugin_updates_available($pdo);
|
||||
$check(count($ups) === 1 && $ups[0]['slug'] === 'katalogplugin' && $ups[0]['installed'] === '1.0.0' && $ups[0]['available'] === '1.1.0', 'Update 1.0.0 → 1.1.0 wird erkannt');
|
||||
$res = plugin_catalog_update_all($pdo);
|
||||
$check($res['updated'] === ['Aus dem Katalog 1.1.0'] && $res['failed'] === [], 'Alle aktualisieren: Plugin wird mit einem Aufruf auf 1.1.0 gebracht');
|
||||
$check($pdo->query("SELECT version FROM plugins WHERE slug='katalogplugin'")->fetchColumn() === '1.1.0' && plugin_updates_available($pdo) === [], 'Die Version ist 1.1.0 und der Hinweis „Update verfügbar“ verschwindet');
|
||||
// Fehler bei einem Plugin stoppt nichts und wird gemeldet (manipulierte Prüfsumme)
|
||||
$served11['plugins'][0]['version'] = '1.2.0';
|
||||
$served11['plugins'][0]['sha256'] = str_repeat('b', 64);
|
||||
$served11['plugins'][0]['downloadUrl'] = 'https://plugins.example.de/dl/katalogplugin-1.1.0.zip';
|
||||
plugin_catalog_cached(true);
|
||||
$res = plugin_catalog_update_all($pdo);
|
||||
$check($res['updated'] === [] && isset($res['failed']['katalogplugin']) && $pdo->query("SELECT version FROM plugins WHERE slug='katalogplugin'")->fetchColumn() === '1.1.0', 'Fehlerhaftes Update wird gemeldet und lässt die installierte Version unverändert');
|
||||
unset($GLOBALS['deskhub_http_get']);
|
||||
$GLOBALS['deskhub_http_get'] = function (string $url, int $max) use (&$served, &$requests, $catalogPkg): array {
|
||||
$requests[] = $url;
|
||||
if (str_contains($url, 'catalog.php')) return [200, json_encode($served), null];
|
||||
if (str_contains($url, 'katalogplugin-1.0.0.zip')) return [200, file_get_contents($catalogPkg), null];
|
||||
return [404, null, 'HTTP 404'];
|
||||
};
|
||||
plugin_catalog_cached(true);
|
||||
$pdo->exec("UPDATE plugins SET version = '1.0.0' WHERE slug = 'katalogplugin'");
|
||||
plugin_uninstall($pdo, 'katalogplugin'); // für die folgenden Fehlerfall-Tests wieder frei machen
|
||||
$served['plugins'][0]['sha256'] = str_repeat('a', 64);
|
||||
$r = plugin_catalog_install($pdo, 'katalogplugin');
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft „Von allen Geräten abmelden“ (includes/signout.php): App-Anmeldungen, Push, Ticket-Sperren, Browser-Sitzungen (über den Widerrufs-Zeitpunkt),
|
||||
* dass andere Mitarbeiter unberührt bleiben, und das Protokoll.
|
||||
*/
|
||||
|
||||
putenv('DESKHUB_TESTING=1');
|
||||
putenv('DESKHUB_SQLITE_PATH=:memory:');
|
||||
require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
||||
|
||||
$failures = [];
|
||||
$total = 0;
|
||||
$check = function (bool $ok, string $what) use (&$failures, &$total): void {
|
||||
$total++;
|
||||
if (!$ok) $failures[] = $what;
|
||||
};
|
||||
$pdo = get_db();
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$dept = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
|
||||
$prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY id LIMIT 1')->fetchColumn();
|
||||
$pdo->exec('DELETE FROM staff');
|
||||
$ins = $pdo->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)');
|
||||
$ins->execute(['Chef', 'chef@test.de', 'x', 'admin', $dept, $now]);
|
||||
$chef = (int) $pdo->lastInsertId();
|
||||
$ins->execute(['Eva', 'eva@test.de', 'x', 'agent', $dept, $now]);
|
||||
$eva = (int) $pdo->lastInsertId();
|
||||
$ins->execute(['Max', 'max@test.de', 'x', 'agent', $dept, $now]);
|
||||
$max = (int) $pdo->lastInsertId();
|
||||
|
||||
$tok = function (int $sid, string $name, string $hash, ?string $revoked = null) use ($pdo, $now): void {
|
||||
$pdo->prepare('INSERT INTO mobile_api_tokens (staff_id, token_hash, device_name, expires_at, last_used_at, revoked_at, created_at) VALUES (?,?,?,?,?,?,?)')
|
||||
->execute([$sid, $hash, $name, date('Y-m-d H:i:s', time() + 86400), $now, $revoked, $now]);
|
||||
};
|
||||
$tok($eva, 'Android Pixel', str_repeat('a', 64)); $tok($eva, 'Windows-Programm', str_repeat('b', 64)); $tok($eva, 'Chrome', str_repeat('c', 64));
|
||||
$tok($max, 'Max Handy', str_repeat('d', 64));
|
||||
$pdo->prepare('INSERT INTO push_subscriptions (staff_id, endpoint, p256dh, auth) VALUES (?,?,?,?)')->execute([$eva, 'https://push.example/1', 'k', 'a']);
|
||||
$pdo->prepare('INSERT INTO push_subscriptions (staff_id, endpoint, p256dh, auth) VALUES (?,?,?,?)')->execute([$max, 'https://push.example/2', 'k', 'a']);
|
||||
$pdo->prepare('INSERT INTO push_devices (staff_id, token, token_hash, platform, device_name, created_at) VALUES (?,?,?,?,?,?)')->execute([$eva, 'fcm-eva', str_repeat('e', 64), 'android', 'Pixel', $now]);
|
||||
$pdo->prepare('INSERT INTO push_devices (staff_id, token, token_hash, platform, device_name, created_at) VALUES (?,?,?,?,?,?)')->execute([$max, 'fcm-max', str_repeat('f', 64), 'android', 'Max', $now]);
|
||||
$pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, locked_by, locked_at, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)')
|
||||
->execute(['S-1', 'Gesperrt', 'K', 'k@example.org', $dept, $prio, 'open', 'web', $eva, $now, $now, $now]);
|
||||
$pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, locked_by, locked_at, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)')
|
||||
->execute(['S-2', 'Von Max gesperrt', 'K', 'k@example.org', $dept, $prio, 'open', 'web', $max, $now, $now, $now]);
|
||||
|
||||
$check(count(staff_app_logins($pdo, $eva)) === 3 && count(staff_app_logins($pdo, $max)) === 1, 'Vorher: Evas drei App-Anmeldungen sind sichtbar');
|
||||
$tok($eva, 'Abgelaufen', str_repeat('9', 64), $now);
|
||||
$check(count(staff_app_logins($pdo, $eva)) === 3, 'bereits widerrufene Anmeldungen werden nicht angezeigt');
|
||||
|
||||
// Eine Browser-Sitzung, die vor dem Widerruf angemeldet wurde, ist danach ungültig
|
||||
$_SESSION['staff_login_at'] = time() - 600;
|
||||
$check(!login_guard_session_revoked($eva), 'Vorher: Sitzung gültig');
|
||||
$r = staff_sign_out_everywhere($pdo, $eva, $chef);
|
||||
$check($r['apps'] === 4 && $r['browsers'] === 1 && $r['devices'] === 1 && $r['locks'] === 1, 'Zahlen: ' . json_encode($r));
|
||||
$check(login_guard_session_revoked($eva), 'Danach: ältere Browser-Sitzung ist ungültig');
|
||||
$_SESSION['staff_login_at'] = time() + 5;
|
||||
$check(!login_guard_session_revoked($eva), 'eine neue Anmeldung danach ist gültig');
|
||||
$check((int) $pdo->query("SELECT COUNT(*) FROM mobile_api_tokens WHERE staff_id = {$eva}")->fetchColumn() === 0, 'App-Anmeldungen von Eva gelöscht');
|
||||
$check((int) $pdo->query("SELECT COUNT(*) FROM push_subscriptions WHERE staff_id = {$eva}")->fetchColumn() === 0 && (int) $pdo->query("SELECT COUNT(*) FROM push_devices WHERE staff_id = {$eva}")->fetchColumn() === 0, 'Push von Eva gelöscht (Browser und App)');
|
||||
$check($pdo->query("SELECT locked_by FROM tickets WHERE ticket_number = 'S-1'")->fetchColumn() === null, 'Ticket-Sperre von Eva freigegeben');
|
||||
// Andere bleiben unberührt
|
||||
$check((int) $pdo->query("SELECT COUNT(*) FROM mobile_api_tokens WHERE staff_id = {$max}")->fetchColumn() === 1, 'Max behält seine App-Anmeldung');
|
||||
$check((int) $pdo->query("SELECT COUNT(*) FROM push_subscriptions WHERE staff_id = {$max}")->fetchColumn() === 1 && (int) $pdo->query("SELECT COUNT(*) FROM push_devices WHERE staff_id = {$max}")->fetchColumn() === 1, 'Max behält sein Push');
|
||||
$check((int) $pdo->query("SELECT locked_by FROM tickets WHERE ticket_number = 'S-2'")->fetchColumn() === $max, 'Max behält seine Ticket-Sperre');
|
||||
$_SESSION['staff_login_at'] = time() - 600;
|
||||
$check(!login_guard_session_revoked($max), 'Maxens Browser-Sitzung bleibt gültig');
|
||||
// Protokoll
|
||||
$ev = $pdo->query("SELECT * FROM login_events WHERE event = 'signout' AND staff_id = {$eva}")->fetch(PDO::FETCH_ASSOC);
|
||||
$check($ev && str_contains((string) $ev['detail'], 'Administrator'), 'Protokolleintrag „Überall abgemeldet“ mit Hinweis auf den Administrator');
|
||||
$check(isset(login_guard_event_labels()['signout']), 'Ereignisart ist beschriftet');
|
||||
$check((int) $pdo->query("SELECT COUNT(*) FROM admin_audit_log WHERE action LIKE 'Mitarbeiter #{$eva} von allen Geräten abgemeldet%'")->fetchColumn() === 1, 'Audit-Log-Eintrag');
|
||||
// Selbst abmelden
|
||||
$r2 = staff_sign_out_everywhere($pdo, $max, $max);
|
||||
$ev2 = $pdo->query("SELECT detail FROM login_events WHERE event = 'signout' AND staff_id = {$max}")->fetchColumn();
|
||||
$check($r2['apps'] === 1 && str_contains((string) $ev2, 'eigene Veranlassung'), 'eigene Abmeldung wird als solche protokolliert');
|
||||
// Unbekannter Mitarbeiter, zweimal hintereinander
|
||||
$check(staff_sign_out_everywhere($pdo, 999999, $chef)['at'] === 0, 'unbekannter Mitarbeiter: nichts passiert, kein Fehler');
|
||||
$r3 = staff_sign_out_everywhere($pdo, $eva, $chef);
|
||||
$check($r3['apps'] === 0 && $r3['browsers'] === 0, 'zweites Mal: nichts mehr zu beenden, kein Fehler');
|
||||
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo "Sign out everywhere checks passed ({$total} Prüfungen).\n";
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft „Von allen Geräten abmelden“ über die echte Weboberfläche und die Mitarbeiter-API: eigene Abmeldung („alle anderen“ und „auch hier“),
|
||||
* Abmeldung durch einen Administrator, und dass App-Anmeldungen danach nicht mehr gelten.
|
||||
*/
|
||||
|
||||
if (!extension_loaded('pdo_sqlite') || !function_exists('curl_init')) { echo "Abmelde-Web-Test übersprungen: pdo_sqlite oder curl fehlt.\n"; exit(0); }
|
||||
|
||||
date_default_timezone_set('Europe/Berlin');
|
||||
$root = dirname(__DIR__);
|
||||
$work = sys_get_temp_dir() . '/deskhub-sow-' . bin2hex(random_bytes(4));
|
||||
$tmp = $work . '/inst';
|
||||
mkdir($tmp, 0755, true);
|
||||
$webPort = random_int(34500, 34999);
|
||||
$skip = '#^(config/config\.php|database/[^/]+\.sqlite[^/]*|backups|uploads|tests|plugins/[^/]+|\.git)(/|$)#';
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST) as $f) {
|
||||
$rel = str_replace(chr(92), '/', substr($f->getPathname(), strlen($root) + 1));
|
||||
if (preg_match($skip, $rel) && !preg_match('#^(backups|uploads|plugins)/(\.htaccess|README\.md)$#', $rel)) continue;
|
||||
if ($f->isDir()) { @mkdir($tmp . '/' . $rel, 0755, true); continue; }
|
||||
@mkdir(dirname($tmp . '/' . $rel), 0755, true);
|
||||
copy($f->getPathname(), $tmp . '/' . $rel);
|
||||
}
|
||||
@mkdir($tmp . '/uploads');
|
||||
@mkdir($tmp . '/config');
|
||||
file_put_contents($tmp . '/config/config.php', '<?php return ' . var_export([
|
||||
'app_key' => bin2hex(random_bytes(32)),
|
||||
'db' => ['driver' => 'sqlite', 'sqlite_path' => $tmp . '/database/deskhub.sqlite'],
|
||||
'site' => ['name' => 'Test', 'url' => 'http://127.0.0.1', 'admin_email' => 'a@test.de', 'timezone' => 'Europe/Berlin'],
|
||||
'uploads' => ['max_size_bytes' => 10485760, 'allowed_extensions' => ['png'], 'path' => $tmp . '/uploads'],
|
||||
], true) . ';');
|
||||
|
||||
putenv('DESKHUB_CONFIG_JSON');
|
||||
putenv('DESKHUB_SQLITE_PATH');
|
||||
putenv('DESKHUB_TESTING');
|
||||
putenv('DESKHUB_API_ALLOW_INSECURE=1');
|
||||
require_once $root . '/database/sqlite_schema.php';
|
||||
require_once $root . '/database/migrate.php';
|
||||
$db = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
|
||||
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
$db->exec('PRAGMA foreign_keys = ON');
|
||||
$db->exec(convert_schema_to_sqlite((string) file_get_contents($root . '/database/schema.sql')));
|
||||
run_migrations($db, true);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$dept = (int) $db->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
|
||||
$db->exec('DELETE FROM staff');
|
||||
$ins = $db->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)');
|
||||
foreach ([['Chef', 'chef@test.de', 'admin'], ['Eva', 'eva@test.de', 'agent'], ['Max', 'max@test.de', 'agent']] as [$n, $e, $r]) {
|
||||
$ins->execute([$n, $e, password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT), $r, $dept, $now]);
|
||||
}
|
||||
$evaId = (int) $db->query("SELECT id FROM staff WHERE email = 'eva@test.de'")->fetchColumn();
|
||||
$db = null;
|
||||
|
||||
$failures = [];
|
||||
$total = 0;
|
||||
$check = function (bool $ok, string $what) use (&$failures, &$total): void { $total++; if (!$ok) $failures[] = $what; };
|
||||
$webProc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$webPort", '-t', $tmp], [['pipe', 'r'], ['file', $work . '/web.out', 'w'], ['file', $work . '/web.out', 'a']], $wp, $tmp);
|
||||
|
||||
try {
|
||||
for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $webPort)) break; usleep(100000); }
|
||||
$pdo = function () use ($tmp): PDO {
|
||||
$p = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
|
||||
$p->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$p->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
return $p;
|
||||
};
|
||||
$call = function (string $method, string $path, array $form, string $jar, ?string $bearer = null, ?array $json = null) use ($webPort): array {
|
||||
$ch = curl_init("http://127.0.0.1:$webPort$path");
|
||||
$h = ['Sec-Fetch-Mode: navigate'];
|
||||
if ($json !== null) $h[] = 'Content-Type: application/json';
|
||||
if ($bearer) $h[] = 'Authorization: Bearer ' . $bearer;
|
||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 60, CURLOPT_HEADER => true, CURLOPT_HTTPHEADER => $h]);
|
||||
if ($jar !== '') { curl_setopt($ch, CURLOPT_COOKIEJAR, $jar); curl_setopt($ch, CURLOPT_COOKIEFILE, $jar); }
|
||||
if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $json !== null ? json_encode($json) : http_build_query($form)); }
|
||||
$raw = (string) curl_exec($ch);
|
||||
$s = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$hs = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||
curl_close($ch);
|
||||
return [$s, substr($raw, $hs), substr($raw, 0, $hs)];
|
||||
};
|
||||
$login = function (string $email) use ($call): string {
|
||||
$jar = tempnam(sys_get_temp_dir(), 'sow');
|
||||
[, $lp] = $call('GET', '/staff/login.php', [], $jar);
|
||||
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $lp, $m);
|
||||
$call('POST', '/staff/login.php', ['csrf_token' => $m[1] ?? '', 'email' => $email, 'password' => 'Sehr-Sicher-1234'], $jar);
|
||||
return $jar;
|
||||
};
|
||||
$csrf = function (string $path, string $jar) use ($call): string {
|
||||
[, $p] = $call('GET', $path, [], $jar);
|
||||
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $p, $mm);
|
||||
return $mm[1] ?? '';
|
||||
};
|
||||
$loggedIn = function (string $jar) use ($call): bool {
|
||||
[$s, $b] = $call('GET', '/staff/profile.php?tab=security', [], $jar);
|
||||
return $s === 200 && str_contains($b, 'Von allen Geräten abmelden');
|
||||
};
|
||||
$apiToken = function (string $email) use ($call): string {
|
||||
[, $b] = $call('POST', '/api/staff/v1/login.php', [], '', null, ['email' => $email, 'password' => 'Sehr-Sicher-1234', 'deviceName' => 'Test-Gerät']);
|
||||
return (string) (json_decode($b, true)['token'] ?? '');
|
||||
};
|
||||
$apiOk = fn(string $tok) => $call('GET', '/api/staff/v1/me.php', [], '', $tok)[0];
|
||||
|
||||
$jarA = $login('eva@test.de');
|
||||
$jarB = $login('eva@test.de');
|
||||
$jarMax = $login('max@test.de');
|
||||
$jarChef = $login('chef@test.de');
|
||||
$tokEva = $apiToken('eva@test.de');
|
||||
$tokMax = $apiToken('max@test.de');
|
||||
$check($loggedIn($jarA) && $loggedIn($jarB) && $loggedIn($jarMax) && $tokEva !== '' && $apiOk($tokEva) === 200, 'Vorher: zwei Browser, eine App-Anmeldung und Max sind angemeldet');
|
||||
[, $page] = $call('GET', '/staff/profile.php?tab=security', [], $jarA);
|
||||
$check(str_contains($page, 'Angemeldete Apps') && str_contains($page, 'Test-Gerät') && str_contains($page, 'Alle anderen Geräte abmelden'), 'Profil zeigt die angemeldeten Apps und beide Knöpfe');
|
||||
|
||||
sleep(1);
|
||||
// Eigene Abmeldung: alle anderen Geräte
|
||||
$t = $csrf('/staff/profile.php?tab=security', $jarA);
|
||||
[$s] = $call('POST', '/staff/profile.php?tab=security', ['csrf_token' => $t, 'action' => 'sign_out_everywhere', 'scope' => 'others'], $jarA);
|
||||
$check($s === 302, 'Web: „Alle anderen Geräte abmelden“ wird angenommen (HTTP ' . $s . ')');
|
||||
$check($loggedIn($jarA), 'Web: die Sitzung, von der aus abgemeldet wurde, bleibt angemeldet');
|
||||
$check(!$loggedIn($jarB), 'Web: der andere Browser ist abgemeldet');
|
||||
$check($apiOk($tokEva) === 401, 'API: die App-Anmeldung gilt nicht mehr (HTTP ' . $apiOk($tokEva) . ')');
|
||||
$check($loggedIn($jarMax) && $apiOk($tokMax) === 200, 'Andere Mitarbeiter bleiben angemeldet');
|
||||
$check($pdo()->query("SELECT COUNT(*) FROM login_events WHERE event = 'signout' AND staff_id = {$evaId}")->fetchColumn() == 1, 'Protokoll: ein Eintrag „Überall abgemeldet“');
|
||||
|
||||
// Danach neu anmelden geht wieder
|
||||
sleep(2);
|
||||
$jarB2 = $login('eva@test.de');
|
||||
$check($loggedIn($jarB2), 'Nach dem Abmelden ist eine neue Anmeldung wieder gültig');
|
||||
$tokEva2 = $apiToken('eva@test.de');
|
||||
$check($apiOk($tokEva2) === 200, 'auch in der App');
|
||||
|
||||
// Eigene Abmeldung: überall, auch hier
|
||||
sleep(1);
|
||||
$t = $csrf('/staff/profile.php?tab=security', $jarA);
|
||||
[$s, , $hdr] = $call('POST', '/staff/profile.php?tab=security', ['csrf_token' => $t, 'action' => 'sign_out_everywhere', 'scope' => 'all'], $jarA);
|
||||
$check($s === 302 && str_contains($hdr, 'login.php'), 'Web: „auch hier“ führt zur Anmeldeseite (HTTP ' . $s . ')');
|
||||
$check(!$loggedIn($jarA) && !$loggedIn($jarB2) && $apiOk($tokEva2) === 401, 'danach ist wirklich nichts mehr angemeldet');
|
||||
|
||||
// Administrator meldet einen Mitarbeiter ab
|
||||
sleep(2);
|
||||
$jarE = $login('eva@test.de');
|
||||
$tokE = $apiToken('eva@test.de');
|
||||
$check($loggedIn($jarE) && $apiOk($tokE) === 200, 'Eva ist wieder angemeldet');
|
||||
[, $page] = $call('GET', '/staff/staff_manage.php', [], $jarChef);
|
||||
$check(str_contains($page, 'Überall abmelden'), 'Mitarbeiterverwaltung zeigt „Überall abmelden“');
|
||||
$check(preg_match('/\.sm-menu-list\s*\{\s*position:fixed/', $page) === 1, 'Aktionsmenü ist frei positioniert (wird vom Tabellenrahmen nicht abgeschnitten)');
|
||||
$t = $csrf('/staff/staff_manage.php', $jarChef);
|
||||
[$s] = $call('POST', '/staff/staff_manage.php', ['csrf_token' => $t, 'action' => 'sign_out_all', 'id' => $evaId], $jarChef);
|
||||
$check($s === 302, 'Administrator: „Überall abmelden“ wird angenommen (HTTP ' . $s . ')');
|
||||
$check(!$loggedIn($jarE) && $apiOk($tokE) === 401, 'Eva ist danach überall abgemeldet');
|
||||
$check($loggedIn($jarChef) && $loggedIn($jarMax), 'Administrator und Max bleiben angemeldet');
|
||||
$ev = $pdo()->query("SELECT detail FROM login_events WHERE event = 'signout' ORDER BY id DESC LIMIT 1")->fetchColumn();
|
||||
$check(str_contains((string) $ev, 'Administrator'), 'Protokoll nennt den Administrator');
|
||||
// Normaler Mitarbeiter darf das bei anderen nicht
|
||||
$maxId = (int) $pdo()->query("SELECT id FROM staff WHERE email = 'max@test.de'")->fetchColumn();
|
||||
$jarE2 = $login('eva@test.de');
|
||||
$t = $csrf('/staff/profile.php', $jarE2);
|
||||
$call('POST', '/staff/staff_manage.php', ['csrf_token' => $t, 'action' => 'sign_out_all', 'id' => $maxId], $jarE2);
|
||||
$check($loggedIn($jarMax) && $apiOk($tokMax) === 200, 'Ein normaler Mitarbeiter kann niemand anderen abmelden');
|
||||
foreach ([$jarA, $jarB, $jarB2, $jarMax, $jarChef, $jarE, $jarE2] as $j) @unlink($j);
|
||||
} finally {
|
||||
if (is_resource($webProc)) { proc_terminate($webProc); proc_close($webProc); }
|
||||
$rm = function (string $d) use (&$rm): void { foreach (glob($d . '/{,.}*', GLOB_BRACE) ?: [] as $e) { $b = basename($e); if ($b === '.' || $b === '..') continue; is_dir($e) ? $rm($e) : @unlink($e); } @rmdir($d); };
|
||||
$rm($work);
|
||||
}
|
||||
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo "Sign out web checks passed ({$total} Prüfungen).\n";
|
||||
@@ -14,6 +14,7 @@ require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
||||
$failures = [];
|
||||
$check = function (bool $ok, string $what) use (&$failures): void { if (!$ok) $failures[] = $what; };
|
||||
$pdo = get_db();
|
||||
set_setting('sla_notify_mode', 'assignee'); // diese Prüfungen betreffen den Modus „Bearbeiter bzw. Abteilung“ (Standard ist „nur Administratoren“)
|
||||
$now = db_now();
|
||||
$pdo->prepare('INSERT INTO departments (name, email) VALUES (?,?)')->execute(['Support', 'support@firma.example']);
|
||||
$dept = (int) $pdo->lastInsertId();
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft zwei Dinge rund um „SLA überschritten“: (1) ein wieder geöffnetes (zuvor gelöstes) Ticket bekommt eine neue Frist und löst nicht sofort
|
||||
* eine Überschreitung aus, (2) die Benachrichtigung geht standardmäßig nur per E-Mail an die Administratoren (Einstellung „Bearbeiter bzw. Abteilung“ bleibt möglich).
|
||||
*/
|
||||
|
||||
putenv('DESKHUB_TESTING=1');
|
||||
putenv('DESKHUB_SQLITE_PATH=:memory:');
|
||||
require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
||||
if (!defined('DESKHUB_CRON_RUNNER')) define('DESKHUB_CRON_RUNNER', true);
|
||||
require_once dirname(__DIR__) . '/cron/check_sla.php';
|
||||
|
||||
$failures = [];
|
||||
$total = 0;
|
||||
$check = function (bool $ok, string $what) use (&$failures, &$total): void {
|
||||
$total++;
|
||||
if (!$ok) $failures[] = $what;
|
||||
};
|
||||
$pdo = get_db();
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$ago = fn(int $h) => date('Y-m-d H:i:s', time() - $h * 3600);
|
||||
$dept = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
|
||||
$pdo->exec('UPDATE priorities SET response_hours = 4, resolution_hours = 48');
|
||||
$prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY id LIMIT 1')->fetchColumn();
|
||||
$pdo->exec('DELETE FROM staff');
|
||||
$ins = $pdo->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)');
|
||||
$ins->execute(['Chef', 'chef@test.de', 'x', 'admin', $dept, $now]);
|
||||
$ins->execute(['Agent', 'agent@test.de', 'x', 'agent', $dept, $now]);
|
||||
$agent = (int) $pdo->lastInsertId();
|
||||
$ins->execute(['Kollege', 'kollege@test.de', 'x', 'agent', $dept, $now]);
|
||||
|
||||
$mk = function (string $n, string $status, array $o = []) use ($pdo, $dept, $prio, $now, $agent): int {
|
||||
$pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, assigned_staff_id, resolution_due_at, first_response_due_at, first_responded_at, resolved_at, is_overdue, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)')
|
||||
->execute([$n, 'S ' . $n, 'K', 'k@example.org', $dept, $prio, $status, 'web', $agent, $o['due'] ?? null, $o['rdue'] ?? null, $o['first'] ?? null, $o['resolved'] ?? null, $o['overdue'] ?? 0, $o['created'] ?? $now, $now]);
|
||||
return (int) $pdo->lastInsertId();
|
||||
};
|
||||
$row = fn(int $id) => $pdo->query("SELECT * FROM tickets WHERE id = {$id}")->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// ── Wieder geöffnet: neue Frist ──────────────────────────────────────────────
|
||||
$old = $ago(100);
|
||||
$t = $mk('R-1', 'open', ['due' => $ago(50), 'rdue' => $ago(90), 'first' => $ago(95), 'resolved' => $ago(60), 'overdue' => 1, 'created' => $old]);
|
||||
ticket_sla_status_transition($pdo, $t, 'resolved', 'open');
|
||||
$r = $row($t);
|
||||
$check($r['resolution_due_at'] > $now && $r['is_overdue'] == 0, 'gelöst → offen: neue Lösungsfrist in der Zukunft, nicht mehr überfällig: ' . json_encode([$r['resolution_due_at'], $r['is_overdue']]));
|
||||
$expected = strtotime($r['resolution_due_at']) - time();
|
||||
$check($expected > 47 * 3600 && $expected < 49 * 3600, 'neue Frist = 48 Stunden der Priorität ab jetzt');
|
||||
$h = $pdo->query("SELECT COUNT(*) FROM ticket_sla_history WHERE ticket_id = {$t} AND event_type = 'restarted'")->fetchColumn();
|
||||
$check((int) $h === 1, 'im SLA-Verlauf steht „neu gestartet“');
|
||||
$res = run_check_sla($pdo, false);
|
||||
$check($res['newlyOverdue'] === 0, 'der SLA-Job meldet für das wieder geöffnete Ticket nichts: ' . json_encode($res['newlyOverdue']));
|
||||
$check((int) $row($t)['is_overdue'] === 0, 'Ticket bleibt nicht überfällig');
|
||||
|
||||
$t2 = $mk('R-2', 'open', ['due' => $ago(50), 'overdue' => 1, 'created' => $old]);
|
||||
ticket_sla_status_transition($pdo, $t2, 'closed', 'open');
|
||||
$check($row($t2)['resolution_due_at'] > $now, 'geschlossen → offen: ebenfalls neue Frist');
|
||||
|
||||
$t3 = $mk('R-3', 'pending', ['due' => $ago(50), 'overdue' => 1, 'created' => $old]);
|
||||
ticket_sla_status_transition($pdo, $t3, 'resolved', 'pending');
|
||||
$r3 = $row($t3);
|
||||
$check($r3['resolution_due_at'] > $now && $r3['sla_paused_at'] !== null, 'gelöst → wartet auf Kunde: neue Frist, die Uhr ist pausiert');
|
||||
|
||||
// Keine Änderung bei anderen Übergängen
|
||||
$due = $ago(30);
|
||||
$t4 = $mk('R-4', 'answered', ['due' => $due, 'created' => $old]);
|
||||
ticket_sla_status_transition($pdo, $t4, 'open', 'answered');
|
||||
$check($row($t4)['resolution_due_at'] === $due, 'offen → beantwortet: Frist bleibt');
|
||||
$t5 = $mk('R-5', 'closed', ['due' => $due, 'created' => $old]);
|
||||
ticket_sla_status_transition($pdo, $t5, 'resolved', 'closed');
|
||||
$check($row($t5)['resolution_due_at'] === $due, 'gelöst → geschlossen: Frist bleibt');
|
||||
$t6 = $mk('R-6', 'open', ['due' => $due, 'created' => $old]);
|
||||
ticket_sla_status_transition($pdo, $t6, 'open', 'open');
|
||||
$check($row($t6)['resolution_due_at'] === $due, 'gleicher Status: nichts passiert');
|
||||
$t7 = $mk('R-7', 'open', ['due' => $due, 'overdue' => 1, 'created' => $old]);
|
||||
$check(ticket_sla_restart($pdo, 999999) === null, 'unbekanntes Ticket: kein Fehler');
|
||||
|
||||
// ── Wer benachrichtigt wird ──────────────────────────────────────────────────
|
||||
$tk = ['assigned_staff_id' => $agent, 'department_id' => $dept];
|
||||
$check(sla_notify_mode() === 'admins', 'Standard: nur Administratoren');
|
||||
$rec = array_map('strtolower', sla_overdue_recipients($pdo, $tk));
|
||||
$check($rec === ['chef@test.de'], 'Empfänger: nur der Administrator, nicht Bearbeiter oder Abteilung: ' . json_encode($rec));
|
||||
set_setting('sla_notify_mode', 'assignee');
|
||||
$rec = array_map('strtolower', sla_overdue_recipients($pdo, $tk));
|
||||
$check($rec === ['agent@test.de'], 'Einstellung „Bearbeiter bzw. Abteilung“: der Bearbeiter: ' . json_encode($rec));
|
||||
set_setting('sla_notify_mode', 'unsinn');
|
||||
$check(sla_notify_mode() === 'admins', 'ungültige Einstellung fällt auf Administratoren zurück');
|
||||
set_setting('sla_notify_mode', 'admins');
|
||||
$check(sla_overdue_recipients($pdo, ['assigned_staff_id' => null, 'department_id' => $dept]) === ['chef@test.de'], 'auch ohne Bearbeiter: nur der Administrator');
|
||||
fcm_notify_overdue(['ticket_number' => 'X', 'department_id' => $dept, 'assigned_staff_id' => $agent]);
|
||||
$check(true, 'Push im Administrator-Modus ohne Fehler übersprungen');
|
||||
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo "SLA reopen and notify checks passed ({$total} Prüfungen).\n";
|
||||
Reference in New Issue
Block a user