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', ' 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&page=einstellungen'), 'Auf der Seite „Backup“ gibt es den Reiter „Synology NAS Backup“'); $sidebar = preg_match('#|#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, '

Synology NAS Backup

') && 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, 'location.replace') && !str_contains($b, 'Fatal error'), 'Nach dem Speichern ersetzt sich die Seite durch die normale Adresse (kein erneutes Absenden beim Neuladen, HTTP ' . $s . ')'); [, $b] = $req('GET', $pluginUrl, [], $jar); $check(str_contains($b, 'Einstellungen gespeichert'), 'Einstellungen speichern meldet Erfolg'); $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(str_contains($b, 'location.replace'), 'Auch nach „Fehlende kopieren“ lädt die Seite die normale Adresse: Neuladen (alle 10 s) oder F5 sendet den Klick nicht noch einmal ab'); $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“'); // Das Kopieren läuft seit Plugin 2.3 im Hintergrund (nach der Antwort): die Seite nach dem Klick neu laden [, $b] = $req('GET', $pluginUrl, [], $jar); $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";