OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

ApiRegistryTest.php

13 KB
<?php

declare(strict_types=1);

/**
 * Die lesenden Modul-Endpunkte und ihre Beschreibung.
 *
 * Die Beschreibung entsteht aus derselben Aufstellung wie der Endpunkt. Damit
 * das so bleibt, prüft dieser Fall beides gegeneinander – und zusätzlich, dass
 * **jede** registrierte Route der Schnittstelle in der Spezifikation steht.
 * Genau an dieser Stelle waren zuvor sieben Endpunkte undokumentiert.
 *
 * Die zweite Hälfte betrifft das, was nicht hinaus soll: Der Tresor hat keinen
 * Endpunkt, und keine Sammlung gibt ein Feld heraus, das einen Schlüssel oder
 * ein verschlüsseltes Geheimnis führt.
 */

use OpenNIT\Api\ApiRegistry;
use OpenNIT\Api\ApiScopes;

return static function (): void {

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

    /** Die Spezifikation ohne Bootstrap – wie im vorhandenen Spec-Test. */
    $spec = static function () use ($wurzel): array {
        $klasse = new ReflectionClass(OpenNIT\Controllers\Admin\WordPressAdminController::class);
        $obj    = $klasse->newInstanceWithoutConstructor();
        $m      = $klasse->getMethod('buildOpenapiSpec');
        $m->setAccessible(true);
        return $m->invoke($obj, 'https://example.test');
    };

    // ----------------------------------------------------------------
    // Das Verzeichnis
    // ----------------------------------------------------------------

    test('Jede Sammlung ist vollständig beschrieben', function () {
        foreach (ApiRegistry::all() as $key => $r) {
            assertSame($key, $r->key, 'Der Schlüssel im Verzeichnis passt nicht zur Sammlung');
            assertTrue(
                (bool)preg_match('/^[a-z][a-z0-9-]{1,40}$/', $key),
                'Der Schlüssel ' . $key . ' taugt nicht als Pfadsegment'
            );
            assertTrue(trim($r->title) !== '', 'Der Sammlung ' . $key . ' fehlt eine Bezeichnung');
            assertTrue(trim($r->summary) !== '', 'Der Sammlung ' . $key . ' fehlt eine Beschreibung');
            assertTrue($r->fields !== [], 'Die Sammlung ' . $key . ' gibt keine Felder heraus');
            assertTrue(isset($r->fields['id']), 'Der Sammlung ' . $key . ' fehlt die Kennung');
            assertSame('read:' . $key, $r->scope());
        }
    });

    test('Kein Feld trägt ein Geheimnis nach draußen', function () {
        // Verschlüsselte Spalten, Schlüssel und Kennwörter haben in einer
        // Antwort nichts verloren – auch nicht versehentlich über einen
        // Ausdruck, der die Spalte umbenennt.
        $verboten = ['_enc', 'password', 'passwort', 'secret', 'token', 'private_key', 'api_key'];

        foreach (ApiRegistry::all() as $key => $r) {
            foreach ($r->fields as $name => $ausdruck) {
                foreach ($verboten as $wort) {
                    assertFalse(
                        str_contains(strtolower($name), $wort) || str_contains(strtolower($ausdruck), $wort),
                        'Die Sammlung ' . $key . ' gibt ein Geheimnis heraus: ' . $name
                    );
                }
            }
        }
    });

    test('Der Tresor hat keinen Endpunkt', function () {
        // Sein Inhalt hängt am Anwendungsschlüssel und gehört hinter eine
        // Anmeldung, nicht hinter einen Schlüssel in fremder Hand.
        foreach (ApiRegistry::all() as $key => $r) {
            assertFalse($r->module === 'vault', 'Der Tresor ist über die Schnittstelle erreichbar');
            assertFalse(str_contains($r->table, 'vault'), 'Eine Sammlung liest aus dem Tresor: ' . $key);
        }
    });

    test('Jede Sammlung liest aus Tabellen, die es gibt', function () use ($wurzel) {
        // Ein Tippfehler im Tabellennamen faellt sonst erst im Betrieb auf –
        // und dort als „Sammlung nicht lesbar", was nach einem fehlenden Modul
        // aussieht statt nach einem Fehler.
        $vorhanden = [];
        foreach ((array)glob($wurzel . '/database/migrations/*.sql') as $datei) {
            preg_match_all(
                '/CREATE TABLE IF NOT EXISTS `?(\w+)`?/',
                (string)file_get_contents($datei),
                $treffer
            );
            foreach ($treffer[1] as $name) {
                $vorhanden[$name] = true;
            }
        }
        assertTrue(count($vorhanden) > 50, 'Das Schema liess sich nicht lesen');

        foreach (ApiRegistry::all() as $key => $r) {
            $tabellen = [explode(' ', trim($r->table))[0]];
            foreach ($r->joins as $join) {
                if (preg_match('/JOIN\s+(\w+)/i', $join, $m)) {
                    $tabellen[] = $m[1];
                }
            }
            foreach ($tabellen as $tabelle) {
                assertTrue(
                    isset($vorhanden[$tabelle]),
                    'Die Sammlung ' . $key . ' liest aus einer Tabelle, die es nicht gibt: ' . $tabelle
                );
            }
        }
    });

    test('Ein Filter setzt genau einen Platzhalter', function () {
        // Sonst passen Bedingung und übergebener Wert nicht zusammen, und die
        // Abfrage scheitert erst im Betrieb.
        foreach (ApiRegistry::all() as $key => $r) {
            foreach ($r->filters as $name => $bedingung) {
                assertSame(
                    1,
                    substr_count($bedingung, '?'),
                    'Der Filter ' . $key . '.' . $name . ' hat nicht genau einen Platzhalter'
                );
            }
        }
    });

    test('Sortierung kommt nur aus dem Verzeichnis', function () use ($quelle) {
        // Der Ausdruck geht ohne Platzhalter in die Abfrage; er darf deshalb
        // nie aus der Anfrage stammen.
        $ctrl = $quelle('src/Controllers/Api/ResourceController.php');
        assertTrue(
            str_contains($ctrl, 'if ($wunsch === \'\' || !isset($resource->sorts[$wunsch]))'),
            'Ein beliebiger Sortierschlüssel landet in der Abfrage'
        );
        assertTrue(
            str_contains($ctrl, "=== 'desc' ? 'DESC' : 'ASC'"),
            'Die Richtung wird nicht auf zwei Werte begrenzt'
        );
    });

    test('Die Seitengröße ist nach oben begrenzt', function () use ($quelle) {
        $ctrl = $quelle('src/Controllers/Api/ResourceController.php');
        assertTrue(str_contains($ctrl, 'min(ApiRegistry::MAX_LIMIT, $limit)'), 'Die Seitengröße ist unbegrenzt');
        assertTrue(ApiRegistry::MAX_LIMIT > 0 && ApiRegistry::MAX_LIMIT <= 1000, 'Die Obergrenze ist unbrauchbar');
    });

    test('Jeder Aufruf prüft Token, Modul und Scope', function () use ($quelle) {
        $ctrl  = $quelle('src/Controllers/Api/ResourceController.php');
        $start = (int)strpos($ctrl, 'private function vorlauf(');
        $body  = substr($ctrl, $start, 1600);

        assertTrue(str_contains($body, '$this->cors();'), 'HTTPS wird nicht erzwungen');
        assertTrue(str_contains($body, '$this->authenticateByToken()'), 'Der Token wird nicht geprüft');
        assertTrue(str_contains($body, "\$this->app->getConfig()['modules'][\$resource->module]"), 'Das Modul wird nicht geprüft');
        assertTrue(str_contains($body, '$this->hasScope($resource->scope())'), 'Der Scope wird nicht geprüft');

        // Und die beiden Endpunkte gehen wirklich durch den Vorlauf.
        assertSame(2, substr_count($ctrl, '$this->vorlauf($response,'), 'Ein Endpunkt umgeht die Prüfung');
    });

    // ----------------------------------------------------------------
    // Beschreibung und Wirklichkeit
    // ----------------------------------------------------------------

    test('Jede registrierte Route der Schnittstelle ist beschrieben', function () use ($quelle, $spec) {
        // Genau hier sind zuvor sieben Endpunkte undokumentiert geblieben.
        $app = $quelle('src/Core/Application.php');
        preg_match_all(
            "/router->(get|post)\('(\\/api\\/ex\\/[^']*)'/",
            $app,
            $treffer,
            PREG_SET_ORDER
        );

        $pfade = array_keys($spec()['paths']);
        $offen = [];

        foreach ($treffer as [, , $route]) {
            // Die allgemeinen Sammlungs-Routen sind je Sammlung beschrieben,
            // nicht unter ihrem Platzhalter – das prüft der Fall darunter.
            if (str_contains($route, '{resource}')) {
                continue;
            }
            if (!in_array($route, $pfade, true)) {
                $offen[] = $route;
            }
        }

        assertSame([], array_values(array_unique($offen)), 'Diese Endpunkte stehen in keiner Beschreibung');
    });

    test('Jede Sammlung steht mit beiden Endpunkten in der Beschreibung', function () use ($spec) {
        $pfade = $spec()['paths'];

        assertTrue(isset($pfade['/api/ex/v1']), 'Der Katalog fehlt in der Beschreibung');

        foreach (ApiRegistry::all() as $key => $r) {
            assertTrue(isset($pfade['/api/ex/v1/' . $key]['get']), 'Die Liste von ' . $key . ' fehlt');
            assertTrue(isset($pfade['/api/ex/v1/' . $key . '/{id}']['get']), 'Der Einzelabruf von ' . $key . ' fehlt');

            $beschreibung = (string)$pfade['/api/ex/v1/' . $key]['get']['description'];
            assertTrue(str_contains($beschreibung, $r->scope()), 'Bei ' . $key . ' fehlt der Scope in der Beschreibung');

            // Jeder Filter und jeder Sortierschlüssel steht als Parameter dabei.
            $namen = array_column($pfade['/api/ex/v1/' . $key]['get']['parameters'], 'name');
            foreach (array_keys($r->filters) as $filter) {
                assertTrue(in_array($filter, $namen, true), 'Der Filter ' . $filter . ' von ' . $key . ' fehlt in der Beschreibung');
            }
            foreach (array_keys($r->fields) as $feld) {
                $eigenschaften = $pfade['/api/ex/v1/' . $key]['get']['responses']['200']['content']['application/json']
                    ['schema']['properties']['data']['items']['properties'];
                assertTrue(isset($eigenschaften[$feld]), 'Das Feld ' . $feld . ' von ' . $key . ' fehlt in der Beschreibung');
            }
        }
    });

    test('Jede Sammlung bringt ihren Zugriffsbereich mit', function () {
        $bereiche = ApiScopes::all();

        foreach (ApiRegistry::all() as $key => $r) {
            assertTrue(isset($bereiche[$r->scope()]), 'Der Bereich zu ' . $key . ' ist nicht vergebbar');
            assertSame('Lesen je Modul', $bereiche[$r->scope()]['gruppe']);
        }

        // Und die gewachsenen Bereiche sind weiterhin alle da.
        foreach (array_keys(ApiScopes::LEGACY) as $alt) {
            assertTrue(isset($bereiche[$alt]), 'Der bestehende Bereich ' . $alt . ' ist verschwunden');
        }
    });

    test('Die Selbstauskunft hängt an einem Bereich', function () use ($quelle) {
        // Zuvor kam jeder gültige Token an die Angaben zur Person und an die
        // Übersicht – ohne dass ein Bereich dafür vergeben sein musste.
        $ctrl = $quelle('src/Controllers/WordPressApiController.php');
        foreach (['me', 'meSummary'] as $methode) {
            $start = (int)strpos($ctrl, 'public function ' . $methode . '(Request');
            assertTrue($start > 0, 'Methode ' . $methode . ' nicht gefunden');
            $body = substr($ctrl, $start, 900);
            assertTrue(str_contains($body, '$this->hasScope('), $methode . ' prüft keinen Bereich');
        }
    });

    test('Der gemeinsame Zugang löst jede Klasse in seinem Namensraum auf', function () use ($quelle) {
        // Ein Trait erbt die `use`-Zeilen der Klasse nicht, die ihn einbindet.
        // Eine Klasse ohne eigenen Import wird deshalb im Namensraum des Traits
        // gesucht – und dort nicht gefunden, erst zur Laufzeit.
        $trait = $quelle('src/Api/TokenAuth.php');

        preg_match_all('/(?<![\\\\\w$>])([A-Z][A-Za-z0-9_]+)::/', $trait, $treffer);
        $eigene = ['self', 'static', 'parent'];

        foreach (array_unique($treffer[1]) as $klasse) {
            if (in_array($klasse, $eigene, true)) {
                continue;
            }
            assertTrue(
                (bool)preg_match('/^use\s+[A-Za-z\\\\]+\\\\' . preg_quote($klasse, '/') . ';$/m', $trait),
                'Die Klasse ' . $klasse . ' wird ohne Import verwendet'
            );
        }
    });

    test('Der gemeinsame Zugang liegt an einer Stelle', function () use ($quelle, $wurzel) {
        // Eine zweite Fassung der Signaturprüfung wäre eine zweite Gelegenheit,
        // sie falsch zu machen.
        assertTrue(is_file($wurzel . '/src/Api/TokenAuth.php'), 'Der gemeinsame Zugang fehlt');

        $trait = $quelle('src/Api/TokenAuth.php');
        assertTrue(str_contains($trait, 'protected function verifyRequestSignature('), 'Die Signaturprüfung fehlt');

        foreach (['src/Controllers/WordPressApiController.php', 'src/Controllers/Api/ResourceController.php'] as $datei) {
            $src = $quelle($datei);
            assertTrue(str_contains($src, 'TokenAuth'), $datei . ' nutzt den gemeinsamen Zugang nicht');
            assertFalse(str_contains($src, 'function verifyRequestSignature('), $datei . ' führt eine eigene Signaturprüfung');
        }
    });
};