OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

StatusPageTokenTest.php

10 KB
<?php

declare(strict_types=1);

use OpenNIT\Controllers\Admin\StatusAdminController;
use OpenNIT\Controllers\StatusController;

/**
 * Deckt die optionale Absicherung der öffentlichen Statusseite ab.
 *
 * Ohne Einstellung bleibt `/status` offen wie bisher. Eingeschaltet entscheidet
 * ein Token in der Adresse – für die Seite selbst wie für die iframe-Variante,
 * und zwar bevor irgendetwas ausgegeben oder aus dem Cache geholt wird.
 */
return static function (): void {

    /** Der Quelltext des öffentlichen Controllers. */
    $controller = static fn (): string => (string)file_get_contents(
        dirname(__DIR__) . '/src/Controllers/StatusController.php'
    );

    /**
     * Ein benannter Methodenrumpf – bis zur schließenden Klammer der Methode.
     * Ohne diese Grenze liefe die Prüfung in die nächste Methode hinein.
     */
    $rumpf = static function (string $name) use ($controller): string {
        $src = $controller();
        $von = strpos($src, 'private function ' . $name);
        if ($von === false) {
            $von = strpos($src, 'public function ' . $name);
        }
        if ($von === false) {
            return '';
        }
        $ende = strpos($src, "\n    }\n", $von);
        return $ende !== false ? substr($src, $von, $ende - $von) : substr($src, $von);
    };

    // ----------------------------------------------------------------
    // Standard: offen
    // ----------------------------------------------------------------

    test('Ohne Einstellung bleibt die Seite offen', function () use ($rumpf) {
        // Bestandsinstanzen haben den Schlüssel nicht – dort darf sich nichts ändern.
        $body = $rumpf('publicAccessAllowed');

        assertTrue(str_contains($body, "get('statuspage_token_enabled') !== '1'"), 'Kein Schalter');
        assertTrue(
            strpos($body, 'return true;') < strpos($body, 'hash_equals'),
            'Der offene Fall wird nicht zuerst entschieden'
        );
    });

    // ----------------------------------------------------------------
    // Eingeschaltet: beide Einstiege geschützt
    // ----------------------------------------------------------------

    test('Seite und iframe-Variante prüfen beide', function () use ($rumpf) {
        foreach (['index', 'embed'] as $einstieg) {
            $body = $rumpf($einstieg);
            assertTrue(str_contains($body, 'publicAccessAllowed($request)'), 'Ungeprüft: ' . $einstieg);
            assertTrue(str_contains($body, 'denyPublic()'), 'Ohne Abweisung: ' . $einstieg);
        }
    });

    test('Die Prüfung läuft vor Ausgabe und Cache', function () use ($rumpf) {
        // Ein Treffer im Kurzzeit-Cache darf nicht an der Prüfung vorbeikommen.
        foreach (['index', 'embed'] as $einstieg) {
            $body = $rumpf($einstieg);
            assertTrue(
                strpos($body, 'publicAccessAllowed') < strpos($body, 'renderPublic'),
                'Cache vor Prüfung in ' . $einstieg
            );
        }
    });

    test('Der Vergleich ist zeitkonstant und weist Leeres ab', function () use ($rumpf) {
        $body = $rumpf('publicAccessAllowed');

        assertTrue(str_contains($body, 'hash_equals('), 'Kein zeitkonstanter Vergleich');
        assertFalse((bool)preg_match('/\$hinterlegt\s*===\s*\$gesendet/', $body), 'Direkter Stringvergleich');
        assertTrue(str_contains($body, "\$hinterlegt !== '' && \$gesendet !== ''"), 'Leere Werte gelten als Treffer');
    });

    test('Fehlversuche sind gedrosselt, gültige Aufrufe nicht', function () use ($rumpf) {
        // Der Endpunkt ist ohne Anmeldung erreichbar; Durchprobieren soll teuer
        // sein. Eine Drosselung über alle Aufrufe träfe dagegen auch mehrere
        // Besucher hinter derselben Adresse.
        $body = $rumpf('publicAccessAllowed');

        assertTrue(str_contains($body, 'RateLimiter'), 'Keine Drosselung');
        assertTrue(
            strpos($body, 'hash_equals') < strpos($body, 'RateLimiter'),
            'Die Drosselung greift vor der Prüfung und damit auch bei gültigem Token'
        );
    });

    test('Die Abweisung verrät nichts über die Seite', function () use ($rumpf) {
        $body = $rumpf('denyPublic');

        assertTrue(str_contains($body, 'http_response_code(404)'), 'Kein neutraler Status');
        assertTrue(str_contains($body, 'noindex'), 'Ohne Hinweis an Suchmaschinen');
        assertTrue(str_contains($body, 'no-store'), 'Die Abweisung wäre zwischenspeicherbar');
        assertFalse(str_contains($body, 'Token'), 'Die Meldung nennt den Grund');
    });

    test('Mit Absicherung gehört die Antwort nicht in einen geteilten Cache', function () use ($controller) {
        assertTrue(
            str_contains($controller(), "'Cache-Control: ' . (\$geschuetzt ? 'private' : 'public')"),
            'Die Antwort bliebe öffentlich zwischenspeicherbar'
        );
    });

    // ----------------------------------------------------------------
    // Ablage des Tokens
    // ----------------------------------------------------------------

    test('Der Token liegt verschlüsselt und nie im Protokoll', function () {
        $admin = (string)file_get_contents(
            dirname(__DIR__) . '/src/Controllers/Admin/StatusAdminController.php'
        );

        assertTrue(method_exists(StatusAdminController::class, 'regenerateToken'), 'Kein Neu-Erzeugen');
        assertTrue(str_contains($admin, 'CredentialCrypto::encrypt'), 'Der Token läge im Klartext');
        assertTrue(str_contains($admin, 'random_bytes(24)'), 'Kein zufälliger Token');

        // Im Audit-Eintrag steht der Vorgang, nicht der Wert.
        $von   = strpos($admin, 'public function regenerateToken');
        $block = substr($admin, $von, 1200);
        assertTrue(str_contains($block, "'statuspage_token_regenerated'"), 'Kein Audit-Eintrag');
        assertFalse((bool)preg_match('/\$token/', $block), 'Der Token taucht im Protokoll-Block auf');
    });

    test('Beim Einschalten entsteht ein Token', function () {
        $admin = (string)file_get_contents(
            dirname(__DIR__) . '/src/Controllers/Admin/StatusAdminController.php'
        );

        assertTrue(
            str_contains($admin, "if (\$tokenOn === '1' && \$this->statusToken() === '') {"),
            'Ohne Token ließe sich die Seite sperren, ohne sie noch öffnen zu können'
        );
    });

    test('Beide Seiten lesen denselben Schlüssel', function () use ($controller) {
        $admin = (string)file_get_contents(
            dirname(__DIR__) . '/src/Controllers/Admin/StatusAdminController.php'
        );

        foreach (['statuspage_token_enabled', 'statuspage_token'] as $schluessel) {
            assertTrue(str_contains($controller(), $schluessel), 'Öffentlich fehlt: ' . $schluessel);
            assertTrue(str_contains($admin, $schluessel), 'Backend fehlt: ' . $schluessel);
        }
    });

    // ----------------------------------------------------------------
    // Adressen und Einbettung
    // ----------------------------------------------------------------

    test('Die Verwaltung kennt den Token nur bei aktiver Absicherung', function () {
        $src = (string)file_get_contents(dirname(__DIR__) . '/src/Controllers/StatusController.php');

        assertTrue(
            str_contains($src, "get('statuspage_token_enabled') === '1'\n                                  ? \$this->statusToken() : ''"),
            'Der Token ginge auch ohne Absicherung an die Ansicht'
        );
        assertTrue(method_exists(StatusController::class, 'manage'));
    });

    test('Der Einbettungscode trägt den Token', function () {
        // Ohne ihn bliebe der eingebettete Rahmen leer.
        $tpl = (string)file_get_contents(dirname(__DIR__) . '/templates/status/manage.php');

        assertTrue(str_contains($tpl, "'/status/embed' + query"), 'iframe ohne Token');
        assertTrue(str_contains($tpl, "origin + '/status' + query"), 'Die genannte Adresse ohne Token');
        assertTrue(str_contains($tpl, "'?token=' . \$status_token"), 'Der Token wird nicht eingesetzt');
    });

    test('Auch die Verweise im Backend führen zur erreichbaren Seite', function () {
        $tpl   = (string)file_get_contents(dirname(__DIR__) . '/templates/status/manage.php');
        $admin = (string)file_get_contents(dirname(__DIR__) . '/templates/admin/statuspage/settings.php');

        // Kopfzeile der Verwaltung
        assertTrue(str_contains($tpl, 'href="/status<?= ($status_token ?? \'\') !== \'\''), 'Kopf-Verweis ohne Token');
        // Fertige Links im Backend
        assertTrue(str_contains($admin, '/status?token='), 'Kein fertiger Link zur Seite');
        assertTrue(str_contains($admin, '/status/embed?token='), 'Kein fertiger Link zur iframe-Variante');
    });

    test('Die Links erscheinen erst, wenn die Absicherung aktiv ist', function () {
        $admin = (string)file_get_contents(dirname(__DIR__) . '/templates/admin/statuspage/settings.php');

        assertTrue(
            str_contains($admin, "(\$v['statuspage_token_enabled'] ?? '0') === '1' && (\$status_token ?? '') !== ''"),
            'Die Karte hängt an keiner Bedingung'
        );
    });

    test('Die Oberfläche benennt die Grenze des Verfahrens', function () {
        // Ein Token in der Adresse landet in Verlauf, Lesezeichen und Protokollen –
        // das soll in der Beschreibung stehen, nicht nur im Quelltext.
        $admin = (string)file_get_contents(dirname(__DIR__) . '/templates/admin/statuspage/settings.php');

        assertTrue(str_contains($admin, 'ersetzt keine Anmeldung'), 'Die Einschränkung fehlt');
        assertTrue(str_contains($admin, 'Server-Protokollen'), 'Der Hinweis auf Protokolle fehlt');
    });

    test('Die Symbole sind der Emoji-Umschaltung bekannt', function () {
        $map   = (string)file_get_contents(dirname(__DIR__) . '/src/Core/IconMap.php');
        $admin = (string)file_get_contents(dirname(__DIR__) . '/templates/admin/statuspage/settings.php');

        assertTrue(str_contains($admin, '<i class="fas fa-sync-alt"></i>'), 'Icon fehlt oder ohne Stil-Präfix');
        assertTrue(str_contains($map, "'fa-sync-alt'"), 'Nicht in der IconMap');
    });
};