Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
/**
* Wiederherstellung aus dem Backend.
*
* Eine Wiederherstellung ersetzt den laufenden Bestand. Die Zusagen, die hier
* festgehalten werden, betreffen deshalb vor allem die Reihenfolge: Ein Archiv
* wird geprüft, bevor irgendetwas geschrieben wird, und was als Quelle in Frage
* kommt, liegt am Ablageort – nicht dort, wohin ein Formularfeld zeigt.
*/
use OpenNIT\Audit\AuditLogger;
use OpenNIT\Backup\DatabaseDumper;
use OpenNIT\Backup\RestoreRunner;
use OpenNIT\Backup\SelfBackupManager;
use OpenNIT\Core\Database;
return static function (): void {
$wurzel = dirname(__DIR__);
$quelle = static fn (string $pfad): string => (string)file_get_contents($wurzel . '/' . $pfad);
$tmp = sys_get_temp_dir() . '/onit-restore-test-' . getmypid();
/** Räumt ein Verzeichnis samt Inhalt ab. */
$weg = static function (string $pfad) use (&$weg): void {
if (is_dir($pfad)) {
foreach (scandir($pfad) ?: [] as $eintrag) {
if ($eintrag !== '.' && $eintrag !== '..') {
$weg($pfad . '/' . $eintrag);
}
}
@rmdir($pfad);
} elseif (is_file($pfad)) {
@unlink($pfad);
}
};
/** Datenbank-Doppel: liefert den Ablageort und eine echte SQLite-Verbindung. */
$db = static function (string $ablageort): Database {
return new class($ablageort) extends Database {
private \PDO $pdo2;
public function __construct(private string $ablageort)
{
parent::__construct([]);
$this->pdo2 = new \PDO('sqlite::memory:');
$this->pdo2->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
}
public function getPdo(): \PDO
{
return $this->pdo2;
}
public function fetchOne(string $sql, array $params = []): ?array
{
return ($params[0] ?? '') === 'selfbackup_path' ? ['value' => $this->ablageort] : null;
}
public function fetchAll(string $sql, array $params = []): array
{
return [];
}
};
};
$audit = static function (): AuditLogger {
return new class extends AuditLogger {
/** @var list<array{art:string, risiko:string}> */
public array $eintraege = [];
public function __construct() {}
public function logWithUser(
int $userId, string $actionCategory, string $actionType, ?string $objectType = null,
?int $objectId = null, ?string $objectName = null, ?array $details = null,
string $result = 'success', string $riskLevel = 'low',
?array $oldValue = null, ?array $newValue = null
): int {
$this->eintraege[] = ['art' => $actionType, 'risiko' => $riskLevel];
return 1;
}
};
};
/**
* Legt ein brauchbares Archiv an: Beschreibungsblatt, Abzug mit Schlusszeile
* und eine Datei im Speicherverzeichnis.
*/
$archivBauen = static function (string $pfad, array $teile = ['database', 'storage']): void {
$zip = new ZipArchive();
$zip->open($pfad, ZipArchive::CREATE | ZipArchive::OVERWRITE);
$zip->addFromString(SelfBackupManager::MANIFEST, (string)json_encode([
'created_at' => '2026-01-02T03:04:05+00:00',
'version' => '9.9.9',
'parts' => $teile,
]));
if (in_array('database', $teile, true)) {
$zip->addFromString(
'database.sql',
"-- OpenNIT\nCREATE TABLE geraete (id INTEGER PRIMARY KEY, name TEXT);\n"
. "INSERT INTO geraete (id,name) VALUES (1,'Aus dem Archiv');\n"
. DatabaseDumper::END_MARKER . "\n"
);
}
if (in_array('storage', $teile, true)) {
$zip->addFromString('storage/notizen/haftnotiz.txt', 'aus dem Archiv');
$zip->addFromString('uploads/bild.txt', 'auch aus dem Archiv');
}
$zip->close();
};
// ----------------------------------------------------------------
// Auswahl der Quelle
// ----------------------------------------------------------------
test('Eine von Hand abgelegte Datei steht zur Auswahl', function () use ($tmp, $weg, $db, $audit) {
// Der Fall, für den es die Wiederherstellung gibt: Die Sicherung stammt
// von einer anderen Installation und steht in keiner Tabelle.
$ablage = $tmp . '/ablage-liste';
@mkdir($ablage . '/eingespielt', 0700, true);
file_put_contents($ablage . '/opennit-2026-01-01-000000.zip', 'x');
file_put_contents($ablage . '/eingespielt/fremd.zip', 'x');
file_put_contents($ablage . '/keine-sicherung.txt', 'x');
$datenbank = $db($ablage);
$runner = new RestoreRunner(
$datenbank, $audit(), $tmp, [],
new SelfBackupManager($datenbank, $audit(), $tmp, [])
);
$namen = array_column($runner->quellen(), 'filename');
assertTrue(in_array('opennit-2026-01-01-000000.zip', $namen, true), 'Die Datei am Ablageort fehlt');
assertTrue(in_array('eingespielt/fremd.zip', $namen, true), 'Das hochgeladene Archiv fehlt');
assertFalse(in_array('keine-sicherung.txt', $namen, true), 'Es wird auch angeboten, was kein Archiv ist');
$weg($ablage);
});
test('Als Quelle taugt nur, was am Ablageort liegt', function () use ($tmp, $weg, $db, $audit) {
// Der Name kommt aus einem Formular; aufgelöst wird deshalb nicht der
// übergebene Text.
$ablage = $tmp . '/ablage-pfad';
@mkdir($ablage . '/eingespielt', 0700, true);
file_put_contents($ablage . '/gut.zip', 'x');
file_put_contents($ablage . '/eingespielt/auch-gut.zip', 'x');
file_put_contents($tmp . '/daneben.zip', 'x');
$datenbank = $db($ablage);
$runner = new RestoreRunner(
$datenbank, $audit(), $tmp, [],
new SelfBackupManager($datenbank, $audit(), $tmp, [])
);
assertSame($ablage . '/gut.zip', $runner->quellpfad('gut.zip'));
assertSame($ablage . '/eingespielt/auch-gut.zip', $runner->quellpfad('eingespielt/auch-gut.zip'));
assertSame(null, $runner->quellpfad('../daneben.zip'), 'Ein Weg nach oben wird aufgelöst');
assertSame(null, $runner->quellpfad('eingespielt/../../daneben.zip'), 'Ein Weg nach oben wird aufgelöst');
assertSame(null, $runner->quellpfad($tmp . '/daneben.zip'), 'Ein absoluter Pfad wird angenommen');
assertSame(null, $runner->quellpfad('woanders/gut.zip'), 'Ein fremdes Unterverzeichnis wird angenommen');
assertSame(null, $runner->quellpfad('gibtsnicht.zip'), 'Eine fehlende Datei gilt als Quelle');
@unlink($tmp . '/daneben.zip');
$weg($ablage);
});
// ----------------------------------------------------------------
// Der Lauf
// ----------------------------------------------------------------
test('Ein brauchbares Archiv kommt vollständig an', function () use ($tmp, $weg, $db, $audit, $archivBauen) {
if (!class_exists('ZipArchive')) {
return;
}
$instanz = $tmp . '/instanz';
$ablage = $instanz . '/storage/backups';
@mkdir($ablage, 0700, true);
$archivBauen($ablage . '/stand.zip');
// Der Upload-Ort kommt aus der Konfiguration; er zeigt hier in die
// Testinstanz, nicht in die Vorgabe des Projektverzeichnisses.
$config = ['app' => ['paths' => ['uploads' => $instanz . '/storage/uploads']]];
$datenbank = $db($ablage);
$protokoll = $audit();
$runner = new RestoreRunner(
$datenbank, $protokoll, $instanz, $config,
new SelfBackupManager($datenbank, $audit(), $instanz, $config)
);
$ergebnis = $runner->lauf($ablage . '/stand.zip', ['database', 'storage'], 7, false);
assertTrue($ergebnis['ok'], 'Der Lauf meldet Beanstandungen: ' . $ergebnis['message']);
assertSame(
'Aus dem Archiv',
$datenbank->getPdo()->query('SELECT name FROM geraete')->fetchColumn(),
'Der Abzug ist nicht eingespielt'
);
assertTrue(
is_file($instanz . '/storage/notizen/haftnotiz.txt'),
'Das Speicherverzeichnis ist nicht geschrieben'
);
assertTrue(
is_file($instanz . '/storage/uploads/bild.txt'),
'Die Uploads sind nicht dorthin gekommen, wo diese Installation sie erwartet'
);
// Die Wartungsmarke hält Besucher draußen – und ist danach wieder fort.
assertFalse(is_file($instanz . '/storage/.maintenance'), 'Die Wartungsmarke bleibt liegen');
assertSame(
[['art' => 'selfbackup_restored', 'risiko' => 'critical']],
$protokoll->eintraege,
'Der Lauf steht nicht als schwerwiegender Vorgang im Protokoll'
);
$weg($instanz);
});
test('Ein beschädigtes Archiv wird abgewiesen, bevor etwas geschrieben wird', function () use ($tmp, $weg, $db, $audit) {
if (!class_exists('ZipArchive')) {
return;
}
$instanz = $tmp . '/instanz-kaputt';
$ablage = $instanz . '/storage/backups';
@mkdir($ablage, 0700, true);
// Ein Archiv ohne Beschreibungsblatt ist keine Sicherung.
$zip = new ZipArchive();
$zip->open($ablage . '/halb.zip', ZipArchive::CREATE | ZipArchive::OVERWRITE);
$zip->addFromString('storage/datei.txt', 'Inhalt');
$zip->close();
$config = ['app' => ['paths' => ['uploads' => $instanz . '/storage/uploads']]];
$datenbank = $db($ablage);
$protokoll = $audit();
$runner = new RestoreRunner(
$datenbank, $protokoll, $instanz, $config,
new SelfBackupManager($datenbank, $audit(), $instanz, $config)
);
$ergebnis = $runner->lauf($ablage . '/halb.zip', ['storage'], 7, false);
assertFalse($ergebnis['ok'], 'Ein Archiv ohne Beschreibungsblatt wird angenommen');
assertFalse(is_file($instanz . '/storage/datei.txt'), 'Es wurde trotzdem geschrieben');
assertSame([], $protokoll->eintraege, 'Ein folgenloser Abbruch steht als Wiederherstellung im Protokoll');
$weg($instanz);
});
test('Was das Archiv nicht enthält, wird nicht angefasst', function () use ($tmp, $weg, $db, $audit, $archivBauen) {
if (!class_exists('ZipArchive')) {
return;
}
$instanz = $tmp . '/instanz-teile';
$ablage = $instanz . '/storage/backups';
@mkdir($ablage, 0700, true);
$archivBauen($ablage . '/nur-dateien.zip', ['storage']);
$config = ['app' => ['paths' => ['uploads' => $instanz . '/storage/uploads']]];
$datenbank = $db($ablage);
$runner = new RestoreRunner(
$datenbank, $audit(), $instanz, $config,
new SelfBackupManager($datenbank, $audit(), $instanz, $config)
);
// Die Datenbank ist gewählt, liegt aber nicht im Archiv.
$ergebnis = $runner->lauf($ablage . '/nur-dateien.zip', ['database', 'storage'], 7, false);
assertTrue($ergebnis['ok'], 'Der Lauf meldet Beanstandungen: ' . $ergebnis['message']);
assertSame(['storage'], $ergebnis['parts'], 'Es wurde mehr eingespielt als enthalten ist');
assertTrue(is_file($instanz . '/storage/notizen/haftnotiz.txt'), 'Das Speicherverzeichnis fehlt');
$weg($instanz);
});
test('Ohne gewählten Teil geschieht nichts', function () use ($tmp, $weg, $db, $audit, $archivBauen) {
if (!class_exists('ZipArchive')) {
return;
}
$instanz = $tmp . '/instanz-leer';
$ablage = $instanz . '/storage/backups';
@mkdir($ablage, 0700, true);
$archivBauen($ablage . '/stand.zip', ['storage']);
$config = ['app' => ['paths' => ['uploads' => $instanz . '/storage/uploads']]];
$datenbank = $db($ablage);
$runner = new RestoreRunner(
$datenbank, $audit(), $instanz, $config,
new SelfBackupManager($datenbank, $audit(), $instanz, $config)
);
$ergebnis = $runner->lauf($ablage . '/stand.zip', ['config'], 7, false);
assertFalse($ergebnis['ok'], 'Ein Lauf ohne enthaltenen Teil gilt als geglückt');
assertFalse(is_file($instanz . '/storage/notizen/haftnotiz.txt'), 'Es wurde trotzdem geschrieben');
$weg($instanz);
});
// ----------------------------------------------------------------
// Zugang und Vorkehrungen
// ----------------------------------------------------------------
test('Zurückspielen hängt an einem eigenen Recht', function () use ($quelle) {
// Wer sichern darf, darf damit noch nicht den gesamten Bestand ersetzen.
$sql = $quelle('database/migrations/227_selfbackup_restore.sql');
assertTrue(str_contains($sql, "INSERT IGNORE INTO `capabilities`"), 'Das Recht wird nicht angelegt');
assertTrue(str_contains($sql, "'restore_selfbackup'"), 'Das Recht heißt anders');
assertTrue(str_contains($sql, "'critical'"), 'Das Recht gilt nicht als schwerwiegend');
$app = $quelle('src/Core/Application.php');
foreach (['restore', 'fetch'] as $aktion) {
$zeile = strstr($app, 'SelfBackupAdminController@' . $aktion . "'");
assertTrue(
is_string($zeile) && str_contains(substr($zeile, 0, 200), "'capability' => 'restore_selfbackup'"),
'Die Route ' . $aktion . ' hängt nicht am eigenen Recht'
);
}
});
test('Ein Versehen reicht nicht aus', function () use ($quelle) {
// Der Satz wird abgetippt, nicht angeklickt – und ohne ihn wird nichts
// angefasst.
$ctrl = $quelle('src/Controllers/Admin/SelfBackupAdminController.php');
$start = (int)strpos($ctrl, 'public function restore(');
$body = substr($ctrl, $start, 2600);
assertTrue(str_contains($body, "!== 'WIEDERHERSTELLEN'"), 'Es wird nicht ausdrücklich bestätigt');
// Die Bestätigung steht vor der Annahme des Archivs und vor dem Lauf.
$b = strpos($body, "!== 'WIEDERHERSTELLEN'");
$a = strpos($body, 'nimmAn(');
$l = strpos($body, '->lauf(');
assertTrue($b !== false && $a !== false && $l !== false && $b < $a && $a < $l, 'Die Reihenfolge stimmt nicht');
});
test('Vor dem Zurückspielen entsteht ein Rückfallpunkt', function () use ($quelle) {
$src = $quelle('src/Backup/RestoreRunner.php');
$start = (int)strpos($src, 'public function lauf(');
$body = substr($src, $start, 2600);
assertTrue(str_contains($body, "'restore', basename(\$pfad)"), 'Der Rückfallpunkt entsteht nicht');
// Er entsteht vor dem ersten Schreibvorgang, und sein Fehlschlag bricht ab.
$r = strpos($body, '$this->manager->run(');
$s = strpos($body, 'spieleDatenbank(');
assertTrue($r !== false && $s !== false && $r < $s, 'Er entsteht erst nach dem Schreiben');
assertTrue(str_contains($body, 'es wurde nichts verändert'), 'Ein Fehlschlag bricht den Lauf nicht ab');
// Und er darf die Quelle nicht aus der Aufbewahrung drängen.
$manager = $quelle('src/Backup/SelfBackupManager.php');
assertTrue(
str_contains($manager, "if (\$schonen !== '' && (string)\$zeile['filename'] === \$schonen) {"),
'Die Aufbewahrung räumt die Quelle des Laufs ab'
);
});
test('Ein hochgeladenes Archiv wird geprüft, bevor es als Quelle gilt', function () use ($quelle) {
$src = $quelle('src/Backup/RestoreRunner.php');
$start = (int)strpos($src, 'public function nimmAn(');
$body = substr($src, $start, 2600);
assertTrue(str_contains($body, 'pruefeDatei('), 'Das Hochgeladene wird nicht geprüft');
assertTrue(str_contains($body, '@unlink($pfad);'), 'Was die Prüfung nicht besteht, bleibt liegen');
assertTrue(str_contains($body, 'move_uploaded_file('), 'Die Datei wird nicht als Upload behandelt');
// Der Ablagename entsteht hier, nicht im Browser des Gegenübers.
assertTrue(str_contains($body, "preg_replace('/[^A-Za-z0-9._-]/'"), 'Der übergebene Name wird übernommen');
});
test('Jeder Schritt steht in der Zusammenfassung auf einer eigenen Zeile', function () use ($wurzel) {
// Gegen die gerenderte Seite, nicht gegen die Vorlage: Einen Umbruch
// unmittelbar hinter dem schließenden PHP-Tag verschluckt PHP, und alle
// Schritte stünden in einer Zeile.
$ansicht = new OpenNIT\Core\View($wurzel . '/templates');
$html = $ansicht->render('admin/selfbackup/restore-result', [
'result' => [
'ok' => true, 'message' => 'Durchgelaufen.', 'fallback' => null, 'manifest' => [],
'parts' => ['storage'], 'log' => ['Erster Schritt', 'Zweiter Schritt', 'Dritter Schritt'],
],
'source' => 'stand.zip',
'app_name' => 'OpenNIT',
]);
$block = (string)(preg_match('~<div class="rr-log">(.*?)</div>~s', $html, $t) ? $t[1] : '');
assertSame(
['Erster Schritt', 'Zweiter Schritt', 'Dritter Schritt'],
array_values(array_filter(array_map('trim', explode("\n", $block)), static fn (string $z): bool => $z !== '')),
'Die Schritte stehen nicht je auf einer Zeile'
);
});
test('Die Zusammenfassung kommt ohne Datenbank aus', function () use ($quelle) {
// Nach dem Einspielen gilt der Bestand aus dem Archiv; eine Seite, die
// Menü, Rechte oder Benutzer nachlädt, käme nicht mehr zustande.
$vorlage = $quelle('templates/admin/selfbackup/restore-result.php');
assertFalse(str_contains($vorlage, '$this->layout('), 'Die Seite hängt am Layout');
assertFalse(str_contains($vorlage, 'hasCapability('), 'Die Seite fragt Rechte ab');
assertTrue(str_contains($vorlage, '<!DOCTYPE html>'), 'Die Seite steht nicht für sich');
$ctrl = $quelle('src/Controllers/Admin/SelfBackupAdminController.php');
assertTrue(
str_contains($ctrl, "\$response->html(\$this->view->render('admin/selfbackup/restore-result'"),
'Die Zusammenfassung geht über den Weg mit Layout'
);
});
$weg($tmp);
};