197 lines
16 KiB
PHP
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";
|