OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

S3BackupTest.php

12 KB
<?php

declare(strict_types=1);

use OpenNIT\Backup\S3Client;
use OpenNIT\Backup\SelfBackupManager;

/**
 * Deckt die Ablage der Sicherung in einem S3-Speicher und die
 * Aufbewahrungssperre ab.
 *
 * Die Sperre ist der eigentliche Zweck: Wer die Installation übernimmt, soll
 * die Sicherungen nicht mitlöschen können. Daraus folgt die Zusage, die hier
 * festgehalten wird – **OpenNIT entfernt nichts, was gesperrt ist**, weder beim
 * Aufräumen noch auf Knopfdruck, und es sagt das, statt einen Erfolg zu melden,
 * den der Speicher gar nicht zulässt.
 *
 * Der zweite Punkt ist die Signatur. Sie ist von Hand gebaut, ohne fremdes SDK;
 * ein Fehler darin fiele erst am fremden Speicher auf. Deshalb wird sie gegen
 * das veröffentlichte Beispiel von AWS gerechnet.
 */
return static function (): void {

    $wurzel = dirname(__DIR__);
    $quelle = static fn (string $pfad): string => (string)file_get_contents($wurzel . '/' . $pfad);

    // ----------------------------------------------------------------
    // Signatur v4
    // ----------------------------------------------------------------

    test('Der Signierschlüssel folgt dem Beispiel von AWS', function () {
        // Aus der Dokumentation zu „Signature Version 4": Aus Secret, Datum,
        // Region und Dienst entsteht über vier HMAC-Schritte der Schlüssel.
        // Stimmt ein Schritt nicht, weist jeder Speicher die Anfrage ab.
        $secret = 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY';
        $tag    = '20150830';
        $region = 'us-east-1';
        $dienst = 'iam';

        $schluessel = hash_hmac(
            'sha256',
            'aws4_request',
            hash_hmac('sha256', $dienst,
                hash_hmac('sha256', $region,
                    hash_hmac('sha256', $tag, 'AWS4' . $secret, true), true), true),
            true
        );

        assertSame(
            'c4afb1cc5771d871763a393e44b703571b55cc28424d1a5e86da6ed3c154a4b9',
            bin2hex($schluessel),
            'Die Schlüsselableitung weicht vom Beispiel ab'
        );

        // Derselbe Aufbau steht im Code – Reihenfolge und Zutaten müssen passen.
        $src = (string)file_get_contents(dirname(__DIR__) . '/src/Backup/S3Client.php');
        assertTrue(
            str_contains($src, "hash_hmac('sha256', 'aws4_request', hash_hmac('sha256', 's3',"),
            'Die Ableitung im Code hat einen anderen Aufbau'
        );
    });

    test('Ein Pfad wird segmentweise kodiert', function () {
        // Würden auch die Trenner kodiert, zeigte die Signatur auf einen
        // anderen Pfad als die Anfrage – der Speicher antwortet dann mit
        // „SignatureDoesNotMatch", ohne zu sagen, warum.
        $ref = new ReflectionMethod(S3Client::class, 'encodePath');
        $ref->setAccessible(true);
        $client = (new ReflectionClass(S3Client::class))->newInstanceWithoutConstructor();

        assertSame('/eimer/opennit-2026-01-01.zip', $ref->invoke($client, '/eimer/opennit-2026-01-01.zip'));
        assertSame('/eimer/mit%20leerzeichen.zip', $ref->invoke($client, '/eimer/mit leerzeichen.zip'));
        assertSame('/a/b/c', $ref->invoke($client, '/a/b/c'), 'Die Trenner wurden kodiert');
    });

    test('Die Prüfsumme des Inhalts wird mitsigniert', function () use ($quelle) {
        // Mit `UNSIGNED-PAYLOAD` schützt die Signatur den Inhalt nicht; über
        // eine Verbindung ohne TLS ließe er sich unterwegs austauschen.
        $src = $quelle('src/Backup/S3Client.php');
        // Der Begriff darf im Kommentar stehen – nur nicht als Wert.
        assertFalse(str_contains($src, "'UNSIGNED-PAYLOAD'"), 'Der Rumpf bleibt ungesichert');
        assertFalse(str_contains($src, '"UNSIGNED-PAYLOAD"'), 'Der Rumpf bleibt ungesichert');
        assertTrue(str_contains($src, "hash_file('sha256', \$datei)"), 'Die Datei wird nicht gehasht');
        assertTrue(str_contains($src, "'x-amz-content-sha256'"), 'Die Prüfsumme wird nicht mitgesendet');
    });

    // ----------------------------------------------------------------
    // Hochladen
    // ----------------------------------------------------------------

    test('Ein abgebrochener mehrteiliger Upload wird verworfen', function () use ($quelle) {
        // Ein liegengebliebener Rest kostet dauerhaft Speicher, ohne je eine
        // vollständige Sicherung zu ergeben.
        $src   = $quelle('src/Backup/S3Client.php');
        $start = strpos($src, 'private function putInTeilen(');
        $body  = substr($src, (int)$start, 3000);

        assertSame(3, substr_count($body, "['uploadId' => \$uploadId], [], '', hash('sha256', '')"), 'Nicht jeder Abbruch räumt auf');
        assertTrue(str_contains($body, "\$this->request('DELETE', \$schluessel, ['uploadId' => \$uploadId]"), 'Der Abbruch fehlt');
    });

    test('Ein Fehler im Abschluss-Rumpf gilt als Fehlschlag', function () use ($quelle) {
        // S3 antwortet auf den Abschluss mit HTTP 200, auch wenn im Rumpf ein
        // Fehler steht – wer nur den Status liest, hält eine halbe Datei für
        // eine ganze.
        $src   = $quelle('src/Backup/S3Client.php');
        $start = strpos($src, 'private function putInTeilen(');
        $body  = substr($src, (int)$start, 3200);
        assertTrue(str_contains($body, "str_contains(\$ende['body'], '<Error>')"), 'Der Rumpf des Abschlusses wird nicht geprüft');
    });

    test('In den Fernspeicher geht nur eine geprüfte Sicherung', function () use ($quelle) {
        // Unter einer Sperre ließe sich ein beschädigtes Archiv später weder
        // ersetzen noch entfernen.
        $src   = $quelle('src/Backup/SelfBackupManager.php');
        $start = strpos($src, 'if ($this->s3Aktiv()) {');
        assertTrue($start !== false, 'Es wird nicht übertragen');
        $body = substr($src, (int)$start, 1200);

        $pruefen   = strpos($body, 'pruefeDatei(');
        $hochladen = strpos($body, 'ladeHoch(');
        assertTrue($pruefen !== false && $hochladen !== false && $pruefen < $hochladen, 'Es wird vor der Prüfung übertragen');
        assertTrue(str_contains($body, "if (!\$pruefung['ok'])"), 'Ein beschädigtes Archiv geht trotzdem hinaus');
    });

    // ----------------------------------------------------------------
    // Die Sperre
    // ----------------------------------------------------------------

    test('Es gibt genau drei Sperren, und „keine" ist die Vorgabe', function () {
        assertSame(['', 'GOVERNANCE', 'COMPLIANCE'], array_keys(SelfBackupManager::LOCK_MODES));

        $ref = new ReflectionClass(SelfBackupManager::class);
        $m   = $ref->newInstanceWithoutConstructor();

        // Ein unbekannter Wert darf nicht auf eine Sperre hinauslaufen, die
        // sich hinterher nicht mehr aufheben lässt.
        $einstellung = $ref->getMethod('einstellung');
        assertTrue($einstellung->isPublic(), 'einstellung() ist nicht erreichbar');
    });

    test('Eine gesperrte Sicherung wird nicht entfernt – und das wird gesagt', function () use ($quelle) {
        $src   = $quelle('src/Backup/SelfBackupManager.php');
        $start = strpos($src, 'private function entferneImFernspeicher(');
        assertTrue($start !== false, 'Der Umgang mit dem Fernspeicher fehlt');
        $body = substr($src, (int)$start, 1400);

        assertTrue(str_contains($body, "strtotime(\$gesperrtBis) > time()"), 'Die Sperre wird nicht geprüft');
        assertTrue(str_contains($body, 'gesperrt und wurde dort nicht entfernt'), 'Es wird nicht gesagt, dass sie stehen bleibt');
        // Und es wird gar nicht erst versucht: Ein Löschversuch gegen COMPLIANCE
        // scheitert ohnehin und verschleiert nur, was gilt.
        $sperrePos = strpos($body, 'gesperrt und wurde dort nicht entfernt');
        $loeschPos = strpos($body, 'deleteObject(');
        assertTrue($sperrePos !== false && $loeschPos !== false && $sperrePos < $loeschPos, 'Es wird trotz Sperre gelöscht');
    });

    test('Das Aufräumen lässt einen Eintrag stehen, dessen Objekt bleibt', function () use ($quelle) {
        // Sonst verlöre die Aufbewahrung die Spur zu einem Objekt, das im
        // Fernspeicher weiter Platz belegt.
        $src   = $quelle('src/Backup/SelfBackupManager.php');
        $start = strpos($src, 'public function raeumeAuf(string $schonen');
        $body  = substr($src, (int)$start, 1800);

        assertTrue(str_contains($body, 'entferneImFernspeicher('), 'Das Aufräumen kümmert sich nicht um den Fernspeicher');
        assertTrue(str_contains($body, "if (!\$fern['ok']) {"), 'Ein verbliebenes Objekt wird übergangen');
        assertTrue(str_contains($body, 'continue;'), 'Der Eintrag verschwindet trotzdem');
    });

    test('Ein Eimer ohne eingeschaltete Sperre wird benannt', function () use ($quelle) {
        // Er nimmt die Datei an und übergeht die Angabe stillschweigend –
        // wer sich darauf verlässt, hätte eine Sperre, die keine ist.
        $ctrl  = $quelle('src/Controllers/Admin/SelfBackupAdminController.php');
        $start = strpos($ctrl, 'public function s3Test(');
        $body  = substr($ctrl, (int)$start, 1200);

        assertTrue(str_contains($body, 'keine Aufbewahrungssperre eingeschaltet'), 'Der Fall wird nicht benannt');
        assertTrue(str_contains($body, 'nur beim'), 'Es wird nicht gesagt, dass sie sich nicht nachrüsten lässt');

        // Und es lässt sich an einem übertragenen Objekt nachlesen.
        $src = $quelle('src/Backup/SelfBackupManager.php');
        assertTrue(str_contains($src, 'public function sperrePruefen('), 'Die Sperre lässt sich nicht nachlesen');
        assertTrue(str_contains($src, 'objectRetention('), 'Es wird nicht am Objekt selbst nachgesehen');
    });

    // ----------------------------------------------------------------
    // Zugangsdaten
    // ----------------------------------------------------------------

    test('Der geheime Schlüssel steht verschlüsselt und nicht im Formular', function () use ($quelle) {
        $ctrl = $quelle('src/Controllers/Admin/SelfBackupAdminController.php');
        assertTrue(str_contains($ctrl, 'CredentialCrypto::encrypt('), 'Das Geheimnis geht im Klartext in die Datenbank');
        assertTrue(str_contains($ctrl, "\$geheim !== ''"), 'Eine leere Eingabe löscht den hinterlegten Wert');
        // Und es gibt einen Weg, es wieder loszuwerden.
        assertTrue(str_contains($ctrl, "\$request->has('s3_secret_remove')"), 'Ein hinterlegtes Geheimnis lässt sich nicht entfernen');

        $tpl = $quelle('templates/admin/selfbackup/index.php');
        assertTrue(str_contains($tpl, 'name="s3_secret"'), 'Das Feld fehlt');
        assertFalse(str_contains($tpl, 'name="s3_secret" class="form-control" value='), 'Der Wert wird in die Seite geschrieben');
        assertTrue(str_contains($tpl, '(hinterlegt)'), 'Es ist nicht erkennbar, ob etwas hinterlegt ist');
        assertTrue(str_contains($tpl, 'name="s3_secret_remove"'), 'Das Kästchen zum Entfernen fehlt');

        $mgr = $quelle('src/Backup/SelfBackupManager.php');
        assertTrue(str_contains($mgr, 'CredentialCrypto::decrypt('), 'Das Geheimnis wird nicht entschlüsselt');
    });

    test('Das Protokoll nennt die Sperre, aber kein Geheimnis', function () use ($quelle) {
        $ctrl = $quelle('src/Controllers/Admin/SelfBackupAdminController.php');
        $start = strpos($ctrl, "'selfbackup_settings_saved'");
        $body  = substr($ctrl, (int)$start, 500);

        assertTrue(str_contains($body, "'fernspeicher'"), 'Es steht nicht im Protokoll, ob der Fernspeicher an ist');
        assertFalse(str_contains($body, 's3_secret'), 'Das Geheimnis landet im Protokoll');
        assertFalse(str_contains($body, 's3_key'), 'Der Zugriffsschlüssel landet im Protokoll');
    });

    // ----------------------------------------------------------------
    // Ablage
    // ----------------------------------------------------------------

    test('Die Sicherung führt ihr Gegenstück im Fernspeicher mit', function () use ($quelle) {
        $sql = $quelle('database/migrations/224_selfbackup_s3.sql');

        foreach (['s3_key', 's3_status', 's3_message', 's3_locked_until'] as $spalte) {
            assertTrue(str_contains($sql, '`' . $spalte . '`'), $spalte . ' fehlt');
        }
        assertTrue(str_contains($sql, 'ALTER TABLE `self_backups`'), 'Die Spalten hängen an der falschen Tabelle');
    });
};