Files
deskhub/DeskHub-Webseite/tests/backup_restore.php
T
2026-10-19 18:05:51 +00:00

197 lines
16 KiB
PHP

<?php
declare(strict_types=1);
/**
* Prüft Backup, Prüfung und Wiederherstellung (includes/backup.php, includes/backup_restore.php) mit einer echten SQLite-Datenbank:
* Backup erstellen -> 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', "<?php // Konfiguration VOR dem Backup\n");
file_put_contents($root . '/includes/code.php', "<?php // Programmcode VOR dem Backup\n");
$now = db_now();
$prio = (int) $pdo->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', "<?php // Konfiguration NACH dem Backup\n");
file_put_contents($root . '/includes/code.php', "<?php // Programmcode NACH dem Backup\n");
$pdo->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";