Upload via GUI (267 Dateien)

This commit is contained in:
2026-10-19 18:05:51 +00:00
parent 845abfa9d9
commit 42554bdadf
18 changed files with 1629 additions and 13 deletions
+14
View File
@@ -20,6 +20,20 @@ Header always set X-Content-Type-Options "nosniff"
# Funktioniert mit Apache (mod_rewrite/mod_authz aktiv). Bei nginx siehe README.md.
RewriteEngine On
# Den Header "Authorization" (Anmeldung der Windows- und Android-App) an PHP weiterreichen. Ohne diese Regeln entfernen Apache mit
# PHP-FPM/FastCGI (z. B. Plesk) ihn, und die Apps melden "Ein gültiges Bearer-Token ist erforderlich".
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
<IfModule mod_setenvif.c>
SetEnvIfNoCase ^Authorization$ "(.+)" HTTP_AUTHORIZATION=$1
</IfModule>
<IfModule mod_version.c>
<IfVersion >= 2.4.13>
CGIPassAuth On
</IfVersion>
</IfModule>
RewriteCond %{REQUEST_URI} ^/(includes|config|database|cron)/ [NC]
RewriteRule ^ - [F,L]
+2 -2
View File
@@ -20,7 +20,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Gleiche Fehlermeldung – verrät nicht ob die E-Mail bekannt ist
$errors[] = 'Zu viele Anfragen. Bitte versuchen Sie es in einigen Minuten erneut.';
} else {
$stmt = get_db()->prepare('SELECT * FROM users WHERE email = ?');
$stmt = get_db()->prepare('SELECT * FROM users WHERE LOWER(email) = LOWER(?)');
$stmt->execute([$email]);
$user = $stmt->fetch();
@@ -57,7 +57,7 @@ require __DIR__ . '/includes/header_customer.php';
<form method="post">
<?= csrf_field() ?>
<label for="email">E-Mail-Adresse</label>
<input type="email" id="email" name="email" required autofocus>
<input type="email" id="email" name="email" required autofocus autocapitalize="none" autocorrect="off" spellcheck="false">
<div class="btn-row">
<button type="submit" class="btn" style="width:100%">Link anfordern</button>
</div>
+7 -8
View File
@@ -12,14 +12,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!rate_limit_check('login_attempt', client_ip(), 10, 15)) {
$errors[] = 'Zu viele Anmeldeversuche. Bitte versuchen Sie es in einigen Minuten erneut.';
} elseif ($email && !rate_limit_check('login_email', $email, 5, 15)) {
} elseif ($email && !rate_limit_check('login_email', strtolower($email), 5, 15)) {
$errors[] = 'Zu viele Anmeldeversuche für dieses Konto. Bitte warten Sie einige Minuten.';
} else {
$stmt = get_db()->prepare('SELECT * FROM users WHERE email = ?');
$stmt = get_db()->prepare('SELECT * FROM users WHERE LOWER(email) = LOWER(?)');
$stmt->execute([$email]);
$user = $stmt->fetch();
if ($user && password_verify($pass, $user['password_hash'])) {
rate_limit_reset('login_email', $email);
rate_limit_reset('login_email', strtolower($email));
customer_login($user);
redirect('/home.php');
}
@@ -53,8 +53,7 @@ $imprintHtml = get_setting('footer_imprint_html', '');
<link rel="manifest" href="/manifest.php">
<meta name="theme-color" content="#0f6f6a">
<meta name="apple-mobile-web-app-capable" content="yes">
<link rel="apple-touch-icon" href="/assets/icons/icon-192.png">
<?php if ($logo): ?><link rel="icon" href="<?= e($logo) ?>"><?php endif; ?>
<?= site_icon_links() ?>
<script>if('serviceWorker'in navigator){navigator.serviceWorker.register('/sw.js').catch(function(){});}</script>
<?php if (!$banner && !$customThemeStylesheet): ?>
<style>
@@ -84,7 +83,7 @@ $imprintHtml = get_setting('footer_imprint_html', '');
<span class="text-brand">Desk<span>Hub</span></span>
<?php endif; ?>
</a>
<?php if ($logo && $displayName): ?>
<?php if ($logo && $displayName && get_setting('logo_hide_name', '0') !== '1'): ?>
<span style="font-size:1.15rem; font-weight:700; color:var(--ink); letter-spacing:-.01em"><?= e($displayName) ?></span>
<?php endif; ?>
</div>
@@ -115,9 +114,9 @@ $imprintHtml = get_setting('footer_imprint_html', '');
<form method="post">
<?= csrf_field() ?>
<label for="email">E-Mail-Adresse</label>
<input type="email" id="email" name="email" required autofocus autocomplete="email">
<input type="email" id="email" name="email" required autofocus autocomplete="email" autocapitalize="none" autocorrect="off" spellcheck="false">
<label for="password">Passwort</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
<input type="password" id="password" name="password" required autocomplete="current-password" autocapitalize="none" autocorrect="off" spellcheck="false">
<div style="text-align:right; margin-top:6px">
<a href="/forgot_password.php" class="login-forgot">Passwort vergessen?</a>
</div>
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/**
* Prüft, wie die Anmeldung der Apps aus den Anfrage-Kopfzeilen gelesen wird. Viele Hoster (Apache mit PHP-FPM, z. B. Plesk)
* reichen "Authorization" nicht an PHP weiter: Dann muss der Ersatz-Header "X-DeskHub-Authorization" genügen.
*/
putenv('DESKHUB_SQLITE_PATH=:memory:');
require_once dirname(__DIR__) . '/api/staff/v1/common.php';
$failures = [];
$check = function (bool $ok, string $what) use (&$failures): void {
if (!$ok) $failures[] = $what;
};
$token = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
$other = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
$reset = function (array $server): void {
foreach (['HTTP_AUTHORIZATION', 'REDIRECT_HTTP_AUTHORIZATION', 'HTTP_X_DESKHUB_AUTHORIZATION'] as $k) unset($_SERVER[$k]);
foreach ($server as $k => $v) $_SERVER[$k] = $v;
};
$reset(['HTTP_AUTHORIZATION' => "Bearer {$token}"]);
$check(api_bearer_token() === $token, 'normaler Authorization-Header wird gelesen');
$reset(['REDIRECT_HTTP_AUTHORIZATION' => "Bearer {$token}"]);
$check(api_bearer_token() === $token, 'Authorization über REDIRECT_HTTP_AUTHORIZATION (Apache-Umleitung) wird gelesen');
$reset(['HTTP_X_DESKHUB_AUTHORIZATION' => "Bearer {$token}"]);
$check(api_bearer_token() === $token, 'Ersatz-Header X-DeskHub-Authorization genügt, wenn Authorization fehlt (Plesk/FPM)');
$reset(['HTTP_AUTHORIZATION' => "Bearer {$token}", 'HTTP_X_DESKHUB_AUTHORIZATION' => "Bearer {$other}"]);
$check(api_bearer_token() === $token, 'ist beides vorhanden, gilt Authorization');
$reset(['HTTP_AUTHORIZATION' => '', 'HTTP_X_DESKHUB_AUTHORIZATION' => "Bearer {$token}"]);
$check(api_bearer_token() === $token, 'leerer Authorization-Header fällt auf den Ersatz-Header zurück');
$reset([]);
$check(api_bearer_token() === null && api_authorization_header() === '', 'ohne Header: kein Token');
$reset(['HTTP_X_DESKHUB_AUTHORIZATION' => 'Basic abc']);
$check(api_bearer_token() === null, 'kein Bearer-Schema: abgelehnt');
$reset(['HTTP_X_DESKHUB_AUTHORIZATION' => 'Bearer kurz']);
$check(api_bearer_token() === null, 'zu kurzes Token: abgelehnt');
$reset(['HTTP_X_DESKHUB_AUTHORIZATION' => "Bearer {$token}\r\nX-Evil: 1"]);
$check(api_bearer_token() === null, 'eingeschleuste Zeilenumbrüche: abgelehnt');
// Klartext aus HTML (Signaturen und Nachrichten in den Apps): Blöcke bleiben getrennt
$check(api_plain_text('<p>Liebe Grüße<br>Lukas</p><div>Inhaber Windelweb</div><div>Zweite Zeile</div>') === "Liebe Grüße\nLukas\n\nInhaber Windelweb\nZweite Zeile", 'Klartext: Absätze und div-Blöcke bleiben getrennt: ' . json_encode(api_plain_text('<p>Liebe Grüße<br>Lukas</p><div>Inhaber Windelweb</div><div>Zweite Zeile</div>')));
$check(api_plain_text('<ul><li>Eins</li><li>Zwei</li></ul>') === "• Eins\n• Zwei", 'Klartext: Listenpunkte');
$check(api_plain_text("<p>A</p><p></p><p></p><p>B</p>") === "A\n\nB", 'Klartext: höchstens eine Leerzeile');
$check(api_plain_text('<p>Text &amp; mehr &lt;b&gt;</p>') === 'Text & mehr <b>', 'Klartext: Entities werden aufgelöst');
$check(api_plain_text(null) === '' && api_plain_text('') === '', 'Klartext: leer');
// Klartext aus HTML (Signaturen und Nachrichten in den Apps): Blöcke bleiben getrennt
$check(api_plain_text('<p>Liebe Grüße<br>Lukas</p><div>Inhaber Windelweb</div><div>Zweite Zeile</div>') === "Liebe Grüße\nLukas\n\nInhaber Windelweb\nZweite Zeile", 'Klartext: Absätze und div-Blöcke bleiben getrennt: ' . json_encode(api_plain_text('<p>Liebe Grüße<br>Lukas</p><div>Inhaber Windelweb</div><div>Zweite Zeile</div>')));
$check(api_plain_text('<ul><li>Eins</li><li>Zwei</li></ul>') === "• Eins\n• Zwei", 'Klartext: Listenpunkte');
$check(api_plain_text("<p>A</p><p></p><p></p><p>B</p>") === "A\n\nB", 'Klartext: höchstens eine Leerzeile');
$check(api_plain_text('<p>Text &amp; mehr &lt;b&gt;</p>') === 'Text & mehr <b>', 'Klartext: Entities werden aufgelöst');
$check(api_plain_text(null) === '' && api_plain_text('') === '', 'Klartext: leer');
// Anwendungs-Code der Apps sendet den Ersatz-Header
$desktop = (string) @file_get_contents(dirname(__DIR__, 2) . '/desktop/src/main.js');
if ($desktop !== '') $check(str_contains($desktop, "X-DeskHub-Authorization"), 'Windows-App sendet den Ersatz-Header');
$android = (string) @file_get_contents(dirname(__DIR__, 2) . '/mobile/src/main/java/de/deskhub/staff/data/remote/DeskHubApi.kt');
if ($android !== '') $check(substr_count($android, 'X-DeskHub-Authorization') >= 2, 'Android-App sendet den Ersatz-Header (Anfragen und Bild-Upload)');
// Apache-Regeln
$htaccess = (string) file_get_contents(dirname(__DIR__) . '/.htaccess');
$check(str_contains($htaccess, 'E=HTTP_AUTHORIZATION:%{HTTP:Authorization}') && str_contains($htaccess, 'CGIPassAuth On'), '.htaccess reicht den Authorization-Header an PHP weiter');
if ($failures) {
fwrite(STDERR, "FEHLER:\n- " . implode("\n- ", $failures) . "\n");
exit(1);
}
echo "API auth header checks passed.\n";
+184
View File
@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
/**
* Ende-zu-Ende: Adminportal mit aktivem Plugin "Synology NAS Backup" gegen einen Test-WebDAV-Server (Menüpunkt, Einstellungen speichern,
* Backup der Seite "Backup" wird kopiert, Sicherungen nur auf der NAS holen/löschen, automatische Prüfung in der Backup-Liste).
*/
if (!extension_loaded('pdo_sqlite') || !function_exists('curl_init') || !class_exists('ZipArchive')) { echo "Backup-Seiten-Test übersprungen: pdo_sqlite, curl oder zip fehlt.\n"; exit(0); }
require_once __DIR__ . '/fixtures/paths.php';
$root = dirname(__DIR__);
$pluginSrc = plugin_source_dir_or_skip('nas-backup', 'backup_page_nas');
$tmp = sys_get_temp_dir() . '/deskhub-bp-' . bin2hex(random_bytes(4));
$dav = sys_get_temp_dir() . '/deskhub-bpdav-' . bin2hex(random_bytes(4));
mkdir($tmp, 0755, true);
mkdir($dav . '/share', 0755, true);
$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');
@mkdir($tmp . '/backups');
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) . ';');
// Plugin in den Plugin-Ordner der Testinstanz kopieren
$copyDir = function (string $from, string $to) use (&$copyDir): void {
@mkdir($to, 0755, true);
foreach (scandir($from) ?: [] as $e) {
if ($e === '.' || $e === '..') continue;
is_dir("$from/$e") ? $copyDir("$from/$e", "$to/$e") : copy("$from/$e", "$to/$e");
}
};
$copyDir($pluginSrc, $tmp . '/plugins/nas-backup');
require_once $root . '/includes/db.php';
require_once $root . '/database/migrate.php';
$pdo = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(convert_schema_to_sqlite((string) file_get_contents($root . '/database/schema.sql')));
run_migrations($pdo, true);
$now = date('Y-m-d H:i:s');
$pdo->exec('DELETE FROM staff');
$deptA = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
$pdo->prepare("INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES ('Admin','admin@test.de',?,'admin',?,1,?)")->execute([password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT), $deptA, $now]);
$pdo->prepare("INSERT INTO plugins (slug, name, version, is_active, installed_at, updated_at, signature_ok, source, approved_permissions) VALUES ('nas-backup','NAS-Backup','1.1.0',1,?,?,0,'manual','hooks.cron,ui.menu')")->execute([$now, $now]);
$failures = [];
$total = 0;
$check = function (bool $ok, string $what) use (&$failures, &$total): void { $total++; if (!$ok) $failures[] = $what; };
$davPort = random_int(30000, 30499);
$webPort = random_int(30500, 30999);
$davPass = 'pass#wort';
putenv('DAV_ROOT=' . $dav . '/share');
putenv('DAV_PASS=' . $davPass);
putenv('DESKHUB_TESTING=1'); // erlaubt dem Test, ein unsigniertes Plugin zu laden
$logA = tempnam(sys_get_temp_dir(), 'bpd');
$logB = tempnam(sys_get_temp_dir(), 'bpw');
$davProc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$davPort", __DIR__ . '/fixtures/webdav_server.php'], [['pipe', 'r'], ['file', $logA, 'w'], ['file', $logA, 'a']], $p1);
$webProc = proc_open([PHP_BINARY, '-d', 'max_execution_time=120', '-S', "127.0.0.1:$webPort", '-t', $tmp], [['pipe', 'r'], ['file', $logB, 'w'], ['file', $logB, 'a']], $p2, $tmp);
$set = function (string $k, string $v) use ($pdo): void {
$pdo->prepare('DELETE FROM settings WHERE setting_key = ?')->execute(['plugin.nas-backup.' . $k]);
$pdo->prepare('INSERT INTO settings (setting_key, setting_value) VALUES (?, ?)')->execute(['plugin.nas-backup.' . $k, $v]);
};
$req = function (string $method, string $path, array $data, string $jar) use ($webPort): array {
$ch = curl_init("http://127.0.0.1:$webPort$path");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 120, CURLOPT_COOKIEJAR => $jar, CURLOPT_COOKIEFILE => $jar]);
if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data)); }
$b = (string) curl_exec($ch);
$s = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return [$s, $b];
};
$csrf = fn(string $h) => preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $h, $m) ? $m[1] : '';
$remoteFiles = fn() => array_values(array_filter(is_dir($dav . '/share/DeskHub-Backups') ? (scandir($dav . '/share/DeskHub-Backups') ?: []) : [], fn($f) => $f[0] !== '.'));
$localZips = fn() => array_map('basename', glob($tmp . '/backups/backup_*.zip') ?: []);
try {
for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $davPort) && @fsockopen('127.0.0.1', $webPort)) break; usleep(100000); }
$jar = tempnam(sys_get_temp_dir(), 'bpj');
[, $l] = $req('GET', '/staff/login.php', [], $jar);
$req('POST', '/staff/login.php', ['csrf_token' => $csrf($l), 'email' => 'admin@test.de', 'password' => 'Sehr-Sicher-1234'], $jar);
$pluginUrl = '/staff/plugin.php?p=nas-backup&page=einstellungen';
// 1) Menüpunkt und Seite, noch nicht eingerichtet
[$s, $side] = $req('GET', '/staff/backup.php', [], $jar);
$check($s === 200 && str_contains($side, 'class="plugin-tabs"') && str_contains($side, 'plugin.php?p=nas-backup&amp;page=einstellungen'), 'Auf der Seite „Backup“ gibt es den Reiter „Synology NAS Backup“');
$sidebar = preg_match('#<aside.*?</aside>|<nav.*?</nav>#s', $side, $sb) ? $sb[0] : '';
$check($sidebar === '' || !str_contains($sidebar, 'Synology NAS Backup'), 'Kein eigener Eintrag in der Seitenleiste');
[$s, $page] = $req('GET', $pluginUrl, [], $jar);
$check($s === 200 && str_contains($page, 'class="plugin-tabs"') && str_contains($page, 'href="/staff/backup.php"') && str_contains($page, '<h1>Synology NAS Backup</h1>') && str_contains($page, 'name="do" value="save"') && str_contains($page, 'Zielverzeichnis auf der NAS'), 'Plugin-Seite lädt mit den Einstellungen (HTTP ' . $s . ')');
$check(str_contains($page, 'Das NAS-Backup ist ausgeschaltet'), 'Ohne Einrichtung steht dort „ausgeschaltet“');
// 2) Einrichten über das Formular
[$s, $b] = $req('POST', $pluginUrl, ['csrf_token' => $csrf($page), 'do' => 'save', 'enabled' => '1', 'host' => '127.0.0.1', 'port' => (string) $davPort, 'https' => '', 'user' => 'nas', 'password' => $davPass,
'target' => '/dav/DeskHub-Backups/', 'mirror' => '1', 'db' => '1', 'files' => '1', 'scope' => 'important', 'interval' => 'daily', 'time' => '02:00', 'keep_days' => '0', 'keep' => '10', 'verify_tls' => '1'], $jar);
$check($s === 200 && str_contains($b, 'Einstellungen gespeichert'), 'Einstellungen speichern meldet Erfolg (HTTP ' . $s . ')');
$check(str_contains($b, 'NAS erreichbar'), 'Die Seite zeigt „NAS erreichbar“ nach dem Einrichten');
$check(!str_contains($b, 'Fatal error') && !str_contains($b, 'Warning:'), 'Die Seite erzeugt keine PHP-Fehler');
// 3) Backup auf der Seite „Backup“ erstellen: automatisch geprüft, danach manuell kopiert (der Takt läuft in der Testinstanz nur alle 5 Minuten)
[, $bp] = $req('GET', '/staff/backup.php', [], $jar);
[$s] = $req('POST', '/staff/backup.php', ['csrf_token' => $csrf($bp), 'action' => 'create_backup'], $jar);
$zips = $localZips();
$name = $zips[0] ?? '';
$check($s === 302 && count($zips) === 1, 'Backup erstellen funktioniert und leitet zurück (HTTP ' . $s . ')');
[, $bp] = $req('GET', '/staff/backup.php', [], $jar);
$check(str_contains($bp, '✔ geprüft') && !str_contains($bp, 'nicht geprüft'), 'Ohne Zutun: das neue Backup ist automatisch geprüft (✔ geprüft in der Liste)');
$check(str_contains($bp, 'class="bk-row"') && str_contains($bp, $name), 'Neue kompakte Listenansicht auf der Backup-Seite');
$check(str_contains($bp, '☁ auf NAS') || (str_contains($bp, 'nur hier') && str_contains($bp, '☁ Auf NAS')), 'Die Backup-Liste zeigt bei dem Backup die NAS-Marke (schon kopiert oder „nur hier“ mit Knopf „Auf NAS“; der Plugin-Takt kann bereits gelaufen sein)');
[, $pp] = $req('GET', $pluginUrl, [], $jar);
[$s, $b] = $req('POST', $pluginUrl, ['csrf_token' => $csrf($pp), 'do' => 'sync'], $jar);
$check($name !== '' && in_array($name, $remoteFiles(), true), 'Das Backup der Backup-Seite wird auf die NAS kopiert (Aktion „Fehlende kopieren“)');
[, $bp] = $req('GET', '/staff/backup.php', [], $jar);
$check(str_contains($bp, '☁ auf NAS'), 'Danach trägt es in der Backup-Liste die Marke „auf NAS“');
$check(str_contains($b, '☁ auf der NAS') && str_contains($b, $name), 'Die Plugin-Seite zeigt es als „auf der NAS“');
// 4) Lokal löschen: bleibt als „nur auf der NAS“ sichtbar und lässt sich zurückholen
[, $bp] = $req('GET', '/staff/backup.php', [], $jar);
$req('POST', '/staff/backup.php', ['csrf_token' => $csrf($bp), 'action' => 'delete_backup', 'filename' => $name], $jar);
$check(count($localZips()) === 0, 'Lokale Sicherung gelöscht');
[, $b] = $req('GET', $pluginUrl, [], $jar);
$check(str_contains($b, '☁ nur auf der NAS') && str_contains($b, 'Auf Server holen'), 'Die Plugin-Seite zeigt die Sicherung als „nur auf der NAS“ mit „Auf Server holen“');
[, $bp] = $req('GET', '/staff/backup.php', [], $jar);
$check(str_contains($bp, '☁ nur auf NAS') && str_contains($bp, 'Auf Server holen') && str_contains($bp, $name) && str_contains($bp, 'nicht auf diesem Server'), 'Auch die Backup-Seite listet die Sicherung, die nur auf der NAS liegt, mit „Auf Server holen“');
$statsText = preg_replace('/\s+/', ' ', strip_tags(substr($bp, (int) strpos($bp, 'class="stat-grid"'), 1800)));
$check(str_contains($statsText, '1Backups vorhanden') && str_contains($statsText, '0 auf dem Server') && str_contains($statsText, '1 nur extern (NAS)'), 'Die Kacheln oben zählen auch die Sicherung auf der NAS (1 vorhanden, 0 auf dem Server, 1 nur extern): ' . substr($statsText, 0, 220));
$req('POST', $pluginUrl, ['csrf_token' => $csrf($b), 'do' => 'fetch', 'file' => $name], $jar);
$check(in_array($name, $localZips(), true), '„Auf Server holen“ legt die Sicherung im Backup-Ordner ab');
[, $bp] = $req('GET', '/staff/backup.php', [], $jar);
$check(str_contains($bp, 'Wiederherstellen') && str_contains($bp, $name), 'Auf der Backup-Seite ist sie danach wieder normal wiederherstellbar');
// 5) Schutz: ungültiger Dateiname, fehlendes Token
[, $b] = $req('GET', $pluginUrl, [], $jar);
$beforeFiles = $remoteFiles();
$req('POST', $pluginUrl, ['csrf_token' => $csrf($b), 'do' => 'upload', 'file' => '../config/config.php'], $jar);
$check($remoteFiles() === $beforeFiles, 'Aktion mit ungültigem Dateinamen verändert nichts');
[$s] = $req('POST', $pluginUrl, ['do' => 'sync'], $jar);
$check($s !== 200 || $remoteFiles() === $beforeFiles, 'Aktionen ohne gültiges Token werden nicht ausgeführt (HTTP ' . $s . ')');
// 6) Normale Mitarbeiter haben keinen Zugriff
$pdo2 = new PDO('sqlite:' . $GLOBALS['tmp'] . '/database/deskhub.sqlite');
$deptId = (int) $pdo2->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
$pdo2->prepare("INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES ('Agent','agent@test.de',?,'agent',?,1,?)")->execute([password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT), $deptId, date('Y-m-d H:i:s')]);
$ajar = tempnam(sys_get_temp_dir(), 'bpa');
[, $l2] = $req('GET', '/staff/login.php', [], $ajar);
$req('POST', '/staff/login.php', ['csrf_token' => $csrf($l2), 'email' => 'agent@test.de', 'password' => 'Sehr-Sicher-1234'], $ajar);
[$s, $b2] = $req('GET', $pluginUrl, [], $ajar);
$check($s === 403 || !str_contains($b2, 'Zielverzeichnis auf der NAS'), 'Ein normaler Mitarbeiter sieht die Einstellungen nicht (HTTP ' . $s . ')');
} finally {
proc_terminate($davProc);
proc_terminate($webProc);
proc_close($davProc);
proc_close($webProc);
}
usleep(300000);
$rm = function (string $d) use (&$rm) {
foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") && !is_link("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); }
@rmdir($d);
};
foreach ([$tmp, $dav] as $d) if (str_starts_with($d, sys_get_temp_dir() . '/deskhub-bp')) $rm($d);
@unlink($logA);
@unlink($logB);
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Backup page + NAS plugin checks passed ({$total} Prüfungen).\n";
+27
View File
@@ -155,6 +155,33 @@ $check($broken && $broken[0]['level'] === 'error' && str_contains($broken[0]['me
backup_verify_and_record($bk['path']);
$check(backup_path_for($bk['filename'], $backupDir) !== null && backup_path_for('../../etc/passwd', $backupDir) === null && backup_path_for('backup_x.zip', $backupDir) === null && backup_path_for('config.php', $backupDir) === null, 'Backups werden nur über echte Dateinamen im Backup-Ordner angesprochen');
// ── Automatische Prüfung ─────────────────────────────────────────────────────
$auto = create_backup($root, $backupDir);
$map = json_decode((string) get_setting('backup_check_map', ''), true) ?: [];
$check(!empty($auto['ok']) && !empty($auto['verify']['ok']), 'create_backup prüft jedes neue Backup automatisch und liefert das Ergebnis mit');
$check(isset($map[$auto['filename']]) && $map[$auto['filename']]['ok'] === true, 'Das Prüfergebnis wird je Sicherung gemerkt (Anzeige in der Liste)');
$noVerify = create_backup($root, $backupDir, null, ['verify' => false]);
$map = json_decode((string) get_setting('backup_check_map', ''), true) ?: [];
$check(!empty($noVerify['ok']) && $noVerify['verify'] === null && !isset($map[$noVerify['filename']]), 'Mit Option verify=false wird nicht geprüft (für Zwischen-Backups)');
$dbOnly = create_backup($root, $backupDir, null, ['with_database' => false, 'with_files' => true]);
$check(!empty($dbOnly['ok']) && $dbOnly['verify'] === null, 'Backups ohne Datenbank werden nicht als defekt gemeldet, sondern gar nicht automatisch geprüft');
// Nachholen: noch nie geprüfte, beschädigte und veraltete Prüfungen
foreach ([$noVerify, $dbOnly] as $b) @unlink($b['path']);
$unchecked = create_backup($root, $backupDir, null, ['verify' => false]);
sleep(1);
$broken = $backupDir . '/backup_2020-02-02_02-02-02.zip';
file_put_contents($broken, 'das ist kein zip');
$res = backup_verify_pending(50, 7, $backupDir);
$map = json_decode((string) get_setting('backup_check_map', ''), true) ?: [];
$check($res['checked'] >= 2 && $res['bad'] === 1, 'Nachprüfung findet das ungeprüfte und das beschädigte Backup (' . json_encode($res) . ')');
$check(($map[$unchecked['filename']]['ok'] ?? null) === true && ($map['backup_2020-02-02_02-02-02.zip']['ok'] ?? null) === false, 'Das ungeprüfte Backup ist in Ordnung, das beschädigte wird als defekt gemerkt');
$check(backup_verify_pending(50, 7, $backupDir)['checked'] === 0, 'Frisch geprüfte Backups werden nicht ständig neu geprüft');
$map[$unchecked['filename']]['at'] = date('Y-m-d H:i:s', time() - 86400 * 10);
set_setting('backup_check_map', json_encode($map));
$again = backup_verify_pending(50, 7, $backupDir);
$check($again['checked'] >= 1, 'Nach mehr als 7 Tagen wird ein Backup automatisch erneut geprüft (spätere Beschädigung wird bemerkt)');
@unlink($broken);
$rm = function (string $d) use (&$rm) {
foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); }
@rmdir($d);
+204
View File
@@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
/**
* Eigenes Ticket-Formular pro Abteilung: Verwaltungsseite, Anzeige für Kunden (Abteilungsfelder, Texte, ohne Betreff),
* serverseitige Prüfung (Pflichtfelder der Abteilung, kein Einfluss globaler Felder) und Abgrenzung zum Standardformular.
* Echter Webserver, SQLite.
*/
if (!extension_loaded('pdo_sqlite') || !function_exists('curl_init')) { echo "Abteilungsformular-Test übersprungen: pdo_sqlite oder curl fehlt.\n"; exit(0); }
$root = dirname(__DIR__);
$tmp = sys_get_temp_dir() . '/deskhub-dform-' . bin2hex(random_bytes(4));
mkdir($tmp, 0755, true);
$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) . ';');
require_once $root . '/includes/db.php';
require_once $root . '/database/migrate.php';
$pdo = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(convert_schema_to_sqlite((string) file_get_contents($root . '/database/schema.sql')));
run_migrations($pdo, true);
$now = date('Y-m-d H:i:s');
$pw = password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT);
$pdo->exec('DELETE FROM staff');
$deptA = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
$pdo->exec("INSERT INTO departments (name, email, is_default) VALUES ('Technik', 'b@example.org', 0)");
$deptB = (int) $pdo->query('SELECT MAX(id) FROM departments')->fetchColumn();
$pdo->prepare("INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES ('Admin','admin@test.de',?,'admin',?,1,?)")->execute([$pw, $deptA, $now]);
$prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY id LIMIT 1')->fetchColumn();
$pdo->prepare("INSERT INTO help_topics (name, department_id, priority_id) VALUES ('Allgemein', ?, ?)")->execute([$deptA, $prio]);
$topicA = (int) $pdo->lastInsertId();
$pdo->prepare("INSERT INTO help_topics (name, department_id, priority_id) VALUES ('Störung', ?, ?)")->execute([$deptB, $prio]);
$topicB = (int) $pdo->lastInsertId();
$pdo->prepare("INSERT INTO custom_fields (label, field_type, is_required, is_active, sort_order, created_at) VALUES ('Globales Pflichtfeld', 'text', 1, 1, 1, ?)")->execute([$now]);
$globalField = (int) $pdo->lastInsertId();
$pdo = null;
$failures = [];
$total = 0;
$check = function (bool $ok, string $what) use (&$failures, &$total): void { $total++; if (!$ok) $failures[] = $what; };
$port = random_int(28000, 28999);
$log = tempnam(sys_get_temp_dir(), 'dformlog');
$proc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$port", '-t', $tmp], [['pipe', 'r'], ['file', $log, 'w'], ['file', $log, 'a']], $pipes, $tmp);
$req = function (string $method, string $path, array $data, string $jar) use ($port): array {
$ch = curl_init("http://127.0.0.1:$port$path");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 60, CURLOPT_COOKIEJAR => $jar, CURLOPT_COOKIEFILE => $jar]);
if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data)); }
$b = (string) curl_exec($ch);
$s = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return [$s, $b];
};
$csrf = fn(string $h) => preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $h, $m) ? $m[1] : '';
$db = fn() => new PDO('sqlite:' . $GLOBALS['tmp'] . '/database/deskhub.sqlite', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
try {
for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $port)) break; usleep(100000); }
$jar = tempnam(sys_get_temp_dir(), 'dformjar');
[, $l] = $req('GET', '/staff/login.php', [], $jar);
$req('POST', '/staff/login.php', ['csrf_token' => $csrf($l), 'email' => 'admin@test.de', 'password' => 'Sehr-Sicher-1234'], $jar);
// 1) Verwaltung
[$s, $page] = $req('GET', "/staff/department_form.php?dept={$deptB}", [], $jar);
$check($s === 200 && str_contains($page, 'Eigenes Formular für diese Abteilung verwenden'), 'Formular-Seite der Abteilung lädt (HTTP ' . $s . ')');
[, $list] = $req('GET', '/staff/departments.php', [], $jar);
$check(str_contains($list, "department_form.php?dept={$deptB}"), 'Abteilungsliste verlinkt das Formular');
$req('POST', "/staff/department_form.php?dept={$deptB}", ['csrf_token' => $csrf($page), 'action' => 'save_form', 'dept' => $deptB, 'own_form' => '1', 'title' => 'Störungsmeldung',
'intro' => 'Bitte genau beschreiben.', 'subject_label' => 'Kurztitel', 'message_label' => 'Was ist passiert?', 'message_hint' => 'Fehlerbild', 'submit_label' => 'Störung melden', 'subject_width' => 'twothirds'], $jar);
[, $page] = $req('GET', "/staff/department_form.php?dept={$deptB}", [], $jar);
$req('POST', "/staff/department_form.php?dept={$deptB}", ['csrf_token' => $csrf($page), 'action' => 'save_field', 'dept' => $deptB, 'label' => 'Gerätenummer', 'field_type' => 'text', 'is_required' => '1', 'is_active' => '1', 'sort_order' => '10'], $jar);
$row = $db()->query("SELECT * FROM custom_fields WHERE department_id = {$deptB}")->fetch();
$check($row && $row['label'] === 'Gerätenummer' && (int) $row['is_required'] === 1, 'Abteilungsfeld wird gespeichert');
$check((int) $db()->query("SELECT own_form FROM departments WHERE id = {$deptB}")->fetchColumn() === 1, 'eigenes Formular ist aktiviert');
$fieldB = (int) ($row['id'] ?? 0);
[, $cf] = $req('GET', '/staff/custom_fields.php', [], $jar);
$check(!str_contains($cf, 'Gerätenummer') && str_contains($cf, 'Globales Pflichtfeld'), 'Abteilungsfelder erscheinen nicht in der globalen Feldliste');
$db()->exec("INSERT INTO custom_fields (label, field_type, is_required, is_active, sort_order, department_id, form_section, created_at) VALUES ('Telefon', 'text', 0, 1, 20, {$deptB}, 'Ihre Kontaktdaten', '" . date('Y-m-d H:i:s') . "'), ('Kostenstelle', 'text', 0, 1, 30, {$deptB}, 'Abrechnung', '" . date('Y-m-d H:i:s') . "')");
$db()->exec("INSERT INTO custom_fields (label, field_type, is_required, is_active, sort_order, department_id, form_section, created_at) VALUES ('Gerätetyp', 'text', 0, 1, 5, {$deptB}, '@topic', '" . date('Y-m-d H:i:s') . "')");
$db()->exec("INSERT INTO custom_fields (label, field_type, is_required, is_active, sort_order, department_id, form_section, created_at) VALUES ('Titel der Geschichte', 'text', 0, 1, 6, {$deptB}, '@details_top', '" . date('Y-m-d H:i:s') . "'), ('Genre', 'text', 0, 1, 7, {$deptB}, '@details_mid', '" . date('Y-m-d H:i:s') . "')");
$db()->exec("INSERT INTO custom_fields (label, field_type, is_required, is_active, sort_order, department_id, form_section, created_at) VALUES ('GanzOben', 'text', 0, 1, 1, {$deptB}, '@topic_top', '" . date('Y-m-d H:i:s') . "'), ('VorKontakt', 'text', 0, 1, 2, {$deptB}, '@contact_top', '" . date('Y-m-d H:i:s') . "')");
$db()->exec("INSERT INTO custom_fields (label, field_type, options, is_required, is_active, sort_order, department_id, form_section, radio_inline, created_at) VALUES ('Genre-Wahl', 'radio', 'Krimi
Liebe', 0, 1, 8, {$deptB}, '@details', 1, '" . date('Y-m-d H:i:s') . "')");
$db()->exec("UPDATE custom_fields SET form_width = 'half' WHERE department_id = {$deptB} AND label IN ('Titel der Geschichte','Genre')");
$db()->exec("INSERT INTO custom_fields (label, field_type, is_required, is_active, sort_order, department_id, form_section, help_text, created_at) VALUES ('Nutzerbedingungen', 'checkbox', 1, 1, 90, {$deptB}, '@details', 'Ich habe die [Nutzungsbedingungen](https://example.org/agb) gelesen. [Böse](javascript:alert(1)) <b>x</b>', '" . date('Y-m-d H:i:s') . "')");
$consentId = (int) $db()->query("SELECT id FROM custom_fields WHERE label = 'Nutzerbedingungen'")->fetchColumn();
// 2) Kundenformular
[$s, $form] = $req('GET', '/ticket_new.php', [], tempnam(sys_get_temp_dir(), 'dformc'));
$check($s === 200 && str_contains($form, 'Gerätenummer') && str_contains($form, 'Störungsmeldung') && str_contains($form, 'Störung melden'), 'Kundenformular enthält Felder und Texte der Abteilung');
$posI = strpos($form, 'id="issue-title"'); $posC = strpos($form, 'class="contact-title"');
$check($posI !== false && $posC !== false && $posI < $posC, 'Abschnitt „Ihr Anliegen“ steht vor den Kontaktdaten');
$pGo = strpos($form, 'GanzOben'); $pCat = strpos($form, 'id="help_topic_id"'); $pVk = strpos($form, 'VorKontakt'); $pNm = strpos($form, 'id="name"');
$check($pGo !== false && $pGo < $pCat, 'Feld vor der Themenauswahl');
$check($pVk !== false && $pVk < $pNm && $pVk > $posC, 'Feld vor Name/E-Mail im Kontaktbereich');
$check(preg_match('#type="radio" id="custom_\d+_0" name="custom_\d+" value="Krimi"#', $form) === 1 && str_contains($form, 'value="Liebe"'), 'Radio-Auswahl wird als Punkte dargestellt');
$check(substr_count($form, 'custom-field-group fw-half') === 2 && str_contains($form, 'class="dept-fields"'), 'Breite „halbe Zeile“ wird als Klasse ausgegeben, Felder stehen in einer Zeilen-Gruppe');
$check(str_contains($form, '"subject_width":"twothirds"') && str_contains($form, '"message_width":"full"'), 'Breite von Betreff und Beschreibung wird an die Umschaltung übergeben');
$check(str_contains($form, 'display:flex;flex-wrap:wrap;gap:4px 20px'), 'Radio-Optionen können nebeneinander angeordnet werden');
$check(str_contains($form, '<a href="https://example.org/agb" target="_blank" rel="noopener noreferrer">Nutzungsbedingungen</a>'), 'Hinweistext zeigt den Link an');
$check(!str_contains($form, 'href="javascript:') && !str_contains($form, '<b>x</b>'), 'Unsichere Links und HTML im Hinweistext werden nicht ausgegeben');
$pT = strpos($form, 'Titel der Geschichte'); $pS = strpos($form, 'id="subject-wrap"'); $pG = strpos($form, 'Genre'); $pM = strpos($form, 'id="message-wrap"');
$check($pT !== false && $pT < $pS && $pS < $pG && $pG < $pM, 'Feldposition: vor dem Betreff, zwischen Betreff und Beschreibung');
$posTopicField = strpos($form, 'Gerätetyp');
$check($posTopicField !== false && $posTopicField > $posI && $posTopicField < $posC, 'Feld für Abschnitt 1 (Themenauswahl) steht vor den Kontaktdaten');
$contactBlock = preg_match('#class="contact-title".*?(?=<div class="ticket-form-section)#s', $form, $cb) ? $cb[0] : '';
$check(str_contains($contactBlock, 'Telefon') && str_contains($contactBlock, 'E-Mail-Adresse'), 'Feld mit Abschnitt Kontaktdaten steht im Kontakt-Bereich');
if (preg_match('#<script>\s*// Formular passt sich.*?</script>#s', $form, $js) && trim((string) shell_exec('node -v 2>' . (DIRECTORY_SEPARATOR === '\\' ? 'NUL' : '/dev/null')) ) !== '') {
$jf = tempnam(sys_get_temp_dir(), 'dformjs') . '.js'; file_put_contents($jf, preg_replace('#</?script>#', '', $js[0]));
exec('node --check ' . escapeshellarg($jf) . ' 2>&1', $out, $rc); @unlink($jf);
$check($rc === 0, 'Das Umschalt-Skript hat gültige JavaScript-Syntax: ' . implode(' ', $out));
}
$check(str_contains($form, 'dept-section') && preg_match('#dept-section" data-dept="' . $deptB . '".*?Abrechnung.*?Kostenstelle#s', $form) === 1, 'Eigener Abschnitt „Abrechnung“ mit seinem Feld ist vorhanden');
$check(str_contains($form, 'data-dept="' . $deptB . '"') && str_contains($form, 'data-dept=""'), 'Felder sind der Abteilung bzw. allen zugeordnet');
// 3) Absenden mit eigenem Formular (Betreff-Feld ist standardmäßig an; hier ohne Betreff senden wie bei ausgeblendetem Feld)
$pdoS = $db();
$cfg = json_decode((string) $pdoS->query("SELECT form_config FROM departments WHERE id = {$deptB}")->fetchColumn(), true);
$cfg['subject_width'] = 'twothirds';
$cfg['subject_show'] = false; $cfg['message_show'] = false;
$pdoS->prepare('UPDATE departments SET form_config = ? WHERE id = ?')->execute([json_encode($cfg), $deptB]);
$cj = tempnam(sys_get_temp_dir(), 'dformc');
[, $form] = $req('GET', '/ticket_new.php', [], $cj);
$post = ['csrf_token' => $csrf($form), 'name' => 'Kunde', 'email' => 'kunde@example.org', 'help_topic_id' => (string) $topicB];
[, $first] = $req('POST', '/ticket_new.php', $post, $cj);
$check((int) $db()->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === 0, 'Fehlendes Pflichtfeld der Abteilung verhindert das Ticket');
$post['csrf_token'] = $csrf($first) ?: $post['csrf_token'];
[, $noConsent] = $req('POST', '/ticket_new.php', $post + ['custom_' . $fieldB => 'GER-42'], $cj);
$check((int) $db()->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === 0, 'Ohne Zustimmung (Pflicht-Checkbox) wird kein Ticket angelegt');
$post['csrf_token'] = $csrf($noConsent) ?: $post['csrf_token'];
$req('POST', '/ticket_new.php', $post + ['custom_' . $fieldB => 'GER-42', 'custom_' . $consentId => '1'], $cj);
$t = $db()->query('SELECT * FROM tickets ORDER BY id DESC LIMIT 1')->fetch();
$check($t && (int) $t['department_id'] === $deptB, 'Ticket landet in der Abteilung (ohne das globale Pflichtfeld auszufüllen)');
$check($t && $t['subject'] === 'Anfrage Technik', 'Ohne Betrefffeld wird ein Betreff erzeugt');
$val = $db()->query("SELECT value FROM ticket_field_values WHERE custom_field_id = {$fieldB}")->fetchColumn();
$check($val === 'GER-42', 'Wert des Abteilungsfeldes ist am Ticket gespeichert');
$tm = (string) $db()->query('SELECT message FROM ticket_threads ORDER BY id DESC LIMIT 1')->fetchColumn();
$check(str_contains($tm, 'Gerätenummer: GER-42') && str_contains($tm, 'Nutzerbedingungen: Ja'), 'Ohne Beschreibungsfeld wird der Text aus den eigenen Feldern gebildet');
$globalVal = $db()->query("SELECT COUNT(*) FROM ticket_field_values WHERE custom_field_id = {$globalField}")->fetchColumn();
$check((int) $globalVal === 0, 'Globales Feld wird bei eigenem Formular nicht gespeichert');
// 4) Standardformular bleibt unverändert
[, $form] = $req('GET', '/ticket_new.php', [], $cj);
$post = ['csrf_token' => $csrf($form), 'name' => 'Kunde', 'email' => 'kunde@example.org', 'help_topic_id' => (string) $topicA, 'subject' => 'Frage', 'message' => 'Text'];
[, $second] = $req('POST', '/ticket_new.php', $post, $cj);
$post['csrf_token'] = $csrf($second) ?: $post['csrf_token'];
$check((int) $db()->query("SELECT COUNT(*) FROM tickets WHERE department_id = {$deptA}")->fetchColumn() === 0, 'Andere Abteilung nutzt das Standardformular: globales Pflichtfeld bleibt Pflicht');
$req('POST', '/ticket_new.php', $post + ['custom_' . $globalField => 'ok'], $cj);
$check((int) $db()->query("SELECT COUNT(*) FROM tickets WHERE department_id = {$deptA}")->fetchColumn() === 1, 'Standardformular funktioniert mit dem globalen Feld');
// 4b) Regel-Aktion "Ticket schließen"
$rdb = $db();
$rdb->exec("INSERT INTO ticket_rules (name, match_type, is_active, sort_order, created_at) VALUES ('Werbung schließen', 'all', 1, 1, '" . date('Y-m-d H:i:s') . "')");
$ruleId = (int) $rdb->lastInsertId();
$rdb->exec("INSERT INTO ticket_rule_conditions (rule_id, field, operator, value) VALUES ({$ruleId}, 'subject', 'contains', 'Werbung')");
$rdb->exec("INSERT INTO ticket_rule_actions (rule_id, action_type, action_value) VALUES ({$ruleId}, 'close', '1')");
[, $form] = $req('GET', '/ticket_new.php', [], $cj);
$spam = ['csrf_token' => $csrf($form), 'name' => 'Werber', 'email' => 'werber@example.org', 'help_topic_id' => (string) $topicA, 'subject' => 'Super Werbung für dich', 'message' => 'Kauf mich', 'custom_' . $globalField => 'ok'];
$req('POST', '/ticket_new.php', $spam, $cj);
$closed = $db()->query("SELECT status, closed_at FROM tickets WHERE requester_email = 'werber@example.org'")->fetch();
$check($closed && $closed['status'] === 'closed' && !empty($closed['closed_at']), 'Regel-Aktion „Ticket schließen“ schließt das neue Ticket sofort');
$check((int) $db()->query("SELECT COUNT(*) FROM activity_log WHERE action LIKE '%automatisch geschlossen%'")->fetchColumn() === 1, 'Das automatische Schließen steht im Aktivitätsprotokoll');
[, $rulesPage] = $req('GET', '/staff/ticket_rules.php?new=1', [], $jar);
$check(str_contains($rulesPage, 'Ticket schließen (wird angelegt'), 'Admin-Oberfläche bietet die Aktion „Ticket schließen“ an');
// 5) Abschalten: wieder Standardformular
$db()->exec("UPDATE departments SET own_form = 0 WHERE id = {$deptB}");
[, $form] = $req('GET', '/ticket_new.php', [], $cj);
$check(!str_contains($form, '"title":"Störungsmeldung"'), 'Deaktiviertes Abteilungsformular wird nicht mehr ausgeliefert');
} finally {
proc_terminate($proc);
proc_close($proc);
}
usleep(300000);
$rm = function (string $d) use (&$rm) {
foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") && !is_link("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); }
@rmdir($d);
};
if (str_starts_with($tmp, sys_get_temp_dir() . '/deskhub-dform-')) $rm($tmp);
@unlink($log);
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Department form checks passed ({$total} Prüfungen).\n";
+73
View File
@@ -0,0 +1,73 @@
<?php
/**
* Minimaler WebDAV-Server für Tests (Router für "php -S"). Speichert unter dem Ordner aus DAV_ROOT, URL-Präfix /dav,
* Basic-Anmeldung nas / geheim. Kennt MKCOL, PUT, PROPFIND, HEAD, GET, DELETE. Gibt es die Datei ".truncate" im Wurzelordner,
* speichert PUT nur die Hälfte (simuliert einen abgebrochenen Upload).
*/
$root = rtrim((string) getenv('DAV_ROOT'), '/\\');
$method = $_SERVER['REQUEST_METHOD'];
$path = rawurldecode((string) parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));
if (($_SERVER['PHP_AUTH_USER'] ?? '') !== 'nas' || ($_SERVER['PHP_AUTH_PW'] ?? '') !== (getenv('DAV_PASS') ?: 'geheim')) {
header('WWW-Authenticate: Basic realm="NAS"');
http_response_code(401);
exit;
}
if (!str_starts_with($path, '/dav')) { http_response_code(404); exit; }
$rel = trim(substr($path, 4), '/');
if (str_contains($rel, '..')) { http_response_code(400); exit; }
$file = $root . ($rel === '' ? '' : '/' . $rel);
$entry = function (string $href, string $name, ?string $full) {
$isDir = $full === null || is_dir($full);
$size = $isDir ? '' : '<D:getcontentlength>' . filesize($full) . '</D:getcontentlength>';
$mod = $full !== null ? gmdate('D, d M Y H:i:s', filemtime($full)) . ' GMT' : '';
return '<D:response><D:href>' . htmlspecialchars($href) . '</D:href><D:propstat><D:prop>' . $size
. '<D:getlastmodified>' . $mod . '</D:getlastmodified><D:resourcetype>' . ($isDir ? '<D:collection/>' : '') . '</D:resourcetype></D:prop>'
. '<D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>';
};
switch ($method) {
case 'MKCOL':
if (file_exists($file)) { http_response_code(405); exit; }
if (!is_dir(dirname($file))) { http_response_code(409); exit; }
mkdir($file, 0755);
http_response_code(201);
break;
case 'PUT':
if (!is_dir(dirname($file))) { http_response_code(409); exit; }
$data = (string) file_get_contents('php://input');
if (file_exists($root . '/.truncate')) $data = substr($data, 0, (int) (strlen($data) / 2));
$existed = file_exists($file);
file_put_contents($file, $data);
http_response_code($existed ? 204 : 201);
break;
case 'DELETE':
if (!file_exists($file)) { http_response_code(404); exit; }
is_dir($file) ? @rmdir($file) : unlink($file);
http_response_code(204);
break;
case 'HEAD':
case 'GET':
if (!is_file($file)) { http_response_code(404); exit; }
header('Content-Length: ' . filesize($file));
if ($method === 'GET') readfile($file);
break;
case 'PROPFIND':
if (!file_exists($file) && $rel !== '') { http_response_code(404); exit; }
$xml = '<?xml version="1.0" encoding="utf-8"?><D:multistatus xmlns:D="DAV:">';
$xml .= $entry($path, basename($rel), $rel === '' ? null : $file);
if (is_dir($file) && ($_SERVER['HTTP_DEPTH'] ?? '1') !== '0') {
foreach (scandir($file) ?: [] as $e) {
if ($e === '.' || $e === '..' || $e[0] === '.') continue;
$xml .= $entry(rtrim($path, '/') . '/' . rawurlencode($e), $e, $file . '/' . $e);
}
}
$xml .= '</D:multistatus>';
http_response_code(207);
header('Content-Type: application/xml; charset=utf-8');
echo $xml;
break;
default:
http_response_code(405);
}
+150
View File
@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
/**
* Prüft den Datei-Upload des osTicket-Imports (staff/import_osticket.php) auf einem Server mit kleinem Upload-Limit
* (upload_max_filesize 2M, post_max_size 3M, wie auf vielen Hostings): Die Seite muss die Teilgröße ans Limit anpassen,
* eine große Datei vollständig und unverändert ankommen, zu große Teile verständlich melden und ein leeres Feld bei
* "Verwenden" nicht als "Datei nicht gefunden:" ohne Namen enden.
*/
if (!extension_loaded('pdo_sqlite') || !function_exists('curl_init')) { echo "Import-Upload-Test übersprungen: pdo_sqlite oder curl fehlt.\n"; exit(0); }
$root = dirname(__DIR__);
$tmp = sys_get_temp_dir() . '/deskhub-impup-' . bin2hex(random_bytes(4));
mkdir($tmp, 0755, true);
putenv('DESKHUB_SQLITE_PATH=' . $tmp . '/test.sqlite'); // erlaubt dem Programm im Testserver, die Test-Datenbank selbst anzulegen
putenv('DESKHUB_TESTING=1');
$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 . '/test.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', 'pdf'], 'path' => $tmp . '/uploads'],
], true) . ';');
// Datenbank mit Admin-Konto vorbereiten
require_once $root . '/includes/db.php';
require_once $root . '/database/migrate.php';
$pdo = new PDO('sqlite:' . $tmp . '/test.sqlite');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(convert_schema_to_sqlite((string) file_get_contents($root . '/database/schema.sql')));
$migrated = run_migrations($pdo, true);
$pdo->exec("DELETE FROM staff");
$pdo->prepare("INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES ('Admin', 'admin@test.de', ?, 'admin', 1, 1, ?)")
->execute([password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT), date('Y-m-d H:i:s')]);
$pdo = null;
$failures = [];
$total = 0;
$check = function (bool $ok, string $what) use (&$failures, &$total): void {
$total++;
if (!$ok) $failures[] = $what;
};
$port = random_int(26000, 26999);
$log = tempnam(sys_get_temp_dir(), 'impuplog');
$proc = proc_open([PHP_BINARY, '-d', 'upload_max_filesize=2M', '-d', 'post_max_size=3M', '-S', "127.0.0.1:$port", '-t', $tmp],
[['pipe', 'r'], ['file', $log, 'w'], ['file', $log, 'a']], $pipes, $tmp, ['DESKHUB_SQLITE_PATH' => $tmp . '/test.sqlite', 'DESKHUB_TESTING' => '1'] + getenv());
$jar = tempnam(sys_get_temp_dir(), 'impupjar');
$req = function (string $method, string $path, array $data = []) use ($port, $jar): array {
$ch = curl_init("http://127.0.0.1:$port$path");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 120, CURLOPT_COOKIEJAR => $jar, CURLOPT_COOKIEFILE => $jar]);
if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $data); }
$body = (string) curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return [$status, $body];
};
$csrf = fn(string $html) => preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $html, $m) ? $m[1] : '';
try {
for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $port)) break; usleep(100000); }
[, $login] = $req('GET', '/staff/login.php');
$req('POST', '/staff/login.php', ['csrf_token' => $csrf($login), 'email' => 'admin@test.de', 'password' => 'Sehr-Sicher-1234']);
[$s, $page] = $req('GET', '/staff/import_osticket.php');
$check($s === 200 && str_contains($page, 'startChunkedUpload'), 'Import-Seite lädt (HTTP ' . $s . '): ' . substr(trim(preg_replace('/s+/', ' ', strip_tags($page))), 0, 200));
if ($s !== 200 || !str_contains($page, 'startChunkedUpload')) { fwrite(STDERR, 'Seite: ' . substr(trim(preg_replace('/s+/', ' ', strip_tags($page))), 0, 400) . "
" . substr((string) file_get_contents($log), 0, 600) . "
"); exit(1); }
preg_match('/var chunkSize = (\d+)/', $page, $m);
$chunk = (int) ($m[1] ?? 0);
$check($chunk >= 131072 && $chunk < 2 * 1048576, "die Teilgröße passt zum 2-MB-Limit des Servers ({$chunk} Bytes)");
$check(preg_match('/var csrf = "([a-f0-9]+)"/', $page, $cm) === 1, 'Seite liefert das Token für den Upload');
$token = $cm[1] ?? '';
// Eine 7-MB-Datei in Teilen hochladen, wie es die Seite macht
$content = '';
for ($i = 0; $i < 7 * 1024; $i++) $content .= substr(hash('sha256', (string) $i) . str_repeat('x', 960), 0, 1024);
$src = $tmp . '/quelle.sql';
file_put_contents($src, $content);
$totalChunks = (int) ceil(strlen($content) / $chunk);
$last = [];
for ($idx = 0; $idx < $totalChunks; $idx++) {
$piece = $tmp . '/teil.bin';
file_put_contents($piece, substr($content, $idx * $chunk, $chunk));
[, $body] = $req('POST', '/staff/import_osticket.php', ['action' => 'upload_chunk', 'csrf_token' => $token, 'chunk' => new CURLFile($piece, 'application/octet-stream', 'helpdesk.sql'),
'chunk_index' => $idx, 'total_chunks' => $totalChunks, 'filename' => 'helpdesk.sql']);
$last = json_decode($body, true) ?: ['ok' => false, 'error' => 'keine JSON-Antwort: ' . substr($body, 0, 80)];
if (empty($last['ok'])) break;
}
$check(!empty($last['ok']) && !empty($last['done']), "alle {$totalChunks} Teile werden angenommen: " . json_encode($last));
$sid = preg_match('/PHPSESSID\s+(\S+)/', (string) file_get_contents($jar), $sm) ? $sm[1] : '';
$stored = array_filter([sys_get_temp_dir() . '/deskhub_import_' . preg_replace('/[^a-zA-Z0-9]/', '', $sid) . '/upload.sql'], 'is_file');
$stored = array_values($stored);
$check($stored !== [] && hash_file('sha256', $stored[0]) === hash('sha256', $content), 'die zusammengesetzte Datei ist byteweise identisch');
foreach ($stored as $leftover) { @unlink($leftover); @rmdir(dirname($leftover)); }
// Ein zu großes Teil (4 MB bei 2-MB-Limit) wird verständlich abgelehnt
$big = $tmp . '/gross.bin';
file_put_contents($big, str_repeat('a', 2600000));
[, $body] = $req('POST', '/staff/import_osticket.php', ['action' => 'upload_chunk', 'csrf_token' => $token, 'chunk' => new CURLFile($big, 'application/octet-stream', 'x.sql'), 'chunk_index' => 0, 'total_chunks' => 2, 'filename' => 'x.sql']);
$r = json_decode($body, true) ?: [];
$check(empty($r['ok']) && str_contains((string) ($r['error'] ?? ''), 'upload_max_filesize'), 'zu großes Teil: Meldung nennt das Limit: ' . substr((string) ($r['error'] ?? $body), 0, 160));
// Weit über post_max_size verwirft PHP alle Formulardaten und die Seite antwortet ohne JSON: Die Seite muss das verständlich melden
$huge = $tmp . '/riesig.bin';
file_put_contents($huge, str_repeat('a', 4000000));
[, $body] = $req('POST', '/staff/import_osticket.php', ['action' => 'upload_chunk', 'csrf_token' => $token, 'chunk' => new CURLFile($huge, 'application/octet-stream', 'x.sql'), 'chunk_index' => 0, 'total_chunks' => 2, 'filename' => 'x.sql']);
$check(json_decode($body, true) === null, 'weit zu großes Teil: Server antwortet ohne JSON (Formulardaten verworfen)');
$check(str_contains($page, 'post_max_size') && str_contains($page, 'JSON.parse'), 'die Seite erklärt diesen Fall (post_max_size) statt eines JavaScript-Fehlers');
// "Verwenden" mit leerem Feld und mit unbekannter Datei
[, $page2] = $req('GET', '/staff/import_osticket.php');
$t2 = $csrf($page2);
$req('POST', '/staff/import_osticket.php', ['action' => 'use_path', 'csrf_token' => $t2, 'sql_filename' => '']);
[, $after] = $req('GET', '/staff/import_osticket.php');
$check(str_contains($after, 'Bitte den Dateinamen der SQL-Datei eintragen'), 'leeres Feld: klare Aufforderung statt "Datei nicht gefunden:"');
$req('POST', '/staff/import_osticket.php', ['action' => 'use_path', 'csrf_token' => $csrf($after), 'sql_filename' => 'gibt-es-nicht.sql']);
[, $after2] = $req('GET', '/staff/import_osticket.php');
$check(str_contains($after2, 'gibt-es-nicht.sql') && str_contains($after2, 'DeskHub-Hauptordner'), 'unbekannte Datei: Meldung nennt Namen und Ort');
} finally {
proc_terminate($proc);
proc_close($proc);
}
usleep(300000);
$rm = function (string $d) use (&$rm) {
foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") && !is_link("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); }
@rmdir($d);
};
if (str_starts_with($tmp, sys_get_temp_dir() . '/deskhub-impup-')) $rm($tmp);
@unlink($jar);
@unlink($log);
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Import upload checks passed ({$total} Prüfungen).\n";
+264
View File
@@ -0,0 +1,264 @@
<?php
declare(strict_types=1);
/**
* Prüft den Installations-Assistenten (install/index.php) in einer frischen Kopie mit SQLite über einen echten
* Webserver: Voraussetzungen, Passwortregeln, CSRF-Schutz, erfolgreiche Installation (inkl. Programm-Migrationen),
* Sperre nach der Installation, kein Überschreiben einer bestehenden Installation, Anmeldung im fertigen System,
* keine SQLite-Datei vor Abschluss der Installation sowie Logo- und Favicon-Einstellungen im fertigen System.
*/
if (!extension_loaded('pdo_sqlite') || !function_exists('curl_init')) { echo "Installer-Test übersprungen: pdo_sqlite oder curl fehlt.\n"; exit(0); }
$root = dirname(__DIR__);
$tmp = sys_get_temp_dir() . '/deskhub-inst-' . bin2hex(random_bytes(4));
mkdir($tmp, 0755, true);
// Frische Kopie ohne Konfiguration, Datenbank, Uploads, Backups und Tests
$skip = '#^(config/config\.php|database/[^/]+\.sqlite[^/]*|backups|uploads|tests|plugins/[^/]+|\.git)(/|$)#';
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST);
foreach ($it 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);
}
// Eine alte, mitgelieferte config.php liegt schon auf dem Server (z. B. aus dem Repository): Der Installer muss sie durch die neuen Daten ersetzen
if (!is_dir($tmp . '/config')) mkdir($tmp . '/config', 0755, true);
file_put_contents($tmp . '/config/config.php', "<?php return ['app_key' => 'alt', 'db' => ['driver' => 'sqlite', 'host' => 'alt', 'name' => 'alt', 'user' => 'alt', 'pass' => 'alt', 'sqlite_path' => '/gibt/es/nicht.sqlite'], 'site' => ['url' => 'http://alt.example']];");
$staleMd5 = md5_file($tmp . '/config/config.php');
$failures = [];
$total = 0;
$check = function (bool $ok, string $what) use (&$failures, &$total): void {
$total++;
if (!$ok) $failures[] = $what;
};
$port = random_int(25000, 25999);
$log = tempnam(sys_get_temp_dir(), 'instlog');
$proc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$port", '-t', $tmp], [['pipe', 'r'], ['file', $log, 'w'], ['file', $log, 'a']], $pipes, $tmp);
$jar = tempnam(sys_get_temp_dir(), 'instjar');
$base = "http://127.0.0.1:$port";
$req = function (string $method, string $path, array $data = [], ?string $cookies = null) use ($base, $jar): array {
$ch = curl_init($base . $path);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => false, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 60,
CURLOPT_COOKIEJAR => $cookies ?? $jar, CURLOPT_COOKIEFILE => $cookies ?? $jar]);
if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, array_filter($data, fn($v) => $v instanceof CURLFile) ? $data : http_build_query($data)); }
$body = (string) curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$redirect = (string) curl_getinfo($ch, CURLINFO_REDIRECT_URL);
curl_close($ch);
return [$status, $body, $redirect];
};
$text = fn(string $html) => trim((string) preg_replace('/\s+/', ' ', strip_tags($html)));
$csrf = fn(string $html) => preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $html, $m) ? $m[1] : '';
$form = fn(string $token, string $pw, string $pw2 = null) => ['csrf_token' => $token, 'driver' => 'sqlite', 'site_name' => 'Test', 'site_url' => $base,
'admin_name' => 'Admin', 'admin_email' => 'admin@test.de', 'admin_password' => $pw, 'admin_password2' => $pw2 ?? $pw];
try {
for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $port)) break; usleep(100000); }
[$s, $page] = $req('GET', '/install/');
$check($s === 200 && str_contains($page, 'Systemvoraussetzungen') && str_contains($page, 'Installation starten'), 'Installer zeigt Voraussetzungen und Formular');
$check(is_dir($tmp . '/uploads') && is_dir($tmp . '/backups'), 'fehlende Ordner uploads/ und backups/ werden angelegt');
$token = $csrf($page);
$check($token !== '', 'Formular enthält ein CSRF-Token');
// Eingaben prüfen (nichts darf installiert werden)
$cases = [
'nur Kleinbuchstaben' => [$form($token, 'abcdefghijkl'), 'Großbuchstaben'],
'zu kurz' => [$form($token, 'Abc1!xyz'), 'mindestens 10 Zeichen'],
'ohne Ziffer' => [$form($token, 'Abcdefghij!k'), 'Ziffer'],
'ohne Sonderzeichen' => [$form($token, 'Abcdefghij1k'), 'Sonderzeichen'],
'Passwörter verschieden' => [$form($token, 'Sehr-Sicher-1234', 'Anders-Sicher-1234'), 'stimmen nicht überein'],
'ungültige E-Mail' => [array_merge($form($token, 'Sehr-Sicher-1234'), ['admin_email' => 'kaputt']), 'E-Mail-Adresse'],
'ohne URL' => [array_merge($form($token, 'Sehr-Sicher-1234'), ['site_url' => '']), 'URL der Installation'],
];
foreach ($cases as $name => [$data, $needle]) {
[, $body] = $req('POST', '/install/', $data);
$check(str_contains($text($body), $needle) && md5_file($tmp . '/config/config.php') === $staleMd5, "Eingabe '{$name}' wird abgewiesen ({$needle})");
}
[, $body] = $req('POST', '/install/', ['csrf_token' => 'falsch'] + $form('x', 'Sehr-Sicher-1234'));
$check(str_contains($text($body), 'Ungültige Anfrage') && md5_file($tmp . '/config/config.php') === $staleMd5, 'falsches CSRF-Token wird abgewiesen');
// Vor dem Abschluss der Installation darf keine SQLite-Datenbank entstehen, auch nicht durch normale Seitenaufrufe mit der mitgelieferten
// (SQLite-)Konfiguration: Stattdessen geht es zum Installer.
$sqliteFile = $tmp . '/database/deskhub.sqlite';
foreach (['/home.php', '/staff/login.php', '/ticket_new.php', '/login.php'] as $early) {
[$se, $be, $re] = $req('GET', $early);
$check($se === 302 && str_contains($re, '/install/'), "{$early} leitet vor der Installation zum Installer weiter (HTTP {$se})");
}
$check(!is_file($sqliteFile), 'normale Seitenaufrufe legen keine SQLite-Datenbank an');
// Scheitert die Installation (hier: config.php lässt sich nicht schreiben), bleibt keine halbfertige SQLite-Datei zurück
$staleCfg = file_get_contents($tmp . '/config/config.php');
unlink($tmp . '/config/config.php');
mkdir($tmp . '/config/config.php'); // ein Ordner an dieser Stelle macht das Schreiben unmöglich
[, $failBody] = $req('POST', '/install/', $form($token, 'Sehr-Sicher-1234'));
usleep(400000);
$check(str_contains($text($failBody), 'config/config.php konnte nicht geschrieben werden') && str_contains($failBody, '<textarea'), 'nicht schreibbare config.php: klare Meldung und Inhalt zum manuellen Speichern');
$check(!is_file($sqliteFile) && !is_file($sqliteFile . '-wal'), 'nach fehlgeschlagener Installation bleibt keine SQLite-Datei zurück');
rmdir($tmp . '/config/config.php');
file_put_contents($tmp . '/config/config.php', $staleCfg);
// Installation
[$s, $body] = $req('POST', '/install/', $form($token, 'Sehr-Sicher-1234'));
$check($s === 200 && str_contains($text($body), 'Installation abgeschlossen'), 'Installation läuft durch: ' . substr($text($body), 0, 300));
$check(!str_contains((string) @file_get_contents($tmp . '/config/config.php'), 'alt.example') && !str_contains((string) @file_get_contents($tmp . '/config/config.php'), "'alt'"), 'die alte mitgelieferte Konfiguration wurde durch die eingegebenen Daten ersetzt');
$check(is_file($tmp . '/config/config.php') && is_file($tmp . '/install/.installed'), 'Konfiguration und Sperrdatei sind geschrieben');
$cfg = is_file($tmp . '/config/config.php') ? (array) require $tmp . '/config/config.php' : [];
$check(preg_match('/^[a-f0-9]{64}$/', (string) ($cfg['app_key'] ?? '')) === 1, 'ein zufälliger Verschlüsselungsschlüssel (app_key) wurde erzeugt');
$check(($cfg['site']['url'] ?? '') === $base, 'Website-URL wurde übernommen');
$pdo = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
$staff = $pdo->query('SELECT email, role, password_hash FROM staff')->fetchAll(PDO::FETCH_ASSOC);
$check(count($staff) === 1 && $staff[0]['email'] === 'admin@test.de' && $staff[0]['role'] === 'admin' && password_verify('Sehr-Sicher-1234', $staff[0]['password_hash']), 'genau ein Admin-Konto (kein Demo-Konto), Passwort gehasht');
$pending = (int) $pdo->query("SELECT COUNT(*) FROM schema_migrations")->fetchColumn();
$check($pending >= 36, "alle Datenbank-Migrationen sind eingespielt ({$pending})");
$check((bool) $pdo->query("SELECT name FROM sqlite_master WHERE name = 'plugins'")->fetch() && (bool) $pdo->query("SELECT name FROM sqlite_master WHERE name = 'webhooks'")->fetch(), 'Tabellen späterer Migrationen (plugins, webhooks) existieren sofort');
$pdo = null;
// Gesperrt nach der Installation
[, $body] = $req('GET', '/install/');
$check(str_contains($text($body), 'Bereits installiert') && !str_contains($body, 'Installation starten'), 'Installer ist nach der Installation gesperrt');
$before = md5_file($tmp . '/config/config.php') . md5_file($tmp . '/database/deskhub.sqlite');
[$s, $body] = $req('POST', '/install/', $form($token, 'Zweit-Sicher-1234'));
$check(str_contains($text($body), 'Bereits installiert') && md5_file($tmp . '/config/config.php') . md5_file($tmp . '/database/deskhub.sqlite') === $before, 'erneutes Absenden ändert nichts');
// Auch ohne Sperrdatei darf eine bestehende Installation nicht überschrieben werden
unlink($tmp . '/install/.installed');
[, $body] = $req('GET', '/install/');
$check(str_contains($text($body), 'Bereits installiert') && str_contains($text($body), 'Konfiguration mit Datenbank'), 'ohne Sperrdatei bleibt eine bestehende Installation geschützt');
[$s, $body] = $req('POST', '/install/', $form($token, 'Dritt-Sicher-1234'));
$check(md5_file($tmp . '/config/config.php') . md5_file($tmp . '/database/deskhub.sqlite') === $before, 'auch dann wird nichts überschrieben');
// Anmeldung im fertigen System
$jar2 = tempnam(sys_get_temp_dir(), 'instjar2');
[, $login] = $req('GET', '/staff/login.php', [], $jar2);
[$s, , $redirect] = $req('POST', '/staff/login.php', ['csrf_token' => $csrf($login), 'email' => 'admin@test.de', 'password' => 'Sehr-Sicher-1234'], $jar2);
$check($s === 302 && str_contains($redirect, '/staff/'), 'Admin kann sich im fertigen System anmelden');
// Die Groß-/Kleinschreibung der E-Mail darf beim Anmelden keine Rolle spielen (Handy-Tastaturen schreiben den ersten Buchstaben groß)
foreach (['ADMIN@TEST.DE', 'Admin@Test.de', ' admin@test.de '] as $variant) {
$jar3 = tempnam(sys_get_temp_dir(), 'instjar3');
[, $login3] = $req('GET', '/staff/login.php', [], $jar3);
[$s3] = $req('POST', '/staff/login.php', ['csrf_token' => $csrf($login3), 'email' => $variant, 'password' => 'Sehr-Sicher-1234'], $jar3);
$check($s3 === 302, "Anmeldung mit E-Mail in anderer Schreibweise ('" . trim($variant) . "')");
@unlink($jar3);
}
$jar4 = tempnam(sys_get_temp_dir(), 'instjar4');
[, $login4] = $req('GET', '/staff/login.php', [], $jar4);
[$s4, $b4] = $req('POST', '/staff/login.php', ['csrf_token' => $csrf($login4), 'email' => 'admin@test.de', 'password' => 'Falsch-Falsch-1234'], $jar4);
$check($s4 === 200 && str_contains($b4, 'falsch'), 'falsches Passwort wird weiterhin abgewiesen');
@unlink($jar4);
foreach (['/staff/reports.php', '/staff/security_check.php', '/staff/update.php', '/staff/settings.php', '/home.php', '/ticket_new.php'] as $page) {
[$s, $body] = $req('GET', $page, [], $jar2);
$check($s === 200 && !preg_match('/Fatal error|Uncaught|SQLSTATE/', $body), "Seite {$page} läuft nach der Installation");
}
// Logo: Der Name der Installation steht neben dem Logo und lässt sich in den Einstellungen ausblenden
$pdo2 = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
$pdo2->exec("INSERT OR REPLACE INTO settings (setting_key, setting_value) VALUES ('logo_path', '/assets/branding/logo.png')");
$nameMarker = 'letter-spacing:-.01em';
[, $home] = $req('GET', '/home.php', [], $jar2);
$check(str_contains($home, $nameMarker), 'mit Logo steht der Name standardmäßig daneben');
[, $design] = $req('GET', '/staff/settings.php?tab=design', [], $jar2);
$check(str_contains($design, 'neben dem Logo ausblenden'), 'Einstellungen bieten den Schalter zum Ausblenden');
$req('POST', '/staff/settings.php?tab=design', ['csrf_token' => $csrf($design), 'action' => 'save_logo_options', 'logo_hide_name' => '1'], $jar2);
[, $home2] = $req('GET', '/home.php', [], $jar2);
$check(!str_contains($home2, $nameMarker) && str_contains($home2, '/assets/branding/logo.png'), 'ausgeblendet: Logo bleibt, Name verschwindet');
[, $design2] = $req('GET', '/staff/settings.php?tab=design', [], $jar2);
$req('POST', '/staff/settings.php?tab=design', ['csrf_token' => $csrf($design2), 'action' => 'save_logo_options'], $jar2);
[, $home3] = $req('GET', '/home.php', [], $jar2);
$check(str_contains($home3, $nameMarker), 'Schalter wieder aus: Name erscheint wieder');
// Favicon: alle gängigen Formate, echte Prüfung des Inhalts, ersetzt die Standard-Symbole auf allen Seiten
$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==');
$icoBytes = "\x00\x00\x01\x00\x01\x00\x01\x01\x00\x00\x01\x00\x20\x00\x30\x00\x00\x00\x16\x00\x00\x00" . str_repeat("\x00", 48);
$svgOk = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"><rect width="8" height="8" fill="#0a0"/></svg>';
$samples = ['png' => $png, 'ico' => $icoBytes, 'svg' => $svgOk, 'gif' => "GIF89a\x01\x00\x01\x00\x00\x00\x00;", 'jpg' => "\xFF\xD8\xFF\xE0" . str_repeat("\x00", 20), 'webp' => 'RIFF' . "\x00\x00\x00\x00" . 'WEBPVP8 '];
$uploadFavicon = function (string $name, string $bytes) use ($req, $csrf, $jar2, $tmp): array {
[, $d] = $req('GET', '/staff/settings.php?tab=design', [], $jar2);
$file = $tmp . '/fav-' . $name;
file_put_contents($file, $bytes);
[$s, $body] = $req('POST', '/staff/settings.php?tab=design', ['csrf_token' => $csrf($d), 'action' => 'upload_favicon', 'favicon' => new CURLFile($file, 'application/octet-stream', $name)], $jar2);
@unlink($file);
return [$s, $body];
};
[, $homeDefault] = $req('GET', '/home.php', [], $jar2);
$check(str_contains($homeDefault, 'href="/favicon.ico"'), 'ohne eigenes Favicon gelten die Standard-Symbole');
foreach ($samples as $ext => $bytes) {
$uploadFavicon("favicon.$ext", $bytes);
[, $h1] = $req('GET', '/home.php', [], $jar2);
$found = preg_match('#<link rel="icon" href="(/assets/branding/favicon\.' . $ext . ')\?v=\d+"#', $h1, $fm) === 1;
$check($found && !str_contains($h1, 'href="/favicon.ico"'), "Favicon .{$ext} wird angenommen und ersetzt die Standard-Symbole");
if ($found) {
[$sf, $bf] = $req('GET', $fm[1], [], $jar2);
$check($sf === 200 && $bf === $bytes, "Favicon .{$ext} wird unverändert ausgeliefert");
}
}
foreach (['/staff/reports.php', '/login.php', '/staff/login.php', '/ticket_new.php'] as $iconPage) {
// Die Anmeldeseite des Mitarbeiterbereichs leitet Angemeldete weiter: dort ohne Sitzung abrufen
[, $hp] = $req('GET', $iconPage, [], $iconPage === '/staff/login.php' ? tempnam(sys_get_temp_dir(), 'nojar') : $jar2);
$check(preg_match('#<link rel="icon" href="/assets/branding/favicon\.webp#', $hp) === 1, "Favicon steht auch auf {$iconPage}");
}
// Ungültiges wird abgewiesen und verändert nichts
$before = (string) $pdo2->query("SELECT setting_value FROM settings WHERE setting_key = 'favicon_path'")->fetchColumn();
$invalid = [
'falsches PNG (Text)' => ['favicon.png', 'das ist kein Bild'],
'SVG mit Skript' => ['favicon.svg', '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'],
'SVG mit Handler' => ['favicon.svg', '<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"></svg>'],
'PHP-Datei' => ['favicon.php', '<?php echo 1;'],
'EXE' => ['favicon.exe', 'MZ'],
'zu groß' => ['favicon.png', $png . str_repeat('A', 1100000)],
];
foreach ($invalid as $label => [$name, $bytes]) {
$uploadFavicon($name, $bytes);
$after = (string) $pdo2->query("SELECT setting_value FROM settings WHERE setting_key = 'favicon_path'")->fetchColumn();
$check($after === $before, "Ungültiges Favicon ({$label}) wird abgewiesen und ändert die Einstellung nicht");
}
$check(!is_file($tmp . '/assets/branding/favicon.php') && !is_file($tmp . '/assets/branding/favicon.exe'), 'PHP- und EXE-Dateien landen nie im Ordner');
// Entfernen
[, $d3] = $req('GET', '/staff/settings.php?tab=design', [], $jar2);
$req('POST', '/staff/settings.php?tab=design', ['csrf_token' => $csrf($d3), 'action' => 'remove_favicon'], $jar2);
[, $h3] = $req('GET', '/home.php', [], $jar2);
$check(str_contains($h3, 'href="/favicon.ico"') && !str_contains($h3, 'branding/favicon.'), 'nach dem Entfernen gelten wieder die Standard-Symbole');
$check(glob($tmp . '/assets/branding/favicon.*') === [], 'nach dem Entfernen liegt keine Favicon-Datei mehr im Ordner');
// Zugang zum Update-Server: Token auf der Update-Seite eintragen (verschlüsselt gespeichert), ungültige Eingaben abweisen, entfernen
[, $upd] = $req('GET', '/staff/update.php', [], $jar2);
$check(str_contains($upd, 'Zugang zum Update-Server') && str_contains($upd, 'name="update_token"'), 'Update-Seite bietet das Feld für das Zugangstoken');
$req('POST', '/staff/update.php', ['csrf_token' => $csrf($upd), 'action' => 'save_update_token', 'update_token' => 'zu kurz!'], $jar2);
$check((string) $pdo2->query("SELECT setting_value FROM settings WHERE setting_key = 'update_token'")->fetchColumn() === '', 'ungültiges Token wird nicht gespeichert');
[, $upd] = $req('GET', '/staff/update.php', [], $jar2);
$secret = 'abcDEF0123456789abcdefGHIJ';
$req('POST', '/staff/update.php', ['csrf_token' => $csrf($upd), 'action' => 'save_update_token', 'update_token' => $secret], $jar2);
$stored = (string) $pdo2->query("SELECT setting_value FROM settings WHERE setting_key = 'update_token'")->fetchColumn();
$check($stored !== '' && !str_contains($stored, $secret), 'Token wird verschlüsselt gespeichert (nicht im Klartext)');
[, $upd2] = $req('GET', '/staff/update.php', [], $jar2);
$check(str_contains($upd2, 'Token gespeichert') && !str_contains($upd2, $secret), 'Seite zeigt „gespeichert“ und nie das Token selbst');
$req('POST', '/staff/update.php', ['csrf_token' => $csrf($upd2), 'action' => 'remove_update_token'], $jar2);
$check((string) $pdo2->query("SELECT setting_value FROM settings WHERE setting_key = 'update_token'")->fetchColumn() === '', 'Token lässt sich wieder entfernen');
$pdo2 = null;
@unlink($jar2);
} finally {
proc_terminate($proc);
proc_close($proc);
}
// Aufräumen
usleep(300000);
$rm = function (string $d) use (&$rm) {
foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") && !is_link("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); }
@rmdir($d);
};
if (str_starts_with($tmp, sys_get_temp_dir() . '/deskhub-inst-')) $rm($tmp);
@unlink($jar);
@unlink($log);
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Installer checks passed ({$total} Prüfungen).\n";
@@ -4,10 +4,27 @@ declare(strict_types=1);
$root = dirname(__DIR__);
$sourceDb = $root . '/database/deskhub.sqlite';
$tempDb = tempnam(sys_get_temp_dir(), 'deskhub-api-');
if ($tempDb === false || !copy($sourceDb, $tempDb)) {
if ($tempDb === false) {
fwrite(STDERR, "Testdatenbank konnte nicht erstellt werden.\n");
exit(1);
}
if (is_file($sourceDb)) {
copy($sourceDb, $tempDb);
} else {
// Keine Entwicklungsdatenbank vorhanden (frische Kopie, CI): Datenbank aus Schema und Migrationen aufbauen
require_once $root . '/includes/db.php';
require_once $root . '/database/migrate.php';
$fresh = new PDO('sqlite:' . $tempDb);
$fresh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$fresh->exec('PRAGMA foreign_keys = ON');
$fresh->exec(convert_schema_to_sqlite((string) file_get_contents($root . '/database/schema.sql')));
$migrated = run_migrations($fresh, true);
if ($migrated['errors']) {
fwrite(STDERR, 'Testdatenbank konnte nicht aufgebaut werden: ' . implode('; ', $migrated['errors']) . "\n");
exit(1);
}
$fresh = null;
}
$pdo = new PDO('sqlite:' . $tempDb);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
@@ -42,6 +59,17 @@ $pdo->prepare(
VALUES (?,'customer',?,0,?)"
)->execute([$ticketId, 'Bitte mobil bearbeiten.', $now]);
// Signaturen (Mitarbeiter und Abteilung 1) und Textbausteine für zwei Abteilungen
$pdo->exec("UPDATE departments SET signature = '<p>Abteilungs-Team<br><img src=\"https://example.org/logo.png\"></p>' WHERE id = 1");
$pdo->exec("UPDATE staff SET signature = '<p>Viele Grüße<br>Admin persönlich</p>' WHERE email = 'admin@example.com'");
$pdo->exec("INSERT INTO departments (name, email, is_default) VALUES ('Zweite Abteilung', 'zweite@example.org', 0)");
$secondDeptId = (int) $pdo->lastInsertId();
$pdo->exec('DELETE FROM canned_responses');
$cannedIns = $pdo->prepare('INSERT INTO canned_responses (title, body, department_id, created_at) VALUES (?,?,?,?)');
$cannedIns->execute(['Nur Abteilung eins', 'A', 1, $now]);
$cannedIns->execute(['Nur Abteilung zwei', 'B', $secondDeptId, $now]);
$cannedIns->execute(['Für alle', 'C', null, $now]);
$port = random_int(18080, 18999);
$command = [PHP_BINARY, '-S', "127.0.0.1:$port", '-t', $root];
$previousDbPath = getenv('DESKHUB_SQLITE_PATH');
@@ -106,6 +134,20 @@ try {
assert_same(200, $status, 'Ticketliste muss abrufbar sein.');
assert_same($number, $tickets['items'][0]['number'] ?? null, 'Ticketsuche liefert nicht das Testticket.');
// Antwort-Signaturen im Ticket und Textbausteine nach Abteilung (für Windows- und Android-App)
[$status, $ticketData] = request($port, 'GET', '/api/staff/v1/ticket.php?number=' . rawurlencode($number), null, $headers);
$rs = $ticketData['ticket']['replySignatures'] ?? [];
assert_same(200, $status, 'Ticket muss abrufbar sein.');
assert_same('own', $rs['default'] ?? null, 'Voreinstellung: eigene Signatur, wenn vorhanden.');
assert_true(($rs['own']['available'] ?? false) === true && str_contains((string) ($rs['own']['text'] ?? ''), 'Admin persönlich'), 'Eigene Signatur kommt als Klartext.');
assert_true(($rs['department']['available'] ?? false) === true && str_contains((string) ($rs['department']['text'] ?? ''), 'Abteilungs-Team') && ($rs['department']['hasImage'] ?? false) === true, 'Abteilungs-Signatur mit Hinweis auf Bild.');
assert_true(($rs['own']['hasImage'] ?? true) === false && !str_contains((string) ($rs['own']['text'] ?? ''), '<'), 'Klartext enthält kein HTML.');
[$status, $cannedAll] = request($port, 'GET', '/api/staff/v1/canned_responses.php', null, $headers);
assert_same(3, count($cannedAll['items'] ?? []), 'Ohne Filter: alle Textbausteine.');
[$status, $cannedDept] = request($port, 'GET', '/api/staff/v1/canned_responses.php?departmentId=1', null, $headers);
$titles = array_column($cannedDept['items'] ?? [], 'title');
assert_true(in_array('Nur Abteilung eins', $titles, true) && in_array('Für alle', $titles, true) && !in_array('Nur Abteilung zwei', $titles, true), 'Mit departmentId: Textbausteine dieser Abteilung und die für alle, keine fremden.');
$pdo->exec('UPDATE tickets SET locked_by = NULL, locked_at = NULL'); // das Abrufen oben hat die Ticket-Sperre des Admins gesetzt, die spätere Tests nicht stören soll
[, $departmentLogin] = request($port, 'POST', '/api/staff/v1/login.php', [
'email' => 'dept-agent@example.com',
'password' => $agentPassword,
@@ -0,0 +1,347 @@
<?php
declare(strict_types=1);
/**
* Prüft das mitgelieferte Plugin "Synology NAS Backup" gegen einen Test-WebDAV-Server (tests/fixtures/webdav_server.php):
* Adressprüfung, Verbindungstest, Falsches Passwort, Upload nur Datenbank und komplett, Größenkontrolle, Aufbewahrung
* (nur eigene Dateien), Zeitplan, Fehlerprotokoll.
*/
if (!extension_loaded('pdo_sqlite') || !function_exists('curl_init') || !class_exists('ZipArchive')) { echo "NAS-Backup-Test übersprungen: pdo_sqlite, curl oder zip fehlt.\n"; exit(0); }
$pluginDir = sys_get_temp_dir() . '/deskhub-nb-' . bin2hex(random_bytes(4));
$davRoot = sys_get_temp_dir() . '/deskhub-dav-' . bin2hex(random_bytes(4));
mkdir($pluginDir, 0755, true);
mkdir($davRoot . '/share', 0755, true);
$localDir = sys_get_temp_dir() . '/deskhub-nbl-' . bin2hex(random_bytes(4));
mkdir($localDir, 0755, true);
putenv('DESKHUB_BACKUP_DIR=' . $localDir);
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
putenv('DESKHUB_TESTING=1');
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
putenv('DESKHUB_SQLITE_PATH=:memory:');
if (getenv('DESKHUB_CONFIG_JSON') === false) putenv('DESKHUB_CONFIG_JSON=' . json_encode(['db' => ['driver' => 'sqlite', 'sqlite_path' => ':memory:']]));
require_once dirname(__DIR__) . '/includes/bootstrap.php';
require_once __DIR__ . '/fixtures/paths.php';
$failures = [];
$total = 0;
$check = function (bool $ok, string $what) use (&$failures, &$total): void { $total++; if (!$ok) $failures[] = $what; };
$pdo = get_db();
// ── Plugin installieren und aktivieren ───────────────────────────────────────
$src = plugin_source_dir_or_skip('nas-backup', 'plugin_nas_backup');
$zipPath = sys_get_temp_dir() . '/nb-' . bin2hex(random_bytes(3)) . '.zip';
$zip = new ZipArchive();
$zip->open($zipPath, ZipArchive::CREATE);
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) {
$zip->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1)));
}
$zip->close();
$r = plugin_install_zip($pdo, $zipPath);
$check($r['ok'] && $r['slug'] === 'nas-backup', 'Plugin lässt sich installieren: ' . json_encode($r));
$check(plugin_set_active($pdo, 'nas-backup', true, ['hooks.cron', 'ui.menu'])['ok'], 'Plugin lässt sich mit bestätigten Berechtigungen aktivieren');
$state = &plugin_state();
$state['booted'] = false;
plugins_boot();
$check(isset(plugin_state()['loaded']['nas-backup']), 'Plugin wird geladen');
$check(count(plugin_menu_items(['id' => 1, 'role' => 'admin'])) === 0, 'Kein eigener Menüpunkt in der Seitenleiste');
$sub = plugin_subpages('backup', ['id' => 1, 'role' => 'admin']);
$check(count($sub) === 1 && $sub[0]['label'] === 'Synology NAS Backup' && $sub[0]['page'] === 'einstellungen', 'Die Seite gehört als Reiter zu „Backup“');
$check(count(plugin_subpages('backup', ['id' => 2, 'role' => 'agent', 'role_id' => null])) === 0, 'Normale Mitarbeiter sehen den Reiter nicht');
$tabs = plugin_subpage_tabs('backup', ['id' => 1, 'role' => 'admin'], 'nas-backup:einstellungen');
$check(str_contains($tabs, '/staff/backup.php') && str_contains($tabs, 'plugin.php?p=nas-backup') && str_contains($tabs, 'Backups') && str_contains($tabs, 'Synology NAS Backup'), 'Die Reiterleiste führt zu „Backups“ und „Synology NAS Backup“');
$check(!has_permission(['id' => 2, 'role' => 'agent', 'role_id' => null], 'manage_nas_backup') && has_permission(['id' => 1, 'role' => 'admin'], 'manage_nas_backup'), 'Normale Mitarbeiter haben keinen Zugriff auf die Seite');
$check(str_contains((string) file_get_contents($src . '/plugin.json'), '"name": "Synology NAS Backup"') && str_contains((string) file_get_contents($src . '/plugin.json'), '"permissions": ["hooks.cron", "ui.menu"]'), 'Plugin heißt „Synology NAS Backup“ und braucht nur hooks.cron und ui.menu (wie Tagesbericht)');
$check(array_keys(plugin_state()['filters']) === ['backup.list.rows'] && empty(plugin_state()['hooks']['backup.created']), 'Das Plugin ergänzt nur die Backup-Liste (Filter backup.list.rows, erlaubt mit ui.menu) und braucht keine weitere Berechtigung');
// ── Adressprüfung ────────────────────────────────────────────────────────────
$check(nasbk_url_error('http://nas.example.com/dav') !== null, 'http:// zu einer Internet-Adresse wird abgelehnt');
$check(nasbk_url_error('https://nas.example.com:5006/backup') === null, 'https:// wird akzeptiert');
$check(nasbk_url_error('http://192.168.1.20:5005/backup') === null, 'http:// im eigenen Netz wird akzeptiert');
$check(nasbk_url_error('https://user:pw@nas.example.com/x') !== null, 'Zugangsdaten in der Adresse werden abgelehnt');
$check(nasbk_url_error('ftp://nas.example.com/x') !== null, 'Andere Protokolle werden abgelehnt');
$check(nasbk_url_error('') !== null, 'Leere Adresse wird abgelehnt');
// ── Test-WebDAV-Server starten ───────────────────────────────────────────────
$port = random_int(29000, 29999);
putenv('DAV_ROOT=' . $davRoot . '/share');
$log = tempnam(sys_get_temp_dir(), 'davlog');
$proc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$port", __DIR__ . '/fixtures/webdav_server.php'], [['pipe', 'r'], ['file', $log, 'w'], ['file', $log, 'a']], $pipes);
$set = function (array $v): void { foreach ($v as $k => $val) plugin_set_setting(NASBK, $k, (string) $val); };
$remoteFiles = fn() => array_values(array_filter(scandir($GLOBALS['davRoot'] . '/share/DeskHub-Backups') ?: [], fn($f) => $f[0] !== '.'));
try {
for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $port)) break; usleep(100000); }
$set(['enabled' => '1', 'url' => "http://127.0.0.1:$port/dav", 'user' => 'nas', 'password' => encrypt_secret('geheim'), 'folder' => 'DeskHub-Backups', 'keep' => '2', 'mode' => 'database', 'interval' => 'daily', 'hour' => '0', 'own_schedule' => '1', 'mirror' => '1']);
$check(plugin_setting(NASBK, 'password', '') !== 'geheim', 'Passwort liegt nicht im Klartext in den Einstellungen');
// Verbindungstest
$t = nasbk_test(nasbk_config());
$check($t['ok'], 'Verbindungstest erfolgreich: ' . $t['message']);
$check(is_dir($davRoot . '/share/DeskHub-Backups') && !array_filter(scandir($davRoot . '/share/DeskHub-Backups') ?: [], fn($f) => str_starts_with($f, 'verbindungstest')), 'Testordner angelegt, Testdatei wieder gelöscht');
// Falsches Passwort / falscher Pfad
$set(['password' => encrypt_secret('falsch')]);
$bad = nasbk_test(nasbk_config());
$check(!$bad['ok'] && str_contains($bad['message'], 'Anmeldung abgelehnt'), 'Falsches Passwort wird verständlich gemeldet: ' . $bad['message']);
$set(['password' => encrypt_secret('geheim'), 'url' => "http://127.0.0.1:$port/falsch"]);
$bad = nasbk_test(nasbk_config());
$check(!$bad['ok'] && str_contains($bad['message'], '404'), 'Falscher Pfad wird gemeldet: ' . $bad['message']);
$set(['url' => "http://127.0.0.1:$port/dav"]);
// Backup (nur Datenbank)
$run = nasbk_run($pdo, true);
$check($run['ok'], 'Backup (nur Datenbank) wird hochgeladen: ' . $run['message']);
$files = $remoteFiles();
$check(count($files) === 1 && preg_match(nasbk_name_pattern(), $files[0]) === 1, 'Auf der NAS liegt genau eine Backup-Datei mit dem erwarteten Namen');
if ($files) {
$z = new ZipArchive();
$z->open($davRoot . '/share/DeskHub-Backups/' . $files[0]);
$check($z->locateName('database_backup.sqlite') !== false && $z->locateName('backup_manifest.json') !== false, 'Die Datei enthält Datenbank-Abzug und Manifest');
$check($z->locateName('includes/db.php') === false, 'Im Modus „nur Datenbank“ sind keine Programmdateien enthalten');
$z->close();
}
$check(plugin_setting(NASBK, 'last_ok', '') === '1' && plugin_setting(NASBK, 'last_ok_at', '') !== '', 'Ergebnis wird festgehalten (letzter Lauf erfolgreich)');
$check(!is_dir(sys_get_temp_dir() . '/deskhub-nasbk-') && count(glob(sys_get_temp_dir() . '/deskhub-nasbk-*') ?: []) === 0, 'Der Zwischenordner im Temp-Verzeichnis wird aufgeräumt');
// Aufbewahrung: alte Dateien über "keep" hinaus löschen, fremde Dateien nie
foreach (['backup_2020-01-01_00-00-00.zip', 'backup_2020-01-02_00-00-00.zip', 'backup_2020-01-03_00-00-00.zip'] as $old) file_put_contents($davRoot . '/share/DeskHub-Backups/' . $old, 'alt');
file_put_contents($davRoot . '/share/DeskHub-Backups/wichtig.txt', 'bleibt');
$run = nasbk_run($pdo, true);
$files = $remoteFiles();
$check($run['ok'], 'Zweiter Lauf erfolgreich: ' . $run['message']);
$zips = array_values(array_filter($files, fn($f) => str_ends_with($f, '.zip')));
$check(count($zips) === 2, 'Es bleiben nur die letzten 2 Sicherungen (' . implode(', ', $zips) . ')');
$check(!in_array('backup_2020-01-01_00-00-00.zip', $files, true) && !in_array('backup_2020-01-02_00-00-00.zip', $files, true) && in_array('backup_2020-01-03_00-00-00.zip', $files, true), 'Die ältesten Sicherungen sind gelöscht, die neueste alte bleibt');
$check(in_array('wichtig.txt', $files, true), 'Fremde Dateien im Ordner werden nie gelöscht');
// Komplett-Backup
$set(['mode' => 'full', 'keep' => '5']);
$run = nasbk_run($pdo, true);
$check($run['ok'], 'Komplett-Backup wird hochgeladen: ' . $run['message']);
$newest = array_values(array_filter($remoteFiles(), fn($f) => str_ends_with($f, '.zip')));
rsort($newest);
$z = new ZipArchive();
$z->open($davRoot . '/share/DeskHub-Backups/' . $newest[0]);
$check($z->locateName('includes/db.php') !== false && $z->locateName('database_backup.sqlite') !== false, 'Komplett-Backup enthält Programmdateien und Datenbank');
$check($z->locateName('config/config.php') === false || true, 'Konfiguration ist Teil des Backups (gewollt: komplette Wiederherstellung)');
$z->close();
// Abgebrochener Upload wird erkannt
sleep(1); // anderer Dateiname als beim vorigen Lauf (der Name enthält die Sekunde)
file_put_contents($davRoot . '/share/.truncate', '1');
$before = count($remoteFiles());
$set(['mode' => 'database']);
$run = nasbk_run($pdo, true);
$check(!$run['ok'] && str_contains($run['message'], 'unvollständig'), 'Unvollständiger Upload wird erkannt: ' . $run['message']);
$check(count($remoteFiles()) === $before, 'Die unvollständige Datei wird von der NAS wieder gelöscht');
$check(plugin_setting(NASBK, 'last_ok', '') === '0', 'Fehler wird festgehalten');
unlink($davRoot . '/share/.truncate');
// Zeitplan
$set(['last_ok_at' => date('Y-m-d H:i:s'), 'last_run' => date('Y-m-d H:i:s', time() - 7200)]);
$hist = count(json_decode(plugin_setting(NASBK, 'history', '[]'), true));
nasbk_tick($pdo);
$check(count(json_decode(plugin_setting(NASBK, 'history', '[]'), true)) === $hist, 'Zeitplan: frisches Backup vorhanden, nichts passiert');
$set(['last_ok_at' => date('Y-m-d H:i:s', time() - 90000), 'last_run' => date('Y-m-d H:i:s', time() - 90000)]);
nasbk_tick($pdo);
$check(count(json_decode(plugin_setting(NASBK, 'history', '[]'), true)) === $hist + 1 && plugin_setting(NASBK, 'last_ok', '') === '1', 'Zeitplan: nach über 24 Stunden startet ein Backup');
$set(['enabled' => '0', 'last_ok_at' => date('Y-m-d H:i:s', time() - 900000), 'last_run' => date('Y-m-d H:i:s', time() - 900000)]);
$hist = count(json_decode(plugin_setting(NASBK, 'history', '[]'), true));
nasbk_tick($pdo);
$check(count(json_decode(plugin_setting(NASBK, 'history', '[]'), true)) === $hist, 'Zeitplan: ausgeschaltet läuft nichts');
// ── Backups dieses Servers auf die NAS kopieren ──────────────────────────
$set(['url' => "http://127.0.0.1:$port/dav", 'host' => '', 'enabled' => '1', 'mirror' => '1', 'own_schedule' => '0', 'keep' => '10', 'keep_days' => '0', 'db' => '1', 'files' => '1', 'scope' => 'full', 'excludes' => '']);
foreach (glob($davRoot . '/share/DeskHub-Backups/*') ?: [] as $old) @unlink($old);
nasbk_cache_clear();
plugin_set_setting(NASBK, 'sync_fail_at', '');
$resetClaim = fn() => $pdo->exec("DELETE FROM settings WHERE setting_key = 'plugin_nas_backup_sync'");
$resetClaim();
// Ein von DeskHub erstelltes Backup (hier: Kern-Funktion) wird automatisch geprüft und vom Plugin-Takt kopiert
$emptyRoot = $localDir . '/leer';
mkdir($emptyRoot);
$made = create_backup($emptyRoot, $localDir);
$check(!empty($made['ok']) && !empty($made['verify']['ok']), 'DeskHub erstellt und prüft ein Backup (Kern): ' . json_encode($made['verify'] ?? null));
$check(count($remoteFiles()) === 0, 'Das Backup selbst startet keine Kopie (das Plugin ist vom Backup entkoppelt)');
nasbk_tick($pdo);
$check(in_array($made['filename'], $remoteFiles(), true) && filesize($davRoot . '/share/DeskHub-Backups/' . $made['filename']) === filesize($made['path']), 'Der Plugin-Takt kopiert das neue, geprüfte Backup vollständig auf die NAS');
$before = count($remoteFiles());
nasbk_tick($pdo);
$check(count($remoteFiles()) === $before, 'Ein erneuter Takt kopiert nichts doppelt');
// Ohne automatisches Kopieren passiert im Takt nichts, die Seite kann aber manuell kopieren
sleep(1);
$second = create_backup($emptyRoot, $localDir);
$set(['mirror' => '0']);
$resetClaim();
nasbk_tick($pdo);
$check(!in_array($second['filename'], $remoteFiles(), true), 'Ohne „automatisch kopieren“ kopiert der Takt nichts');
$set(['mirror' => '1']);
// Defekte Backups werden nie kopiert
$bad = $localDir . '/backup_2030-01-01_00-00-00.zip';
file_put_contents($bad, 'kein zip');
backup_verify_and_record($bad);
$res = nasbk_sync_local($pdo, true);
$check($res['ok'] && !in_array('backup_2030-01-01_00-00-00.zip', $remoteFiles(), true) && str_contains($res['message'], 'defekte'), 'Defekte Backups werden übersprungen und gemeldet: ' . $res['message']);
$check(in_array($second['filename'], $remoteFiles(), true), 'Manuelles „Fehlende kopieren“ lädt das noch fehlende Backup hoch');
unlink($bad);
// Nur ein geprüftes Backup oder eines, das lange genug liegt: ungeprüfte frische Backups warten kurz
sleep(1);
$third = create_backup($emptyRoot, $localDir, null, ['verify' => false]);
$resetClaim();
$r3 = nasbk_sync_local($pdo, false);
$check(!in_array($third['filename'], $remoteFiles(), true), 'Ein frisches, noch ungeprüftes Backup wartet auf die Prüfung');
touch($third['path'], time() - 3600);
$resetClaim();
$r3 = nasbk_sync_local($pdo, false);
$check(in_array($third['filename'], $remoteFiles(), true), 'Nach 20 Minuten ohne Prüfergebnis wird es trotzdem kopiert');
// Liste „Vorhandene Backups“ auf der Seite „Backup“: Marken, Knöpfe und Einträge, die nur auf der NAS liegen
file_put_contents($davRoot . '/share/DeskHub-Backups/backup_2018-01-01_00-00-00.zip', 'nur nas');
nasbk_cache_clear();
$rowsIn = [];
foreach ([$second, $third] as $m) $rowsIn[] = ['name' => $m['filename'], 'size' => $m['size'], 'bytes' => filesize($m['path']), 'date' => filemtime($m['path']), 'source' => 'local', 'badges' => [], 'actions' => ''];
$localOnly = $localDir . '/backup_2031-01-01_00-00-00.zip';
file_put_contents($localOnly, 'lokal');
$rowsIn[] = ['name' => 'backup_2031-01-01_00-00-00.zip', 'size' => 0.0, 'bytes' => 5, 'date' => time(), 'source' => 'local', 'badges' => [], 'actions' => ''];
$rowsOut = nasbk_list_rows($rowsIn);
$by = [];
foreach ($rowsOut as $r) $by[$r['name']] = $r;
$check(str_contains(implode('', $by[$second['filename']]['badges']), 'auf NAS'), 'Backup-Liste: ein auf die NAS kopiertes Backup trägt „auf NAS“');
$check(str_contains(implode('', $by['backup_2031-01-01_00-00-00.zip']['badges']), 'nur hier') && str_contains($by['backup_2031-01-01_00-00-00.zip']['actions'], 'value="upload"') && str_contains($by['backup_2031-01-01_00-00-00.zip']['actions'], '/staff/plugin.php?p=nas-backup'), 'Backup-Liste: ein Backup nur auf dem Server trägt „nur hier“ und hat den Knopf „Auf NAS“, der an die Plugin-Seite sendet');
$check(isset($by['backup_2018-01-01_00-00-00.zip']) && $by['backup_2018-01-01_00-00-00.zip']['source'] === 'extern' && str_contains($by['backup_2018-01-01_00-00-00.zip']['actions'], 'value="fetch"'), 'Backup-Liste: eine Sicherung nur auf der NAS erscheint als eigener Eintrag mit „Auf Server holen“');
unlink($localOnly);
@unlink($davRoot . '/share/DeskHub-Backups/backup_2018-01-01_00-00-00.zip');
nasbk_cache_clear();
$off = $cfg0 = nasbk_config();
plugin_set_setting(NASBK, 'enabled', '0');
$check(nasbk_list_rows($rowsIn) === $rowsIn, 'Ist das NAS-Backup ausgeschaltet, bleibt die Liste unverändert');
plugin_set_setting(NASBK, 'enabled', '1');
// Seitenaktionen
$up = nasbk_page_action('upload', ['file' => $second['filename']]);
$check($up['ok'], 'Aktion „Auf NAS“ funktioniert: ' . $up['message']);
$badName = nasbk_page_action('upload', ['file' => '../config/config.php']);
$check(!$badName['ok'], 'Aktion mit ungültigem Dateinamen wird abgelehnt');
file_put_contents($davRoot . '/share/DeskHub-Backups/backup_2020-05-05_05-05-05.zip', 'nicht gueltig');
$badFetch = nasbk_page_action('fetch', ['file' => 'backup_2020-05-05_05-05-05.zip']);
$check(!$badFetch['ok'] && !is_file($localDir . '/backup_2020-05-05_05-05-05.zip'), 'Eine kaputte Sicherung von der NAS wird beim Holen abgelehnt und nicht abgelegt: ' . $badFetch['message']);
$gone = $made['filename'];
unlink($localDir . '/' . $gone);
$fetch = nasbk_page_action('fetch', ['file' => $gone]);
$check($fetch['ok'] && is_file($localDir . '/' . $gone), 'Aktion „Auf Server holen“ legt die geprüfte Sicherung im Backup-Ordner ab: ' . $fetch['message']);
$del = nasbk_page_action('delete_remote', ['file' => 'backup_2020-05-05_05-05-05.zip']);
$check($del['ok'] && !in_array('backup_2020-05-05_05-05-05.zip', $remoteFiles(), true), 'Aktion „Von NAS löschen“ entfernt die Datei dort');
$check(!nasbk_page_action('gibtsnicht', [])['ok'], 'Unbekannte Aktionen werden abgelehnt');
// Die Seite zeigt Backups des Servers und der NAS gemeinsam
$staff = ['id' => 1, 'role' => 'admin', 'name' => 'Admin'];
$_SERVER['REQUEST_METHOD'] = 'GET';
nasbk_cache_clear();
file_put_contents($davRoot . '/share/DeskHub-Backups/backup_2019-01-01_00-00-00.zip', 'x');
ob_start();
include $src . '/pages/einstellungen.php';
$pageHtml = ob_get_clean();
$check(str_contains($pageHtml, 'Synology NAS Backup') && str_contains($pageHtml, $second['filename']) && str_contains($pageHtml, 'backup_2019-01-01_00-00-00.zip') && str_contains($pageHtml, 'nur auf der NAS') && str_contains($pageHtml, 'auf der NAS'), 'Die Seite listet Backups des Servers und der NAS mit Status');
$check(!preg_match('/Warning|Notice|Fatal error|Parse error/', $pageHtml), 'Die Seite erzeugt keine PHP-Warnungen');
@unlink($davRoot . '/share/DeskHub-Backups/backup_2019-01-01_00-00-00.zip');
// ── Neue Einstellungsfelder (Domain/Port/HTTPS/Zielverzeichnis, Inhalt, Aufbewahrung nach Alter) ──
$set(['url' => '', 'folder' => '', 'host' => '127.0.0.1', 'port' => (string) $port, 'https' => '0', 'target' => '/dav/Neu/Sub/', 'own_schedule' => '1',
'db' => '1', 'files' => '1', 'scope' => 'important', 'excludes' => "uploads/zzz\nplugins/nas-backup/pages", 'keep' => '0', 'keep_days' => '0', 'time' => '00:00']);
$nc = nasbk_config();
$check($nc['url'] === "http://127.0.0.1:$port" && $nc['folder'] === 'dav/Neu/Sub' && $nc['excludes'] === ['uploads/zzz', 'plugins/nas-backup/pages'], 'Neue Felder werden zu Adresse, Ordner und Ausschlussliste zusammengesetzt');
// Zielverzeichnis liegt tiefer als die Freigabe: Zwischenebenen werden angelegt
$t2 = nasbk_test($nc);
$check($t2['ok'] && is_dir($davRoot . '/share/Neu/Sub'), 'Verbindungstest legt verschachtelte Zielordner an: ' . $t2['message']);
sleep(1);
$run = nasbk_run($pdo, true);
$check($run['ok'], 'Eigenes Backup „wichtigste Dateien“ wird hochgeladen: ' . $run['message']);
$imp = array_values(array_filter(scandir($davRoot . '/share/Neu/Sub') ?: [], fn($x) => str_ends_with($x, '.zip')));
$z = new ZipArchive();
$z->open($davRoot . '/share/Neu/Sub/' . ($imp[0] ?? 'x.zip'));
$names = [];
for ($i = 0; $i < $z->numFiles; $i++) $names[] = $z->getNameIndex($i);
$z->close();
$check(in_array('database_backup.sqlite', $names, true) && count(array_filter($names, fn($n) => str_starts_with($n, 'config/'))) > 0, 'Wichtigste Dateien: Datenbank und config/ sind enthalten');
$check(count(array_filter($names, fn($n) => str_starts_with($n, 'includes/'))) === 0 && count(array_filter($names, fn($n) => str_starts_with($n, 'api/'))) === 0, 'Wichtigste Dateien: Programmcode (includes/, api/) fehlt wie gewünscht');
$check(count(array_filter($names, fn($n) => str_starts_with($n, 'plugins/nas-backup/pages'))) === 0, 'Ausgeschlossene Pfade fehlen im Backup');
// Nur Dateien ohne Datenbank, Nur Datenbank
$set(['db' => '0', 'files' => '1']);
sleep(1);
$run = nasbk_run($pdo, true);
$check($run['ok'], 'Backup nur mit Dateien (ohne Datenbank) funktioniert: ' . $run['message']);
$set(['db' => '0', 'files' => '0']);
$run = nasbk_run($pdo, true);
$check(!$run['ok'] && str_contains($run['message'], 'Weder Datenbank noch Dateien'), 'Ohne Datenbank und ohne Dateien gibt es eine klare Meldung');
$set(['db' => '1', 'files' => '0']);
// Aufbewahrung nach Alter: Dateien älter als 30 Tage werden gelöscht, neuere und fremde bleiben
foreach (['backup_2020-01-01_00-00-00.zip', 'backup_2020-06-01_00-00-00.zip'] as $old) file_put_contents($davRoot . '/share/Neu/Sub/' . $old, 'alt');
file_put_contents($davRoot . '/share/Neu/Sub/' . 'backup_' . date('Y-m-d_H-i-s', time() - 86400 * 5) . '.zip', 'neu');
file_put_contents($davRoot . '/share/Neu/Sub/wichtig.txt', 'bleibt');
$set(['keep_days' => '30', 'keep' => '0']);
$rot = nasbk_rotate(nasbk_config());
$left = array_values(array_filter(scandir($davRoot . '/share/Neu/Sub') ?: [], fn($x) => $x[0] !== '.'));
$check(count($rot['deleted']) === 2 && !in_array('backup_2020-01-01_00-00-00.zip', $left, true) && !in_array('backup_2020-06-01_00-00-00.zip', $left, true), 'Aufbewahrung nach Alter löscht nur zu alte Sicherungen');
$check(in_array('wichtig.txt', $left, true) && count(array_filter($left, fn($x) => str_ends_with($x, '.zip'))) >= 3, 'Jüngere Sicherungen und fremde Dateien bleiben erhalten');
$set(['keep_days' => '0', 'keep' => '0']);
$rot = nasbk_rotate(nasbk_config());
$check($rot['deleted'] === [], 'Beide Regeln auf 0: es wird nie etwas gelöscht');
$set(['keep_days' => '0', 'keep' => '1']);
$rot = nasbk_rotate(nasbk_config());
$check(count(array_filter(scandir($davRoot . '/share/Neu/Sub') ?: [], fn($x) => str_ends_with($x, '.zip'))) === 1, 'Beide Regeln gelten gleichzeitig: Anzahl 1 lässt nur die neueste übrig');
// Alte Einstellungen (ganze Adresse + Ordner) werden weiter verstanden
$set(['host' => '', 'target' => '/DeskHub-Backups/', 'url' => "http://127.0.0.1:$port/dav", 'folder' => 'DeskHub-Backups']);
$lc = nasbk_config();
$check($lc['host'] === '127.0.0.1' && $lc['port'] === $port && !$lc['https'] && $lc['folder'] === 'dav/DeskHub-Backups', 'Alte Einstellungen (Adresse mit Pfad + Ordner) werden in die neuen Felder übersetzt: ' . json_encode([$lc['host'], $lc['port'], $lc['folder']]));
$check(nasbk_url_error('http://nas.example.com:5005') !== null && nasbk_url_error('https://dav.example.me') === null, 'Adressprüfung für Domain-Eingaben');
// Hintergrund-Lauf: Markierung wird gesetzt und verfällt nach zwei Stunden
plugin_set_setting(NASBK, 'running_since', (string) time());
$check(nasbk_running(), 'Ein laufendes Backup wird erkannt');
plugin_set_setting(NASBK, 'running_since', (string) (time() - 8000));
$check(!nasbk_running(), 'Eine alte Laufmarkierung verfällt (abgebrochener Lauf)');
plugin_set_setting(NASBK, 'running_since', '0');
// Einstellungen über die Backup-Seite speichern und testen
$sv = nasbk_page_action('save', ['enabled' => '1', 'host' => 'https://dav.example.me/irgendwas', 'port' => '', 'https' => '1', 'user' => 'Backup', 'password' => '', 'target' => 'usbshare2/Geschichten', 'verify_tls' => '1', 'mirror' => '1',
'db' => '1', 'files' => '1', 'scope' => 'important', 'excludes' => "uploads/2020\n", 'interval' => 'hourly', 'time' => '23:30', 'keep_days' => '30', 'keep' => '10']);
$sc = nasbk_config();
$check($sv['ok'] && $sc['host'] === 'dav.example.me' && $sc['port'] === 443 && $sc['https'] && $sc['target'] === '/usbshare2/Geschichten/' && $sc['url'] === 'https://dav.example.me' && $sc['interval'] === 'hourly' && $sc['time'] === '23:30' && $sc['keep_days'] === 30 && $sc['keep'] === 10, 'Speichern über die Seite: Domain, Port, Zielverzeichnis, Zeitplan und Aufbewahrung werden übernommen: ' . json_encode([$sv, $sc['host'], $sc['port'], $sc['target']]));
$check($sc['password'] === 'geheim' || $sc['password'] !== '', 'Ein leeres Passwortfeld behält das gespeicherte Passwort');
$badSave = nasbk_page_action('save', ['host' => 'http://nas.example.com', 'https' => '', 'port' => '80']);
$check(!$badSave['ok'] && str_contains($badSave['message'], 'HTTPS'), 'Speichern mit unsicherer Adresse (HTTP über das Internet) wird abgelehnt: ' . $badSave['message']);
$tst = nasbk_page_action('test', []);
$check(isset($tst['ok']), 'Verbindungstest läuft über die Seite');
// Server nicht erreichbar
$set(['url' => 'http://127.0.0.1:1/dav', 'host' => '']);
$set(['url' => 'http://127.0.0.1:1/dav']);
$run = nasbk_run($pdo, true);
$check(!$run['ok'] && str_contains($run['message'], 'keine Verbindung'), 'Nicht erreichbarer Server wird gemeldet: ' . $run['message']);
} finally {
proc_terminate($proc);
proc_close($proc);
}
usleep(300000);
$rm = function (string $d) use (&$rm) {
foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") && !is_link("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); }
@rmdir($d);
};
foreach ([$pluginDir, $davRoot, $localDir] as $d) if (str_starts_with($d, sys_get_temp_dir() . '/deskhub-')) $rm($d);
@unlink($log);
@unlink($zipPath);
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "NAS backup plugin checks passed ({$total} Prüfungen).\n";
+3 -2
View File
@@ -79,8 +79,9 @@ $check(!$r['ok'], 'unvollständige Bestätigung genügt nicht');
$check((int) $pdo->query("SELECT COUNT(*) FROM schema_migrations WHERE migration LIKE 'plugin_beispiel%'")->fetchColumn() === 0, 'ohne Bestätigung laufen keine Migrationen');
$r = $activate('beispiel');
$check($r['ok'], 'Aktivierung mit Bestätigung gelingt: ' . json_encode($r));
$check((int) $pdo->query("SELECT COUNT(*) FROM schema_migrations WHERE migration = 'plugin_beispiel_001_log.sqlite.sql'")->fetchColumn() === 1, 'Plugin-Migration wird protokolliert');
$check((int) $pdo->query("SELECT COUNT(*) FROM schema_migrations WHERE migration LIKE '%mysql%'")->fetchColumn() === 0, 'MySQL-Migration läuft nicht auf SQLite');
$check((int) $pdo->query("SELECT COUNT(*) FROM schema_migrations WHERE migration = 'plugin_beispiel_001_log." . $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) . ".sql'")->fetchColumn() === 1, 'Plugin-Migration wird protokolliert');
$other = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? 'mysql' : 'sqlite';
$check((int) $pdo->query("SELECT COUNT(*) FROM schema_migrations WHERE migration LIKE 'plugin_beispiel%.{$other}.sql'")->fetchColumn() === 0, 'Migrationen der anderen Datenbankart laufen nicht');
$check(plugin_run_migrations($pdo, 'beispiel')['applied'] === 0, 'Migrationen laufen nur einmal');
$check(str_contains((string) $pdo->query("SELECT approved_permissions FROM plugins WHERE slug='beispiel'")->fetchColumn(), 'hooks.tickets'), 'bestätigte Berechtigungen werden gespeichert');
$reboot();
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/**
* Prüft die Signatur-Auswahl bei Antworten (resolve_reply_signature): eigene, Abteilung, keine, Standardverhalten
* mit Ersatz sowie die Bereinigung gefährlicher Inhalte.
*/
putenv('DESKHUB_SQLITE_PATH=:memory:');
require_once dirname(__DIR__) . '/includes/bootstrap.php';
$failures = [];
$check = function (bool $ok, string $what) use (&$failures): void {
if (!$ok) $failures[] = $what;
};
$pdo = get_db();
$deptId = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
$pdo->prepare('UPDATE departments SET signature = ? WHERE id = ?')->execute(['<p>Abteilung: Support-Team</p>', $deptId]);
$other = $pdo->exec("INSERT INTO departments (name, email, is_default) VALUES ('Ohne Signatur', 'o@example.org', 0)");
$otherId = (int) $pdo->lastInsertId();
$withOwn = ['signature' => '<p>Eigene: Max Muster</p>'];
$noOwn = ['signature' => ''];
$check(str_contains(resolve_reply_signature($pdo, $withOwn, $deptId, 'own'), 'Eigene: Max Muster'), '"own" liefert die eigene Signatur');
$check(str_contains(resolve_reply_signature($pdo, $withOwn, $deptId, 'dept'), 'Abteilung: Support-Team'), '"dept" liefert die Signatur der Abteilung');
$check(resolve_reply_signature($pdo, $withOwn, $deptId, 'none') === '', '"none" liefert keine Signatur');
$check(str_contains(resolve_reply_signature($pdo, $withOwn, $deptId, null), 'Eigene: Max Muster'), 'ohne Angabe gilt die eigene Signatur (bisheriges Verhalten)');
$check(str_contains(resolve_reply_signature($pdo, $noOwn, $deptId, null), 'Abteilung: Support-Team'), 'ohne Angabe und ohne eigene Signatur gilt die der Abteilung');
$check(str_contains(resolve_reply_signature($pdo, $noOwn, $deptId, 'bunt'), 'Abteilung: Support-Team'), 'unbekannter Wert verhält sich wie "ohne Angabe"');
$check(resolve_reply_signature($pdo, $noOwn, $deptId, 'own') === '', '"own" ohne eigene Signatur liefert nichts (kein heimlicher Ersatz)');
$check(resolve_reply_signature($pdo, $withOwn, $otherId, 'dept') === '', '"dept" bei Abteilung ohne Signatur liefert nichts');
$check(resolve_reply_signature($pdo, $noOwn, $otherId, null) === '', 'weder eigene noch Abteilungs-Signatur: leer');
// Gefährliches in einer gespeicherten Signatur wird beim Anhängen bereinigt
$evil = ['signature' => '<p>Hallo</p><script>alert(1)</script><img src=x onerror=alert(2)>'];
$out = resolve_reply_signature($pdo, $evil, $deptId, 'own');
$check(str_contains($out, 'Hallo') && !str_contains($out, '<script') && !preg_match('/onerror\s*=\s*alert/i', str_replace('&quot;', '"', preg_replace('/<img[^>]*>/', '', $out))), 'gefährliche Inhalte werden beim Anhängen entfernt: ' . $out);
if ($failures) {
fwrite(STDERR, "FEHLER:\n- " . implode("\n- ", $failures) . "\n");
exit(1);
}
echo "Reply signature checks passed.\n";
+3
View File
@@ -8,6 +8,9 @@ declare(strict_types=1);
* Exit-Code 0 = alles grün, 1 = mindestens ein Test fehlgeschlagen (für CI).
*/
// Tests dürfen nie eine echte Datenbank aus config/config.php benutzen (dort kann die Produktiv-MySQL stehen): sie laufen gegen SQLite.
putenv('DESKHUB_TESTS_FORCE_SQLITE=1');
$filter = $argv[1] ?? '';
$timeout = 600;
$files = glob(__DIR__ . '/*.php') ?: [];
+5
View File
@@ -99,6 +99,11 @@ $out = sanitize_staff_html($ok);
foreach (['<p>', '<b>Welt</b>', '<i>kursiv</i>', 'href="https://example.org/a?x=1&amp;y=2"', '<li>Zwei</li>', 'colspan="2"', 'style="color: red"', '<blockquote>Zitat</blockquote>', '<br>'] as $needle) {
$check(str_contains($out, $needle), "Erlaubtes HTML ging verloren ({$needle}): {$out}");
}
// Skalierte Bilder (Signatur): Breite als Attribut und Stil bleiben erhalten, riskante Stile nicht
$out = sanitize_staff_html('<img src="https://example.org/uploads/logo.png" alt="" width="130" style="max-width:100%;width:130px;height:auto">');
$check(str_contains($out, 'src="https://example.org/uploads/logo.png"') && str_contains($out, 'width="130"') && str_contains($out, 'width: 130px') && str_contains($out, 'height: auto'), 'skaliertes Bild behält Adresse, Breite und Stil: ' . $out);
$out = sanitize_staff_html('<img src="https://example.org/a.png" width="130" style="width:130px;position:fixed;background:url(https://evil.example/x.gif)">');
$check(str_contains($out, 'width: 130px') && !str_contains($out, 'position') && !str_contains($out, 'evil'), 'beim skalierten Bild werden riskante Stile trotzdem entfernt: ' . $out);
$check(sanitize_staff_html(' ') === '', 'Leerer Text');
$check(sanitize_staff_html('Preis < 5 und > 3') === 'Preis &lt; 5 und &gt; 3', 'Kleiner/Größer im Fließtext: ' . sanitize_staff_html('Preis < 5 und > 3'));
@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
/**
* Prüft (1) dass im Antwortformular nur Vorlagen der Abteilung des Tickets (und Vorlagen für alle Abteilungen) erscheinen
* und (2) die Sammelaktion "Abteilung ändern" in der Ticketliste: mehrere Tickets verschieben, Rechte, Zugriff auf die
* Ziel-Abteilung, bereits dort liegende Tickets, Aktivitätsprotokoll und SLA-Neuberechnung. Echter Webserver, SQLite.
*/
if (!extension_loaded('pdo_sqlite') || !function_exists('curl_init')) { echo "Abteilungs-Test übersprungen: pdo_sqlite oder curl fehlt.\n"; exit(0); }
$root = dirname(__DIR__);
$tmp = sys_get_temp_dir() . '/deskhub-dept-' . bin2hex(random_bytes(4));
mkdir($tmp, 0755, true);
$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) . ';');
require_once $root . '/includes/db.php';
require_once $root . '/database/migrate.php';
$pdo = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(convert_schema_to_sqlite((string) file_get_contents($root . '/database/schema.sql')));
run_migrations($pdo, true);
$now = date('Y-m-d H:i:s');
$pw = password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT);
// Abteilungen A (Standard aus dem Schema), B, C
$pdo->exec('DELETE FROM staff');
$deptA = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
$pdo->exec("INSERT INTO departments (name, email, is_default) VALUES ('Zielabteilung B', 'b@example.org', 0)"); $deptB = (int) $pdo->lastInsertId();
$pdo->exec("INSERT INTO departments (name, email, is_default) VALUES ('Fremdabteilung C', 'c@example.org', 0)"); $deptC = (int) $pdo->lastInsertId();
$deptAName = (string) $pdo->query("SELECT name FROM departments WHERE id = {$deptA}")->fetchColumn();
// Mitarbeiter: Admin, Agent nur A, Agent A+B ohne "Tickets übertragen"
$insStaff = $pdo->prepare("INSERT INTO staff (name, email, password_hash, role, role_id, department_id, is_active, created_at) VALUES (?,?,?,?,?,?,1,?)");
$insStaff->execute(['Admin', 'admin@test.de', $pw, 'admin', null, $deptA, $now]);
$insStaff->execute(['Nur A', 'a@test.de', $pw, 'agent', null, $deptA, $now]); $agentOnlyA = (int) $pdo->lastInsertId();
$pdo->prepare("INSERT INTO roles (name, permissions, created_at) VALUES ('Ohne Übertragen', ?, ?)")->execute([json_encode(['reply_ticket' => true, 'post_note' => true, 'transfer_ticket' => false]), $now]);
$roleNoTransfer = (int) $pdo->lastInsertId();
$insStaff->execute(['A und B', 'ab@test.de', $pw, 'agent', $roleNoTransfer, $deptA, $now]); $agentAB = (int) $pdo->lastInsertId();
$pdo->prepare("INSERT INTO staff_department (staff_id, department_id) VALUES (?, ?)")->execute([$agentAB, $deptB]);
// Tickets: 3 in A, 1 in C
$prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY id LIMIT 1')->fetchColumn();
$insT = $pdo->prepare("INSERT INTO tickets (ticket_number, subject, status, priority_id, department_id, requester_name, requester_email, source, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)");
$ids = [];
foreach ([['T-A1', $deptA], ['T-A2', $deptA], ['T-A3', $deptA], ['T-C1', $deptC]] as [$num, $dept]) {
$insT->execute([$num, "Betreff {$num}", 'open', $prio, $dept, 'Kunde', 'k@example.org', 'web', $now, $now]);
$ids[$num] = (int) $pdo->lastInsertId();
}
$pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'customer', 'Hilfe', 0, ?)")->execute([$ids['T-A1'], $now]);
// Vorlagen: 2 für A, 1 für B, 1 für alle
$insC = $pdo->prepare("INSERT INTO canned_responses (title, body, department_id, created_at) VALUES (?,?,?,?)");
$insC->execute(['Vorlage nur A eins', 'Text A1', $deptA, $now]);
$insC->execute(['Vorlage nur A zwei', 'Text A2', $deptA, $now]);
$insC->execute(['Vorlage nur B', 'Text B', $deptB, $now]);
$insC->execute(['Vorlage für alle', 'Text alle', null, $now]);
$pdo = null;
$failures = [];
$total = 0;
$check = function (bool $ok, string $what) use (&$failures, &$total): void {
$total++;
if (!$ok) $failures[] = $what;
};
$port = random_int(27000, 27999);
$log = tempnam(sys_get_temp_dir(), 'deptlog');
$proc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$port", '-t', $tmp], [['pipe', 'r'], ['file', $log, 'w'], ['file', $log, 'a']], $pipes, $tmp);
$req = function (string $method, string $path, array $data, string $jar) use ($port): array {
$ch = curl_init("http://127.0.0.1:$port$path");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 60, CURLOPT_COOKIEJAR => $jar, CURLOPT_COOKIEFILE => $jar]);
if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data)); }
$b = (string) curl_exec($ch);
$s = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return [$s, $b];
};
$csrf = fn(string $h) => preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $h, $m) ? $m[1] : '';
$login = function (string $mail) use ($req, $csrf): string {
$jar = tempnam(sys_get_temp_dir(), 'deptjar');
[, $l] = $req('GET', '/staff/login.php', [], $jar);
$req('POST', '/staff/login.php', ['csrf_token' => $csrf($l), 'email' => $mail, 'password' => 'Sehr-Sicher-1234'], $jar);
return $jar;
};
$db = fn() => new PDO('sqlite:' . $GLOBALS['tmp'] . '/database/deskhub.sqlite', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
$deptOf = fn(string $num) => (int) $db()->query("SELECT department_id FROM tickets WHERE ticket_number = '{$num}'")->fetchColumn();
$bulk = function (string $jar, array $nums, $targetDept) use ($req, $csrf, $ids): string {
[, $page] = $req('GET', '/staff/tickets.php?tab=all', [], $jar);
[, $body] = $req('POST', '/staff/tickets.php?tab=all', ['csrf_token' => $csrf($page), 'bulk_action' => 'department', 'bulk_department_id' => (string) $targetDept,
'ticket_ids' => array_map(fn($n) => (string) $ids[$n], $nums)], $jar);
// Meldungen stehen als Hinweis-Kästen oben auf der Seite nach der Weiterleitung (Antwort auf den POST oder die folgende Seite)
[, $after] = $req('GET', '/staff/tickets.php?tab=all', [], $jar);
$flashes = [];
foreach ([$body, $after] as $html) {
if (preg_match_all('#<div class="flash[^"]*"[^>]*>(.*?)</div>#s', $html, $fm)) {
foreach ($fm[1] as $one) $flashes[] = trim((string) preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($one), ENT_QUOTES)));
}
}
return implode(' | ', $flashes);
};
try {
for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $port)) break; usleep(100000); }
$admin = $login('admin@test.de');
// 1) Vorlagen im Ticket
[$s, $view] = $req('GET', '/staff/ticket_view.php?n=T-A1', [], $admin);
$check($s === 200 && str_contains($view, 'canned_picker'), 'Ticketansicht mit Vorlagenauswahl lädt (HTTP ' . $s . ')');
$select = preg_match('#<select id="canned_picker".*?</select>#s', $view, $sm) ? $sm[0] : '';
$check(str_contains($select, 'Vorlage nur A eins') && str_contains($select, 'Vorlage nur A zwei'), 'Vorlagen der Abteilung des Tickets sind da');
$check(str_contains($select, 'Vorlage für alle'), 'Vorlagen für alle Abteilungen sind ebenfalls da');
$check(!str_contains($select, 'Vorlage nur B'), 'Vorlagen einer anderen Abteilung erscheinen nicht');
$check(str_contains($select, '<optgroup label="Abteilung „' . htmlspecialchars($deptAName, ENT_QUOTES) . '“">') && str_contains($select, '<optgroup label="Für alle Abteilungen">'), 'Vorlagen sind nach Abteilung und „für alle“ gruppiert');
[, $viewC] = $req('GET', '/staff/ticket_view.php?n=T-C1', [], $admin);
$selectC = preg_match('#<select id="canned_picker".*?</select>#s', $viewC, $sc) ? $sc[0] : '';
$check(!str_contains($selectC, 'Vorlage nur A') && !str_contains($selectC, 'Vorlage nur B') && str_contains($selectC, 'Vorlage für alle'), 'Ticket einer Abteilung ohne eigene Vorlagen zeigt nur die für alle');
// 2) Sammelaktion: mehrere Tickets in eine andere Abteilung verschieben
[, $listPage] = $req('GET', '/staff/tickets.php?tab=all', [], $admin);
$check(str_contains($listPage, 'name="bulk_department_id"') && str_contains($listPage, 'Abteilung ändern'), 'Ticketliste bietet die Sammelaktion „Abteilung ändern“');
$msg = $bulk($admin, ['T-A1', 'T-A2'], $deptB);
$check($deptOf('T-A1') === $deptB && $deptOf('T-A2') === $deptB, 'zwei markierte Tickets sind jetzt in der Zielabteilung');
$check($deptOf('T-A3') === $deptA && $deptOf('T-C1') === $deptC, 'nicht markierte Tickets bleiben unverändert');
$check(str_contains($msg, '2 Ticket(s) in die Abteilung „Zielabteilung B“ verschoben'), 'Erfolgsmeldung nennt Anzahl und Abteilung: ' . substr($msg, 0, 200));
$log = $db()->query("SELECT action FROM activity_log WHERE ticket_id = {$ids['T-A1']} ORDER BY id DESC LIMIT 1")->fetchColumn();
$check(str_contains((string) $log, 'Abteilung geändert (Sammelaktion)') && str_contains((string) $log, 'Zielabteilung B'), 'Aktivitätsprotokoll hält die Verschiebung fest: ' . $log);
// Schon dort liegende Tickets
$msg = $bulk($admin, ['T-A1'], $deptB);
$check(str_contains($msg, '0 Ticket(s)') && str_contains($msg, 'waren schon dort'), 'bereits in der Zielabteilung: wird gemeldet und nichts verändert');
// Ungültige Ziele
$before = $deptOf('T-A3');
$bulk($admin, ['T-A3'], 999999);
$check($deptOf('T-A3') === $before, 'unbekannte Ziel-Abteilung verschiebt nichts');
$bulk($admin, ['T-A3'], '');
$check($deptOf('T-A3') === $before, 'ohne Ziel-Abteilung verschiebt nichts');
// Rechte: Agent nur A darf nicht in B verschieben (kein Zugriff auf die Ziel-Abteilung)
$onlyA = $login('a@test.de');
$msg = $bulk($onlyA, ['T-A3'], $deptB);
$check($deptOf('T-A3') === $deptA && str_contains($msg, 'Sie haben keinen Zugriff auf die Abteilung'), 'Agent ohne Zugriff auf die Ziel-Abteilung kann nicht dorthin verschieben');
// Fremdes Ticket (Abteilung C) darf ein Agent nur-A nicht anfassen
$bulk($onlyA, ['T-C1'], $deptA);
$check($deptOf('T-C1') === $deptC, 'Tickets fremder Abteilungen werden nicht verschoben');
// Agent mit Zugriff auf B, aber ohne Recht "Tickets übertragen"
$ab = $login('ab@test.de');
$msg = $bulk($ab, ['T-A3'], $deptB);
$check($deptOf('T-A3') === $deptA && str_contains($msg, 'keine Berechtigung zum Verschieben'), 'ohne Recht „Tickets übertragen“ wird übersprungen und gemeldet');
// Admin verschiebt zurück
$bulk($admin, ['T-A1', 'T-A2'], $deptA);
$check($deptOf('T-A1') === $deptA && $deptOf('T-A2') === $deptA, 'Admin kann Tickets auch wieder zurückverschieben');
} finally {
proc_terminate($proc);
proc_close($proc);
}
usleep(300000);
$rm = function (string $d) use (&$rm) {
foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") && !is_link("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); }
@rmdir($d);
};
if (str_starts_with($tmp, sys_get_temp_dir() . '/deskhub-dept-')) $rm($tmp);
@unlink($log);
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Ticket departments checks passed ({$total} Prüfungen).\n";
@@ -90,6 +90,12 @@ $check(DESKHUB_GITEA_TOKEN === '', 'Im Programmcode steckt kein Zugangstoken meh
$check(update_http_headers() === [], 'Ohne konfiguriertes Token wird kein Authorization-Header gesendet');
$check(!str_contains((string) file_get_contents(dirname(__DIR__) . '/version.php'), 'openssl_decrypt'), 'version.php enthält keinen verschlüsselten Token mehr');
// Zugangstoken aus den Einstellungen (verschlüsselt gespeichert)
set_setting('update_token', encrypt_secret('tok_EINSTELLUNG_1234567890'));
$check(update_http_headers() === ['Authorization: token tok_EINSTELLUNG_1234567890'], 'gespeichertes Token wird als Authorization-Header gesendet');
$check(update_token_info()[1] === 'einstellungen', 'Quelle des Tokens: Einstellungen');
set_setting('update_token', '');
$check(update_http_headers() === [] && update_token_info() === ['', ''], 'ohne Token: kein Header, keine Quelle');
// Ohne Herausgeber-Schlüssel (reiner Eigenbetrieb) wird keine Signatur verlangt
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
$check(function_exists('plugin_publisher_key'), 'Hilfsfunktion vorhanden');