OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

SelfBackupRestoreTest.php

18 KB
<?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);
};