OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

ApiSpecAccuracyTest.php

7 KB
<?php

declare(strict_types=1);

use OpenNIT\Controllers\Admin\WordPressAdminController;

/**
 * Deckt ab, dass die API-Dokumentation beschreibt, was die API verlangt.
 *
 * Die Spezifikation nannte als Sicherheitsverfahren allein den Bearer-Token,
 * während fast jeder Endpunkt zusätzlich eine HMAC-Signatur erwartet. Wer sich
 * an die Dokumentation hielt, bekam zwangsläufig 401. Eine Beschreibung, die
 * von der Umsetzung abweicht, ist schlimmer als keine – deshalb wird die
 * Übereinstimmung hier festgehalten.
 */
return static function (): void {

    /** Erzeugt die Spezifikation ohne App-Bootstrap. */
    $spec = static function (): array {
        $klasse = new ReflectionClass(WordPressAdminController::class);
        $m = $klasse->getMethod('buildOpenapiSpec');
        $m->setAccessible(true);
        return $m->invoke($klasse->newInstanceWithoutConstructor(), 'https://beispiel.test');
    };

    // ----------------------------------------------------------------
    // Verfahren
    // ----------------------------------------------------------------

    test('Die Signatur-Header sind als Verfahren beschrieben', function () use ($spec) {
        $schemata = $spec()['components']['securitySchemes'] ?? [];

        foreach (['BearerToken', 'SignatureTimestamp', 'SignatureNonce', 'Signature'] as $name) {
            assertTrue(isset($schemata[$name]), 'Verfahren fehlt: ' . $name);
        }

        assertSame('X-OpenNIT-Timestamp', $schemata['SignatureTimestamp']['name']);
        assertSame('X-OpenNIT-Nonce', $schemata['SignatureNonce']['name']);
        assertSame('X-OpenNIT-Signature', $schemata['Signature']['name']);
    });

    test('Bearer und Signatur werden gemeinsam verlangt', function () use ($spec) {
        // Ein einziges Objekt bedeutet UND; mehrere Objekte hießen ODER und
        // würden den Bearer allein als ausreichend beschreiben.
        $security = $spec()['security'] ?? [];

        assertSame(1, count($security), 'Die Verfahren sind mit ODER verknüpft');
        assertSame(
            ['BearerToken', 'SignatureTimestamp', 'SignatureNonce', 'Signature'],
            array_keys($security[0])
        );
    });

    test('Die Agent-Endpunkte kommen ohne Signatur aus', function () use ($spec) {
        // Scan- und DNS-Agent signieren nicht; für sie genügt der Bearer.
        $paths = $spec()['paths'];

        foreach ([
            ['/api/ex/ipam/subnets', 'get'],
            ['/api/ex/ipam/scan-results', 'post'],
            ['/api/ex/dns/zones', 'post'],
        ] as [$pfad, $verb]) {
            $op = $paths[$pfad][$verb] ?? null;
            assertTrue($op !== null, 'Operation fehlt: ' . $verb . ' ' . $pfad);
            assertSame(['BearerToken'], array_keys($op['security'][0]), $verb . ' ' . $pfad);
        }
    });

    // ----------------------------------------------------------------
    // Anleitung
    // ----------------------------------------------------------------

    test('Die Beschreibung nennt die kanonische Zeichenkette', function () use ($spec) {
        $text = $spec()['info']['description'] ?? '';

        foreach (['X-OpenNIT-Timestamp', 'X-OpenNIT-Nonce', 'X-OpenNIT-Signature', 'v2', 'Signing-Secret'] as $teil) {
            assertTrue(str_contains($text, $teil), 'Fehlt in der Beschreibung: ' . $teil);
        }
    });

    // ----------------------------------------------------------------
    // Übereinstimmung mit der Prüfung
    // ----------------------------------------------------------------

    test('Die dokumentierte Zeichenkette ergibt die erwartete Signatur', function () {
        // Nachgerechnet in derselben Reihenfolge, die verifyRequestSignature()
        // bildet: v2, Methode, Pfad, Query, E-Mail, Body-Hash, Zeit, Nonce.
        $secret = 'geheimes-signing-secret';
        $ts     = '1757000000';
        $nonce  = 'abcdef0123456789';

        parse_str('limit=5&email=a%40b.de', $teile);
        ksort($teile);

        $signatur = hash_hmac('sha256', implode("\n", [
            'v2', 'GET', '/api/ex/my-tickets', http_build_query($teile),
            'a@b.de', hash('sha256', ''), $ts, $nonce,
        ]), $secret);

        assertSame(64, strlen($signatur));
        assertSame(strtolower($signatur), $signatur, 'Die Signatur ist nicht kleingeschrieben');
    });

    // ----------------------------------------------------------------
    // Markdown-Download (opennit-api-docs.md)
    // ----------------------------------------------------------------

    /** Liefert die heruntergeladene Dokumentation als Text. */
    $markdown = static function (): string {
        $zeilen = file(
            dirname(__DIR__) . '/src/Controllers/Admin/WordPressAdminController.php'
        ) ?: [];

        $start = null;
        foreach ($zeilen as $i => $zeile) {
            if (trim($zeile) === '$md = <<<MD') {
                $start = $i;
            }
            if ($start !== null && trim($zeile) === 'MD;') {
                $block = implode('', array_slice($zeilen, $start, $i - $start + 1));
                $base  = 'https://beispiel.test/api/ex';
                return (string)eval($block . ' return $md;');
            }
        }

        return '';
    };

    test('Die Markdown-Doku nennt die Signaturpflicht', function () use ($markdown) {
        $text = $markdown();

        assertTrue($text !== '', 'Der Dokumentations-Block wurde nicht gefunden');

        foreach ([
            'X-OpenNIT-Timestamp',
            'X-OpenNIT-Nonce',
            'X-OpenNIT-Signature',
            'Signing-Secret',
            'v2',
        ] as $teil) {
            assertTrue(str_contains($text, $teil), 'Fehlt in der Markdown-Doku: ' . $teil);
        }
    });

    test('Die Markdown-Doku benennt die Ausnahme für die Agenten', function () use ($markdown) {
        $text = $markdown();

        assertTrue(str_contains($text, 'ipam_scan'), 'Der Scan-Agent ist nicht als Ausnahme genannt');
        assertTrue(str_contains($text, 'dns_import'), 'Der DNS-Agent ist nicht als Ausnahme genannt');
    });

    test('Das Beispiel in der Markdown-Doku ergibt die erwartete Signatur', function () use ($markdown) {
        // Nachgerechnet für den im Beispiel gezeigten Aufruf: GET ohne Query,
        // ohne E-Mail, mit leerem Body.
        assertTrue(
            str_contains(
                $markdown(),
                "printf 'v2\\nGET\\n/api/ex/loan-devices\\n\\n\\n%s\\n%s\\n%s'"
            ),
            'Das Beispiel bildet nicht mehr die kanonische Zeichenkette'
        );

        $erwartet = hash_hmac('sha256', implode("\n", [
            'v2', 'GET', '/api/ex/loan-devices', '', '', hash('sha256', ''),
            '1757000000', 'abcdef0123456789',
        ]), 'geheim');

        assertSame('8db8afd5b95243f3c046e338f5465549a1f14887ec1ff960e2685e099eb38509', $erwartet);
    });

    test('Die Reihenfolge der Bestandteile steht so im Quelltext', function () {
        // Bindet die Beschreibung an die Umsetzung: Ändert sich die Reihenfolge
        // in verifyRequestSignature(), schlägt dieser Test an. Die Prüfung liegt
        // im gemeinsamen Zugang, den alle Endpunkte der Schnittstelle nutzen.
        $src = (string)file_get_contents(dirname(__DIR__) . '/src/Api/TokenAuth.php');

        assertTrue(
            str_contains($src, "['v2', \$method, \$path, \$canonicalQuery, \$email, \$bodyHash, \$ts, \$nonce]"),
            'Die Reihenfolge der signierten Bestandteile hat sich geändert'
        );
    });
};