OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

SelfBackupTest.php

19 KB
<?php

declare(strict_types=1);

use OpenNIT\Backup\BackupRestorer;
use OpenNIT\Backup\DatabaseDumper;
use OpenNIT\Backup\SelfBackupManager;

/**
 * Deckt die Sicherung der eigenen Installation ab.
 *
 * Eine Sicherung enthält alles, was die Installation ausmacht: den Inhalt der
 * Datenbank samt Tresor und – wenn die Konfiguration mitgeht – den Schlüssel,
 * mit dem sich dieser lesen lässt. Daraus folgen die beiden Zusagen, die hier
 * festgehalten werden: Der Ablageort darf nicht im Webroot liegen, und die
 * Konfiguration ist nicht von sich aus dabei.
 *
 * Der dritte Punkt ist die Prüfung. Eine Sicherung, die niemand je geöffnet
 * hat, ist eine Vermutung – die Prüfung liest deshalb jeden Eintrag wirklich,
 * und der Rückweg wird hier gegen ein echtes Archiv und eine echte Datenbank
 * gefahren, nicht gegen eine Zeichenkette.
 */
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-backup-test-' . getmypid();
    @mkdir($tmp, 0700, true);

    /** 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);
        }
    };

    // ----------------------------------------------------------------
    // Rückweg: SQL einspielen
    // ----------------------------------------------------------------

    test('Ein Abzug lässt sich wieder einspielen', function () {
        // Gegen eine echte Datenbank, nicht gegen eine Zeichenkette: Erst das
        // zeigt, ob die Anweisungen so getrennt werden, wie sie geschrieben wurden.
        $pdo = new PDO('sqlite::memory:');
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

        $sql = "-- OpenNIT – Sicherung der Datenbank\n"
             . "\n"
             . "CREATE TABLE geraete (\n"
             . "  id INTEGER PRIMARY KEY,\n"
             . "  name TEXT\n"
             . ");\n"
             . "INSERT INTO geraete (id,name) VALUES\n"
             . "(1,'Erstes'),\n"
             . "(2,'Zweites; mit Strichpunkt'),\n"
             . "(3,'Drittes\nmit Umbruch');\n"
             . "\n"
             . DatabaseDumper::END_MARKER . "\n";

        $strom = fopen('php://memory', 'r+');
        fwrite($strom, $sql);
        rewind($strom);

        $ergebnis = BackupRestorer::applySql($pdo, $strom);
        fclose($strom);

        assertSame(0, $ergebnis['failed'], 'Eine Anweisung ist gescheitert');
        assertSame(2, $ergebnis['executed'], 'Erwartet wurden CREATE und INSERT');

        $zeilen = $pdo->query('SELECT name FROM geraete ORDER BY id')->fetchAll(PDO::FETCH_COLUMN);
        assertSame(3, count($zeilen), 'Nicht alle Zeilen kamen an');
        // Ein Strichpunkt mitten im Wert darf die Anweisung nicht zerteilen.
        assertSame('Zweites; mit Strichpunkt', $zeilen[1]);
        // Ein Zeilenumbruch im Wert ebenso wenig.
        assertSame("Drittes\nmit Umbruch", $zeilen[2]);
    });

    test('Ein Kommentar zwischen zwei Anweisungen stört nicht', function () {
        $pdo = new PDO('sqlite::memory:');
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

        $strom = fopen('php://memory', 'r+');
        fwrite($strom, "CREATE TABLE a (id INTEGER);\n-- Tabelle `b`\nCREATE TABLE b (id INTEGER);\n");
        rewind($strom);
        $ergebnis = BackupRestorer::applySql($pdo, $strom);
        fclose($strom);

        assertSame(2, $ergebnis['executed']);
        assertSame(0, $ergebnis['failed']);
    });

    // ----------------------------------------------------------------
    // Rückweg: Dateien aus dem Archiv
    // ----------------------------------------------------------------

    test('Ein Eintrag mit Pfadwechsel landet nicht außerhalb des Ziels', function () use ($tmp, $weg) {
        // Der Name kommt aus dem Archiv und damit nicht zwingend aus eigener Hand.
        if (!class_exists('ZipArchive')) {
            return;
        }
        $archiv = $tmp . '/boese.zip';
        $ziel   = $tmp . '/ziel';
        @mkdir($ziel, 0700, true);

        $zip = new ZipArchive();
        $zip->open($archiv, ZipArchive::CREATE | ZipArchive::OVERWRITE);
        $zip->addFromString('storage/brav.txt', 'ok');
        $zip->addFromString('storage/../../entwischt.txt', 'nicht ok');
        $zip->addFromString('storage/tief/../../auch-entwischt.txt', 'nicht ok');
        $zip->close();

        $zip = new ZipArchive();
        $zip->open($archiv);
        $anzahl = BackupRestorer::extract($zip, 'storage/', $ziel);
        $zip->close();

        assertSame(1, $anzahl, 'Es wurde mehr als die brave Datei geschrieben');
        assertTrue(is_file($ziel . '/brav.txt'), 'Die brave Datei fehlt');
        assertFalse(is_file(dirname($ziel, 2) . '/entwischt.txt'), 'Eine Datei ist aus dem Ziel entwischt');
        assertFalse(is_file(dirname($ziel) . '/auch-entwischt.txt'), 'Eine Datei ist aus dem Ziel entwischt');

        $weg($ziel);
        @unlink($archiv);
    });

    test('Einzelne Namen lassen sich anderswohin schreiben', function () use ($tmp, $weg) {
        // `.env` liegt nicht in `config/`, kommt im Archiv aber von dort.
        if (!class_exists('ZipArchive')) {
            return;
        }
        $archiv = $tmp . '/conf.zip';
        $ziel   = $tmp . '/conf';
        @mkdir($ziel, 0700, true);

        $zip = new ZipArchive();
        $zip->open($archiv, ZipArchive::CREATE | ZipArchive::OVERWRITE);
        $zip->addFromString('config/app.php', '<?php return [];');
        $zip->addFromString('config/.env', 'APP_KEY=x');
        $zip->close();

        $zip = new ZipArchive();
        $zip->open($archiv);
        $anzahl = BackupRestorer::extract($zip, 'config/', $ziel, ['.env' => $tmp . '/.env']);
        $zip->close();

        assertSame(2, $anzahl);
        assertTrue(is_file($ziel . '/app.php'), 'app.php fehlt');
        assertTrue(is_file($tmp . '/.env'), '.env ging nicht an seinen eigenen Ort');
        assertFalse(is_file($ziel . '/.env'), '.env liegt zusätzlich am falschen Ort');

        $weg($ziel);
        @unlink($tmp . '/.env');
        @unlink($archiv);
    });

    // ----------------------------------------------------------------
    // Ablageort
    // ----------------------------------------------------------------

    test('Ein Ablageort im Webroot wird abgewiesen', function () use ($quelle) {
        // Eine Sicherung, die sich über die eigene Adresse herunterladen lässt,
        // ist keine.
        $src   = $quelle('src/Backup/SelfBackupManager.php');
        $start = strpos($src, 'public function pruefeZielverzeichnis(');
        assertTrue($start !== false, 'Die Prüfung fehlt');
        $body = substr($src, $start, 1400);

        assertTrue(str_contains($body, "realpath(\$this->rootDir . '/public')"), 'Der Webroot wird nicht bestimmt');
        assertTrue(str_contains($body, 'str_starts_with($this->aufgeloest($pfad)'), 'Die Lage wird nicht geprüft');
        assertTrue(str_contains($body, 'liegt im Webroot'), 'Der Grund wird nicht benannt');

        // Die Lage steht vor dem Anlegen – sonst bliebe ein abgewiesener Ort
        // als leeres Verzeichnis zurück.
        $lage    = strpos($body, 'liegt im Webroot');
        $anlegen = strpos($body, '@mkdir(');
        assertTrue($lage !== false && $anlegen !== false && $lage < $anlegen, 'Es wird vor der Prüfung angelegt');

        // Und die Prüfung steht vor jedem Schreiben.
        $run = substr($src, (int)strpos($src, 'public function run('), 1200);
        $p = strpos($run, 'pruefeZielverzeichnis(');
        $i = strpos($run, "insert('self_backups'");
        assertTrue($p !== false && $i !== false && $p < $i, 'Es wird vor der Prüfung geschrieben');
    });

    test('Ein abgewiesener Ort bleibt nicht als Verzeichnis zurück', function () use ($wurzel) {
        // Ein Webroot-Pfad wird verworfen – er darf dort aber auch kein leeres
        // Verzeichnis hinterlassen, das beim nächsten Blick verwirrt.
        $ref = new ReflectionClass(\OpenNIT\Backup\SelfBackupManager::class);
        $m   = $ref->newInstanceWithoutConstructor();
        $prop = $ref->getProperty('rootDir');
        $prop->setAccessible(true);
        $prop->setValue($m, $wurzel);

        $pruefen = $ref->getMethod('pruefeZielverzeichnis');

        foreach ([
            $wurzel . '/public/sicherungen-testfall',
            // Auch der Umweg über `..` führt in den Webroot.
            $wurzel . '/public/../public/sicherungen-testfall',
            $wurzel . '/storage/../public/sicherungen-testfall',
        ] as $pfad) {
            $ergebnis = $pruefen->invoke($m, $pfad);
            assertFalse($ergebnis['ok'], 'Angenommen: ' . $pfad);
            assertTrue(str_contains($ergebnis['message'], 'Webroot'), 'Der Grund wird nicht benannt');
            assertFalse(is_dir($pfad), 'Es blieb ein Verzeichnis zurück: ' . $pfad);
        }

        assertFalse(is_dir($wurzel . '/public/sicherungen-testfall'), 'Im Webroot blieb etwas zurück');
    });

    test('Ein relativer Ablageort wird abgewiesen', function () use ($wurzel) {
        $ref = new ReflectionClass(\OpenNIT\Backup\SelfBackupManager::class);
        $m   = $ref->newInstanceWithoutConstructor();
        $prop = $ref->getProperty('rootDir');
        $prop->setAccessible(true);
        $prop->setValue($m, $wurzel);

        $ergebnis = $ref->getMethod('pruefeZielverzeichnis')->invoke($m, 'storage/backups');
        assertFalse($ergebnis['ok'], 'Ein relativer Pfad wurde angenommen');
        assertTrue(str_contains($ergebnis['message'], 'absoluter Pfad'), 'Der Grund wird nicht benannt');
    });

    test('Der Ablageort wird nicht aus dem Upload-Pfad abgeleitet', function () use ($quelle) {
        // Der darf laut Konfiguration ganz woanders liegen; sein
        // Elternverzeichnis wäre dann ein fremder Ort.
        $src   = $quelle('src/Backup/SelfBackupManager.php');
        $start = strpos($src, 'public function standardZiel(');
        $body  = substr($src, $start, 200);
        assertFalse(str_contains($body, 'dirname(Paths::uploads'), 'Der Standard hängt am Upload-Pfad');
        assertTrue(str_contains($body, "\$this->rootDir . '/storage/backups'"), 'Der Standard stimmt nicht');
    });

    test('Uploads gehen einen eigenen Weg ins Archiv und zurück', function () use ($quelle) {
        // Sie dürfen anderswo liegen als im Projektverzeichnis, und beim
        // Zurückspielen zählt der Ort DIESER Installation.
        $src = $quelle('src/Backup/SelfBackupManager.php');
        assertTrue(str_contains($src, "Paths::uploads(\$this->config), 'uploads', []"), 'Uploads werden nicht eigens gepackt');
        assertTrue(str_contains($src, "'uploads'"), 'Der eigene Zweig fehlt');

        $cli = $quelle('bin/restore-backup.php');
        assertTrue(str_contains($cli, "extract(\$zip, 'uploads/', \$uploadsZiel"), 'Uploads werden nicht eigens zurückgeschrieben');
        assertTrue(str_contains($cli, '$uploadsZiel = Paths::uploads($ctx->config);'), 'Das Ziel stammt nicht aus dieser Installation');
    });

    // ----------------------------------------------------------------
    // Was mitgeht
    // ----------------------------------------------------------------

    test('Die Konfiguration ist nicht von sich aus dabei', function () use ($quelle) {
        // Sie enthält den Anwendungsschlüssel; damit ließe sich der Tresor aus
        // derselben Sicherung entschlüsseln.
        foreach ([
            'src/Controllers/Admin/SelfBackupAdminController.php',
            'src/Cron/Handlers/SelfBackupHandler.php',
        ] as $datei) {
            assertTrue(
                str_contains($quelle($datei), "\$teil === 'config' ? '0' : '1'"),
                $datei . ': Die Konfiguration ist vorausgewählt'
            );
        }
    });

    test('Zwischenspeicher und Sicherungen selbst bleiben draußen', function () use ($quelle) {
        // Ohne das wüchse jede Sicherung um alle vorherigen.
        $src = $quelle('src/Backup/SelfBackupManager.php');
        $start = strpos($src, 'private const STORAGE_SKIP');
        $zeile = substr($src, $start, 160);
        foreach (['cache', 'tmp', 'sessions', 'backups', 'logs', 'uploads'] as $name) {
            assertTrue(str_contains($zeile, "'" . $name . "'"), $name . ' fehlt in der Ausnahmeliste');
        }
    });

    // ----------------------------------------------------------------
    // Prüfung und Aufbewahrung
    // ----------------------------------------------------------------

    test('Die Prüfung liest jeden Eintrag wirklich', function () use ($quelle) {
        $src   = $quelle('src/Backup/SelfBackupManager.php');
        $start = strpos($src, 'public function pruefeDatei(');
        $body  = substr($src, $start, 3000);

        assertTrue(str_contains($body, 'hash_file('), 'Die Prüfsumme wird nicht verglichen');
        assertTrue(str_contains($body, 'ZipArchive::CHECKCONS'), 'Das Archiv wird nicht auf Schlüssigkeit geöffnet');
        assertTrue(str_contains($body, '$zip->getStream($name)'), 'Die Einträge werden nicht gelesen');
        assertTrue(str_contains($body, 'DatabaseDumper::END_MARKER'), 'Die Vollständigkeit des Abzugs wird nicht geprüft');
    });

    test('Ein gescheiterter Lauf verdrängt keine geglückte Sicherung', function () use ($quelle) {
        $src   = $quelle('src/Backup/SelfBackupManager.php');
        $start = strpos($src, 'public function raeumeAuf(');
        $body  = substr($src, $start, 900);
        assertTrue(str_contains($body, "status = 'ok'"), 'Die Aufbewahrung zählt auch Fehlschläge mit');
    });

    test('Der Abzug endet mit einer erkennbaren Schlusszeile', function () use ($quelle) {
        // Erst sie unterscheidet eine vollständige Datei von einer abgebrochenen.
        $src = $quelle('src/Backup/DatabaseDumper.php');
        assertTrue(str_contains($src, 'public const END_MARKER'), 'Es gibt keine Schlusszeile');
        assertTrue(str_contains($src, 'fwrite($fh, self::END_MARKER'), 'Sie wird nicht geschrieben');
    });

    // ----------------------------------------------------------------
    // Zugang und Protokoll
    // ----------------------------------------------------------------

    test('Alles hängt an einem eigenen Recht', function () use ($quelle) {
        // Wer Sicherungen anlegt und herunterlädt, hält den gesamten
        // Datenbestand in der Hand. Wer sie zurückspielt, ersetzt ihn – das ist
        // ein zweites Recht.
        $ctrl = $quelle('src/Controllers/Admin/SelfBackupAdminController.php');
        assertSame(
            substr_count($ctrl, 'public function '),
            substr_count($ctrl, "authorize('manage_selfbackup')")
                + substr_count($ctrl, "authorize('restore_selfbackup')"),
            'Nicht jede öffentliche Aktion prüft das Recht'
        );

        $app = $quelle('src/Core/Application.php');
        foreach (['index', 'saveSettings', 'run', 'download', 'verify', 'destroy', 's3Test', 's3Lock',
                  'restore', 'fetch'] as $aktion) {
            assertTrue(
                str_contains($app, "SelfBackupAdminController@" . $aktion . "'"),
                'Route fehlt: ' . $aktion
            );
        }

        // Jede Route der Datensicherung hängt an einem der beiden Rechte –
        // gezählt, damit eine später hinzugefügte nicht ohne auskommt.
        assertSame(
            substr_count($app, 'SelfBackupAdminController@'),
            substr_count($app, "'capability' => 'manage_selfbackup'")
                + substr_count($app, "'capability' => 'restore_selfbackup'"),
            'Nicht jede Route ist gesichert'
        );
    });

    test('Das Herunterladen steht eigens im Protokoll', function () use ($quelle) {
        // Damit verlässt der gesamte Datenbestand die Installation; ein
        // Seitenaufruf ist das nicht.
        $ctrl  = $quelle('src/Controllers/Admin/SelfBackupAdminController.php');
        $start = strpos($ctrl, 'public function download(');
        $body  = substr($ctrl, $start, 1200);

        assertTrue(str_contains($body, 'selfbackup_downloaded'), 'Der Download wird nicht protokolliert');
        assertTrue(str_contains($body, "'high'"), 'Der Vorgang gilt nicht als schwerwiegend');

        // Und zwar bevor die Datei hinausgeht.
        $p = strpos($body, 'selfbackup_downloaded');
        $r = strpos($body, 'readfile(');
        assertTrue($p !== false && $r !== false && $p < $r, 'Es wird erst ausgeliefert, dann protokolliert');
    });

    test('Der Weg über die Kommandozeile bleibt bestehen', function () use ($quelle, $wurzel) {
        // Wo eine Shell zur Verfügung steht, ist sie der ruhigste Weg – und sie
        // nutzt dieselben Schreibvorgänge wie das Backend.
        assertTrue(is_file($wurzel . '/bin/restore-backup.php'), 'Das Skript fehlt');

        $cli = $quelle('bin/restore-backup.php');
        assertTrue(str_contains($cli, '--dry-run'), 'Es gibt keinen Probelauf');
        assertTrue(str_contains($cli, 'pruefeDatei('), 'Das Archiv wird vor dem Schreiben nicht geprüft');
        assertTrue(str_contains($cli, "\$eingabe !== 'JA'"), 'Es wird nicht ausdrücklich bestätigt');

        // Die Reihenfolge: prüfen, bestätigen, schreiben.
        $p = strpos($cli, 'pruefeDatei(');
        $b = strpos($cli, 'Zum Fortfahren');
        $s = strpos($cli, "in_array('database', \$zuTun, true)");
        assertTrue($p < $b && $b < $s, 'Die Reihenfolge stimmt nicht');

        assertTrue(str_contains($cli, 'BackupRestorer::applySql'), 'Das Skript geht einen eigenen Weg');
        assertTrue(
            str_contains($quelle('src/Backup/RestoreRunner.php'), 'BackupRestorer::applySql'),
            'Das Backend geht einen eigenen Weg'
        );
    });

    // ----------------------------------------------------------------
    // Einrichtung
    // ----------------------------------------------------------------

    test('Tabelle, Recht und Zeitplan sind angelegt', function () use ($quelle) {
        $sql = $quelle('database/migrations/222_self_backup.sql');

        assertTrue(str_contains($sql, 'CREATE TABLE IF NOT EXISTS `self_backups`'), 'Die Tabelle fehlt');
        assertTrue(str_contains($sql, "INSERT IGNORE INTO `capabilities`"), 'Das Recht wird nicht angelegt');
        assertTrue(str_contains($sql, 'manage_selfbackup'), 'Das Recht heißt anders');
        assertTrue(str_contains($sql, "INSERT IGNORE INTO `cron_tasks`"), 'Der Zeitplan wird nicht angelegt');
        assertTrue(str_contains($sql, 'SelfBackupHandler'), 'Der Zeitplan kennt seinen Handler nicht');

        // Standardmäßig aus: Ohne geprüften Ablageort liefe er ins Leere.
        $zeitplan = substr($sql, (int)strpos($sql, "'self_backup', 'Datensicherung"));
        assertTrue(str_contains($zeitplan, 'SelfBackupHandler\', 1440, 0,'), 'Der Zeitplan ist von sich aus an');

        // `trigger` ist ein reserviertes Wort – die Spalte heißt anders.
        assertFalse(str_contains($sql, '`trigger`'), 'Eine Spalte heißt wie ein reserviertes Wort');
    });

    $weg($tmp);
};