Daten verändern/zerstören -> wiederherstellen -> alles ist wieder da. Dazu beschädigte, unvollständige und * manipulierte Backups, die nicht zurückgespielt werden dürfen. */ if (!class_exists('ZipArchive')) { echo "Backup-Tests übersprungen: PHP-ZIP fehlt.\n"; exit(0); } $tmp = sys_get_temp_dir() . '/dh-bk-' . bin2hex(random_bytes(4)); mkdir($tmp . '/root/uploads', 0755, true); mkdir($tmp . '/root/plugins/demo', 0755, true); mkdir($tmp . '/root/config', 0755, true); mkdir($tmp . '/root/includes', 0755, true); mkdir($tmp . '/backups', 0755, true); putenv('DESKHUB_SQLITE_PATH=' . $tmp . '/live.sqlite'); putenv('DESKHUB_TESTING=1'); putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php'); require_once dirname(__DIR__) . '/includes/bootstrap.php'; require_once dirname(__DIR__) . '/includes/backup_restore.php'; $failures = []; $total = 0; $check = function (bool $ok, string $what) use (&$failures, &$total): void { $total++; if (!$ok) $failures[] = $what; }; $pdo = get_db(); $root = $tmp . '/root'; $backupDir = $tmp . '/backups'; // ── Ausgangsdaten ──────────────────────────────────────────────────────────── file_put_contents($root . '/uploads/anhang.txt', "Wichtiger Anhang \u{00e4}\u{00f6}\u{00fc}"); file_put_contents($root . '/plugins/demo/plugin.json', '{"slug":"demo"}'); file_put_contents($root . '/config/config.php', "query('SELECT id FROM priorities ORDER BY id LIMIT 1')->fetchColumn(); $dept = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn(); $ins = $pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)'); foreach ([['B-1', "Drucker 'kaputt', (dringend) ✓"], ['B-2', 'Rechnung \\ fehlt'], ['B-3', 'Normal']] as [$num, $subj]) $ins->execute([$num, $subj, 'Kunde', 'k@example.test', $dept, $prio, 'open', $now, $now]); set_setting('display_name', 'Vor dem Backup'); $ticketsBefore = (int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn(); $usersBefore = (int) $pdo->query('SELECT COUNT(*) FROM staff')->fetchColumn(); $maxIdBefore = (int) $pdo->query('SELECT MAX(id) FROM tickets')->fetchColumn(); // ── Backup erstellen und prüfen ────────────────────────────────────────────── $bk = create_backup($root, $backupDir, $pdo); $check($bk['ok'] && is_file($bk['path']), 'Backup wird erstellt: ' . json_encode($bk)); $check(basename($bk['path']) === $bk['filename'] && str_starts_with($bk['filename'], 'backup_'), 'Backup hat den üblichen Dateinamen'); $zip = new ZipArchive(); $zip->open($bk['path']); $names = []; for ($i = 0; $i < $zip->numFiles; $i++) $names[] = $zip->getNameIndex($i); $manifest = json_decode((string) $zip->getFromName('backup_manifest.json'), true); $zip->close(); $check(in_array('database_backup.sqlite', $names, true) && in_array('uploads/anhang.txt', $names, true) && in_array('backup_manifest.json', $names, true), 'Backup enthält Datenbank-Abzug, Dateien und Manifest'); $check(($manifest['driver'] ?? '') === 'sqlite' && (int) $manifest['tables'] > 20 && ($manifest['version'] ?? '') === DESKHUB_VERSION, 'Manifest nennt Datenbanktyp, Tabellenzahl und Programmversion'); $check(!in_array('live.sqlite', $names, true) && !array_filter($names, fn($n) => str_ends_with($n, '.sqlite') && $n !== 'database_backup.sqlite'), 'die laufende Datenbankdatei wird nicht als Kopie ins Backup gelegt'); $v = backup_verify($bk['path']); $check($v['ok'] && $v['info']['driver'] === 'sqlite' && $v['info']['tickets'] === $ticketsBefore && $v['info']['staff'] === $usersBefore, 'Prüfung bestätigt das Backup und nennt die Anzahl Tickets und Mitarbeiter: ' . json_encode($v)); // ── Beschädigte oder manipulierte Backups werden erkannt ───────────────────── $makeZip = function (array $files): string { $p = tempnam(sys_get_temp_dir(), 'bz') . '.zip'; $z = new ZipArchive(); $z->open($p, ZipArchive::CREATE | ZipArchive::OVERWRITE); foreach ($files as $n => $c) $z->addFromString($n, $c); $z->close(); return $p; }; $check(!backup_verify($tmp . '/gibtesnicht.zip')['ok'], 'fehlende Datei wird erkannt'); $trunc = $tmp . '/abgeschnitten.zip'; file_put_contents($trunc, substr((string) file_get_contents($bk['path']), 0, (int) (filesize($bk['path']) / 2))); $check(!backup_verify($trunc)['ok'], 'abgeschnittenes Backup wird erkannt'); $check(!backup_verify($makeZip(['uploads/a.txt' => 'x']))['ok'], 'Backup ohne Datenbank-Abzug wird erkannt'); $check(!backup_verify($makeZip(['database_backup.sqlite' => str_repeat('kein sqlite ', 200)]))['ok'], 'Datenbank-Abzug, der keine SQLite-Datei ist, wird erkannt'); $check(!backup_verify($makeZip(['../evil.txt' => 'x', 'database_backup.sqlite' => 'x']))['ok'], 'Pfad mit ".." im Backup wird abgelehnt'); $corrupt = $tmp . '/korrupt.sqlite'; $z0 = new ZipArchive(); $z0->open($bk['path']); file_put_contents($corrupt, $z0->getFromName('database_backup.sqlite')); $z0->close(); $raw = (string) file_get_contents($corrupt); file_put_contents($corrupt, substr($raw, 0, 100) . str_repeat("\0", 2000) . substr($raw, 2100)); $check(!backup_verify($makeZip(['database_backup.sqlite' => (string) file_get_contents($corrupt)]))['ok'], 'beschädigte Datenbank im Backup wird erkannt (Integritätsprüfung)'); $noTickets = new PDO('sqlite::memory:'); $onlySettings = tempnam(sys_get_temp_dir(), 'ns'); $o = new PDO('sqlite:' . $onlySettings); $o->exec('CREATE TABLE settings (k TEXT)'); $o = null; $check(!backup_verify($makeZip(['database_backup.sqlite' => (string) file_get_contents($onlySettings)]))['ok'], 'Datenbank ohne die Kerntabellen (tickets, users …) wird erkannt'); // ── Zerstören und wiederherstellen ─────────────────────────────────────────── $pdo->exec('DELETE FROM tickets'); $pdo->exec("INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, created_at, updated_at) VALUES ('NACH-1', 'Nach dem Backup', 'X', 'x@example.test', {$dept}, {$prio}, 'open', '{$now}', '{$now}')"); set_setting('display_name', 'Nach dem Backup'); unlink($root . '/uploads/anhang.txt'); unlink($root . '/plugins/demo/plugin.json'); file_put_contents($root . '/config/config.php', "exec("INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES ('Nur nach Backup', 'neu@example.test', 'x', 'agent', {$dept}, 1, '{$now}')"); $check((int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === 1, 'Ausgangslage: Daten wurden verändert'); $countBackups = fn() => count(glob($backupDir . '/backup_*.zip')); $beforeCount = $countBackups(); $r = backup_restore($pdo, $bk['path'], ['root' => $root, 'backup_dir' => $backupDir]); $check($r['ok'] && $r['tables'] > 20 && $r['rows'] > 0 && $r['files'] >= 2, 'Wiederherstellung läuft durch: ' . json_encode($r)); $check($countBackups() === $beforeCount + 1 && !empty($r['safety']) && is_file($backupDir . '/' . $r['safety']), 'vor der Wiederherstellung wird ein Sicherheits-Backup erstellt'); $check((int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === $ticketsBefore, 'alle Tickets sind wieder da'); $subjects = $pdo->query('SELECT subject FROM tickets ORDER BY ticket_number')->fetchAll(PDO::FETCH_COLUMN); $check($subjects === ["Drucker 'kaputt', (dringend) ✓", 'Rechnung \\ fehlt', 'Normal'], 'Inhalte mit Sonderzeichen sind unverändert: ' . json_encode($subjects, JSON_UNESCAPED_UNICODE)); $check((int) $pdo->query("SELECT COUNT(*) FROM tickets WHERE ticket_number = 'NACH-1'")->fetchColumn() === 0, 'Daten, die erst nach dem Backup entstanden, sind weg'); $check((int) $pdo->query('SELECT COUNT(*) FROM staff')->fetchColumn() === $usersBefore, 'Mitarbeiter-Stand entspricht dem Backup'); get_setting('display_name'); // Zwischenspeicher der Einstellungen $check($pdo->query("SELECT setting_value FROM settings WHERE setting_key = 'display_name'")->fetchColumn() === 'Vor dem Backup', 'Einstellungen sind zurückgesetzt'); $pdo->exec("INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, created_at, updated_at) VALUES ('B-4', 'Neu', 'X', 'x@example.test', {$dept}, {$prio}, 'open', '{$now}', '{$now}')"); $check((int) $pdo->query("SELECT id FROM tickets WHERE ticket_number = 'B-4'")->fetchColumn() === $maxIdBefore + 1, 'die Nummernvergabe läuft nach der Wiederherstellung am richtigen Punkt weiter'); $check(file_exists($root . '/uploads/anhang.txt') && str_contains((string) file_get_contents($root . '/uploads/anhang.txt'), 'Wichtiger Anhang'), 'hochgeladene Dateien sind wieder da'); $check(is_file($root . '/plugins/demo/plugin.json'), 'Plugins sind wieder da'); $check(str_contains((string) file_get_contents($root . '/config/config.php'), 'NACH dem Backup'), 'die Konfiguration wird nie überschrieben'); $check(str_contains((string) file_get_contents($root . '/includes/code.php'), 'NACH dem Backup'), 'Programmdateien werden nie überschrieben (dafür gibt es das System-Update)'); // ── Optionen und Schutz ────────────────────────────────────────────────────── unlink($root . '/uploads/anhang.txt'); $pdo->exec("UPDATE tickets SET subject = 'geändert'"); $r2 = backup_restore($pdo, $bk['path'], ['root' => $root, 'backup_dir' => $backupDir, 'dirs' => [], 'database' => true]); $check($r2['ok'] && !file_exists($root . '/uploads/anhang.txt') && $pdo->query("SELECT COUNT(*) FROM tickets WHERE subject = 'geändert'")->fetchColumn() == 0, 'ohne Dateien wird nur die Datenbank zurückgespielt'); $pdo->exec("UPDATE tickets SET subject = 'noch einmal geändert'"); $r3 = backup_restore($pdo, $bk['path'], ['root' => $root, 'backup_dir' => $backupDir, 'database' => false, 'dirs' => ['uploads']]); $check($r3['ok'] && file_exists($root . '/uploads/anhang.txt') && $pdo->query("SELECT COUNT(*) FROM tickets WHERE subject = 'noch einmal geändert'")->fetchColumn() > 0, 'ohne Datenbank werden nur die gewählten Ordner zurückgespielt'); $bad = backup_restore($pdo, $trunc, ['root' => $root, 'backup_dir' => $backupDir]); $check(!$bad['ok'] && str_contains($bad['error'], 'nicht wiederherstellbar') && $pdo->query("SELECT COUNT(*) FROM tickets WHERE subject = 'noch einmal geändert'")->fetchColumn() > 0, 'ein beschädigtes Backup wird nicht zurückgespielt, die Daten bleiben unangetastet'); $foreign = $makeZip(['database_backup.sql' => "-- x\nSET FOREIGN_KEY_CHECKS=0;\nDROP TABLE IF EXISTS `tickets`;\nCREATE TABLE `tickets` (id int);\nCREATE TABLE `users` (id int);\nCREATE TABLE `staff` (id int);\nCREATE TABLE `settings` (id int);\nSET FOREIGN_KEY_CHECKS=1;\n"]); $vm = backup_verify($foreign); $check($vm['ok'] && $vm['info']['driver'] === 'mysql' && $vm['info']['tables'] === 4, 'MySQL-Abzug wird erkannt und geprüft'); $mismatch = backup_restore($pdo, $foreign, ['root' => $root, 'backup_dir' => $backupDir]); $check(!$mismatch['ok'] && str_contains($mismatch['error'], 'MYSQL') && str_contains($mismatch['error'], 'SQLITE'), 'ein Backup eines anderen Datenbanktyps wird abgelehnt'); $incomplete = $makeZip(['database_backup.sql' => "SET FOREIGN_KEY_CHECKS=0;\nCREATE TABLE `tickets` (id int);\nCREATE TABLE `users` (id int);\nCREATE TABLE `staff` (id int);\nCREATE TABLE `settings` (id int);\nINSERT INTO `tickets` (id) VALUES\n(1),\n"]); $check(!backup_verify($incomplete)['ok'], 'abgebrochener MySQL-Abzug (Ende fehlt) wird erkannt'); // SQL-Zerlegung: Semikolons und Zeilenumbrüche in Texten $sqlFile = $tmp . '/t.sql'; file_put_contents($sqlFile, "-- Kommentar\nSET A=1;\n\nINSERT INTO t (a) VALUES\n('x; y'),\n('it\\'s; here'),\n('z');\nSELECT 2;\n"); $stmts = iterator_to_array(backup_sql_statements($sqlFile), false); $check(count($stmts) === 3 && str_contains($stmts[1], "it\\'s; here") && $stmts[2] === 'SELECT 2;', 'SQL-Anweisungen werden richtig getrennt (Semikolon in Texten, Anführungszeichen mit Backslash)'); // Prüfergebnis merken, Hinweis im System, sicherer Zugriff auf Backup-Dateien $rec = backup_verify_and_record($bk['path']); $saved = json_decode((string) get_setting('backup_last_check', ''), true); $check($rec['ok'] && ($saved['ok'] ?? null) === true && $saved['file'] === $bk['filename'], 'das Prüfergebnis wird gespeichert'); $check(!array_filter(system_health($pdo), fn($i) => $i['code'] === 'backup_broken'), 'ein gutes Backup löst keinen Hinweis aus'); backup_verify_and_record($trunc); $broken = array_values(array_filter(system_health($pdo), fn($i) => $i['code'] === 'backup_broken')); $check($broken && $broken[0]['level'] === 'error' && str_contains($broken[0]['message'], 'nicht wiederherstellbar'), 'ein nicht wiederherstellbares Backup erzeugt einen Fehler-Hinweis im System'); 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); }; $rm($tmp); @unlink($onlySettings); if ($failures) { fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n"); exit(1); } echo "Backup restore checks passed ({$total} Prüfungen).\n";