OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

ApiWriteTest.php

35 KB
<?php

declare(strict_types=1);

/**
 * Die schreibenden Modul-Endpunkte: Prüfung, Schema und Beschreibung.
 *
 * Die Feldregeln einer Sammlung sind eine Behauptung über die Datenbank – dass
 * es die Spalte gibt, dass sie den Wert fasst, dass eine Aufzählung genau die
 * Werte kennt. Stimmt die Behauptung nicht, scheitert erst der Aufruf im
 * Betrieb, und zwar mit einer Meldung, die nichts erklärt. Dieser Fall gleicht
 * beide Seiten ab: die Regeln gegen das Schema der Migrationen.
 *
 * Die zweite Hälfte prüft die Prüfung selbst – was sie annimmt, was sie
 * abweist – und dass die Doku beide Zugänge so beschreibt, wie sie sind.
 */

use OpenNIT\Api\ApiRegistry;
use OpenNIT\Api\ApiWriter;
use OpenNIT\Core\Database;

return static function (): void {

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

    /**
     * Die Spalten der Migrationen, in Dateireihenfolge angewendet.
     *
     * Ergibt je Tabelle die Spalte samt ihrer Definition – also so, wie sie
     * nach der letzten Migration dasteht, nicht wie sie einmal angelegt wurde.
     *
     * @return array<string, array<string,string>>
     */
    $schema = static function () use ($wurzel): array {
        static $tabellen = null;
        if ($tabellen !== null) {
            return $tabellen;
        }

        $tabellen = [];
        $dateien  = (array)glob($wurzel . '/database/migrations/*.sql');
        sort($dateien);

        foreach ($dateien as $datei) {
            $sql = (string)file_get_contents($datei);

            $teile = preg_split('/CREATE TABLE IF NOT EXISTS\s+/i', $sql) ?: [];
            array_shift($teile);
            foreach ($teile as $teil) {
                if (!preg_match('/^`?(\w+)`?/', $teil, $n)) {
                    continue;
                }
                foreach (preg_split('/\r?\n/', $teil) ?: [] as $zeile) {
                    if (preg_match('/^\s*\)\s*ENGINE/i', $zeile)) {
                        break;
                    }
                    if (preg_match('/^\s*`(\w+)`\s+(\S.*?),?\s*$/', $zeile, $c)) {
                        $tabellen[$n[1]][$c[1]] ??= $c[2];
                    }
                }
            }

            if (preg_match_all('/ALTER TABLE\s+`?(\w+)`?\s+(.*?);/is', $sql, $alter, PREG_SET_ORDER)) {
                foreach ($alter as $a) {
                    foreach (preg_split('/,\s*\n/', $a[2]) ?: [] as $stueck) {
                        if (preg_match('/^\s*(?:ADD|MODIFY)\s+COLUMN\s+`(\w+)`\s+(.*)$/is', $stueck, $m)) {
                            $tabellen[$a[1]][$m[1]] = $m[2];
                        } elseif (preg_match('/^\s*CHANGE\s+COLUMN\s+`(\w+)`\s+`(\w+)`\s+(.*)$/is', $stueck, $m)) {
                            unset($tabellen[$a[1]][$m[1]]);
                            $tabellen[$a[1]][$m[2]] = $m[3];
                        } elseif (preg_match('/^\s*DROP\s+COLUMN\s+`(\w+)`/i', $stueck, $m)) {
                            unset($tabellen[$a[1]][$m[1]]);
                        }
                    }
                }
            }
        }

        return $tabellen;
    };

    /** Eine Datenbank, die jeden Verweis kennt – oder keinen. */
    $datenbank = static function (bool $findetVerweise): Database {
        return new class ($findetVerweise) extends Database {
            public function __construct(private bool $findet)
            {
            }

            public function fetchOne(string $sql, array $params = []): ?array
            {
                return $this->findet ? ['id' => (int)($params[0] ?? 0)] : null;
            }
        };
    };

    // ----------------------------------------------------------------
    // Die Regeln gegen das Schema
    // ----------------------------------------------------------------

    test('Das Schema der Migrationen lässt sich lesen', function () use ($schema) {
        $s = $schema();
        assertTrue(count($s) > 200, 'Es wurden kaum Tabellen gefunden – der Abgleich wäre wertlos');
        assertTrue(isset($s['contracts']['company_id']), 'Eine bekannte Spalte fehlt');
        // Eine später geänderte Spalte muss in ihrem letzten Stand dastehen.
        assertTrue(
            str_contains(strtoupper($s['kanban_tasks']['month'] ?? ''), 'NULL DEFAULT NULL'),
            'Eine nachträglich geänderte Spalte wird im alten Stand geführt'
        );
    });

    test('Jedes schreibbare Feld hat eine Spalte', function () use ($schema) {
        $s = $schema();
        foreach (ApiRegistry::all() as $key => $r) {
            if (!$r->schreibbar()) {
                continue;
            }
            $tabelle = $r->baseTable();
            assertTrue(isset($s[$tabelle]), 'Die Sammlung ' . $key . ' schreibt in eine unbekannte Tabelle');
            foreach ($r->write as $feld => $regel) {
                $spalte = (string)($regel['column'] ?? $feld);
                assertTrue(
                    isset($s[$tabelle][$spalte]),
                    'Die Sammlung ' . $key . ' schreibt nach ' . $tabelle . '.' . $spalte . ' – die Spalte gibt es nicht'
                );
            }
            if ($r->ownerField !== '') {
                assertTrue(
                    isset($s[$tabelle][$r->ownerField]),
                    'Die Sammlung ' . $key . ' trägt den Urheber in eine Spalte ein, die es nicht gibt'
                );
            }
        }
    });

    test('Jede Pflichtspalte lässt sich beim Anlegen füllen', function () use ($schema) {
        // Eine Spalte ohne Vorgabe, die nicht leer bleiben darf, muss über den
        // Rumpf kommen oder aus dem Token – sonst scheitert jedes Anlegen.
        $s = $schema();
        foreach (ApiRegistry::all() as $key => $r) {
            if (!$r->schreibbar()) {
                continue;
            }
            $erreichbar = [];
            foreach ($r->write as $feld => $regel) {
                $erreichbar[(string)($regel['column'] ?? $feld)] = !empty($regel['required']);
            }
            if ($r->ownerField !== '') {
                $erreichbar[$r->ownerField] = true;
            }

            foreach ($s[$r->baseTable()] ?? [] as $spalte => $definition) {
                $d = strtoupper($definition);
                if (str_contains($d, 'AUTO_INCREMENT') || str_contains($d, 'DEFAULT ') || !str_contains($d, 'NOT NULL')) {
                    continue;
                }
                assertTrue(
                    array_key_exists($spalte, $erreichbar),
                    'Die Sammlung ' . $key . ' kann ' . $spalte . ' nicht füllen, die Spalte darf aber nicht leer bleiben'
                );
                assertTrue(
                    $erreichbar[$spalte],
                    'Die Sammlung ' . $key . ' führt ' . $spalte . ' nicht als Pflichtfeld, die Spalte verlangt einen Wert'
                );
            }
        }
    });

    test('Eine Aufzählung kennt genau die Werte ihrer Spalte', function () use ($schema) {
        $s = $schema();
        foreach (ApiRegistry::all() as $key => $r) {
            foreach ($r->write as $feld => $regel) {
                $spalte     = (string)($regel['column'] ?? $feld);
                $definition = trim((string)($s[$r->baseTable()][$spalte] ?? ''));
                $istAufzaehlung  = (bool)preg_match('/^ENUM\s*\((.*?)\)/is', $definition, $e);
                $sollAufzaehlung = ($regel['type'] ?? '') === 'enum';

                // Eine im Backend gepflegte Auswahlliste steht nicht in der Spalte:
                // Dort ist ein freier Schlüssel nötig, die Werte liefert die Tabelle.
                if (($regel['values_table'] ?? '') !== '') {
                    assertTrue($sollAufzaehlung, 'Bei ' . $key . '.' . $feld . ' gehört values_table zu einer Aufzählung');
                    assertFalse($istAufzaehlung,
                        'Bei ' . $key . '.' . $feld . ' ist die Liste pflegbar, die Spalte aber ein festes ENUM');
                    assertTrue(str_starts_with(strtoupper($definition), 'VARCHAR'),
                        'Bei ' . $key . '.' . $feld . ' muss die Spalte einen freien Schlüssel aufnehmen');
                    assertTrue(isset($s[(string)$regel['values_table']][(string)($regel['values_column'] ?? '')]),
                        'Die Werteliste von ' . $key . '.' . $feld . ' zeigt auf eine Spalte, die es nicht gibt');
                    continue;
                }

                assertSame(
                    $istAufzaehlung,
                    $sollAufzaehlung,
                    'Bei ' . $key . '.' . $feld . ' sagen Spalte und Regel Verschiedenes über die Aufzählung'
                );
                if (!$istAufzaehlung) {
                    continue;
                }

                preg_match_all("/'([^']*)'/", $e[1], $erlaubt);
                $fremd = array_diff(array_map('strval', (array)($regel['values'] ?? [])), $erlaubt[1]);
                assertSame(
                    [],
                    array_values($fremd),
                    'Die Regel ' . $key . '.' . $feld . ' lässt Werte zu, die die Spalte nicht kennt'
                );
            }
        }
    });

    test('Eine Textgrenze ist nie größer als die Spalte', function () use ($schema) {
        $s = $schema();
        foreach (ApiRegistry::all() as $key => $r) {
            foreach ($r->write as $feld => $regel) {
                if (($regel['type'] ?? 'string') !== 'string') {
                    continue;
                }
                $definition = trim((string)($s[$r->baseTable()][$regel['column'] ?? $feld] ?? ''));
                assertTrue(
                    (bool)preg_match('/^(VAR)?CHAR\s*\(\s*(\d+)\s*\)/i', $definition, $m),
                    'Das Feld ' . $key . '.' . $feld . ' gilt als kurzer Text, die Spalte ist keiner'
                );
                assertTrue(
                    (int)($regel['max'] ?? 255) <= (int)$m[2],
                    'Das Feld ' . $key . '.' . $feld . ' lässt mehr Zeichen zu, als die Spalte fasst'
                );
            }
        }
    });

    test('Was gelöscht ist, gibt keine Sammlung heraus', function () use ($schema) {
        // Wo die Oberfläche einen Datensatz nur als gelöscht markiert, muss die
        // Sammlung ihn ebenso auslassen – sonst zeigt sie, was niemand mehr
        // sehen soll, und lässt es sich sogar noch ändern.
        $s        = $schema();
        $merkmale = ['deleted_at', 'is_deleted', 'archived_at', 'is_archived'];

        foreach (ApiRegistry::all() as $key => $r) {
            $weich = array_values(array_intersect($merkmale, array_keys($s[$r->baseTable()] ?? [])));
            if ($weich === []) {
                continue;
            }
            assertTrue(
                $r->baseWhere !== '',
                'Die Sammlung ' . $key . ' kennt ' . implode('/', $weich) . ', schränkt aber nicht ein'
            );
            foreach ($weich as $spalte) {
                assertTrue(
                    str_contains($r->baseWhere, $spalte),
                    'Die Grundbedingung von ' . $key . ' geht an ' . $spalte . ' vorbei'
                );
            }
        }
    });

    test('Eine Grundbedingung passt zur Abfrage, in die sie eingesetzt wird', function () {
        foreach (ApiRegistry::all() as $key => $r) {
            if ($r->baseWhere === '') {
                assertSame('', $r->baseCondition());
                continue;
            }
            // Sie geht ohne Platzhalter in die Abfrage – ein `?` bliebe ungefüllt.
            assertSame(0, substr_count($r->baseWhere, '?'), 'Die Grundbedingung von ' . $key . ' hat einen Platzhalter');

            // Und sie spricht die Tabelle unter dem Alias an, den die Abfrage vergibt.
            $teile = preg_split('/\s+/', trim($r->table)) ?: [];
            $alias = $teile[1] ?? $teile[0];
            assertTrue(
                str_starts_with($r->baseWhere, $alias . '.'),
                'Die Grundbedingung von ' . $key . ' nennt einen anderen Alias als ' . $alias
            );

            assertSame('(' . $r->baseWhere . ')', $r->baseCondition());
        }
    });

    test('Die Grundbedingung gilt für Liste, Einzelabruf und Ändern', function () use ($quelle) {
        // In allen dreien – sonst zeigt die Liste den Datensatz nicht, der
        // Einzelabruf aber schon, oder er lässt sich noch überschreiben.
        $ctrl = $quelle('src/Controllers/Api/ResourceController.php');

        assertSame(
            3,
            substr_count($ctrl, '$resource->baseCondition()') - substr_count($ctrl, "baseCondition() !== ''"),
            'Die Grundbedingung wird nicht an allen drei Stellen eingesetzt'
        );

        foreach (['public function index(', 'public function show(', 'private function datensatz('] as $stelle) {
            $von = strpos($ctrl, $stelle);
            assertTrue($von !== false, 'Die Stelle ' . $stelle . ' fehlt');
            assertTrue(
                str_contains(substr($ctrl, $von, 1400), 'baseCondition()'),
                $stelle . ' lässt die Grundbedingung aus'
            );
        }
    });

    test('Jede Feldregel ist vollständig', function () {
        foreach (ApiRegistry::all() as $key => $r) {
            foreach ($r->write as $feld => $regel) {
                $typ = (string)($regel['type'] ?? 'string');
                assertTrue(in_array($typ, ApiWriter::TYPES, true), 'Unbekannte Feldart bei ' . $key . '.' . $feld);
                if ($typ === 'enum') {
                    assertTrue(($regel['values'] ?? []) !== [], 'Der Aufzählung ' . $key . '.' . $feld . ' fehlen die Werte');
                }
                if ($typ === 'ref') {
                    assertTrue(
                        (bool)preg_match('/^[a-z_]+$/', (string)($regel['table'] ?? '')),
                        'Dem Verweis ' . $key . '.' . $feld . ' fehlt eine auflösbare Zieltabelle'
                    );
                }
            }
        }
    });

    test('Eine schreibbare Sammlung gehört ins Protokoll', function () {
        foreach (ApiRegistry::all() as $key => $r) {
            if (!$r->schreibbar()) {
                // Ohne Begründung sieht ein fehlender Schreibzugang wie ein
                // Versehen aus – die Antwort mit HTTP 405 gibt sie weiter.
                assertTrue(trim($r->writeNote) !== '', 'Die Sammlung ' . $key . ' sagt nicht, warum sie nur lesbar ist');
                assertSame([], array_values($r->write));
                continue;
            }
            assertTrue($r->auditCategory !== '', 'Der Sammlung ' . $key . ' fehlt der Protokollbereich');
            assertTrue($r->auditObject !== '', 'Der Sammlung ' . $key . ' fehlt die Datensatzart im Protokoll');
            assertTrue(
                isset($r->write[$r->labelField]) || isset($r->fields[$r->labelField]),
                'Die Sammlung ' . $key . ' benennt ihre Datensätze über ein Feld, das es nicht gibt'
            );
            assertSame('write:' . $key, $r->writeScope());
        }
    });

    test('Kein schreibbares Feld führt ein Geheimnis herein', function () {
        // Ein verschlüsselter Wert gehört in die Oberfläche: Über die
        // Schnittstelle geschrieben läge er im Klartext in der Spalte.
        $verboten = ['_enc', 'password', 'passwort', 'secret', 'token', 'private_key', 'api_key', 'license_key'];

        foreach (ApiRegistry::all() as $key => $r) {
            foreach ($r->write as $feld => $regel) {
                $spalte = strtolower($feld . ' ' . (string)($regel['column'] ?? ''));
                foreach ($verboten as $wort) {
                    assertFalse(
                        str_contains($spalte, $wort),
                        'Die Sammlung ' . $key . ' nimmt mit ' . $feld . ' ein Geheimnis entgegen'
                    );
                }
            }
        }
    });

    // ----------------------------------------------------------------
    // Die Prüfung selbst
    // ----------------------------------------------------------------

    test('Ein unbekanntes Feld wird abgewiesen, nicht übergangen', function () use ($datenbank) {
        $writer   = new ApiWriter($datenbank(true));
        $resource = ApiRegistry::get('companies');

        $ergebnis = $writer->pruefe($resource, ['name' => 'Beispiel AG', 'geheim' => 'x'], true);

        assertFalse($ergebnis['ok']);
        assertTrue(isset($ergebnis['errors']['geheim']), 'Das unbekannte Feld wurde stillschweigend verworfen');
        assertFalse(array_key_exists('geheim', $ergebnis['values']));
    });

    test('Beim Anlegen zählt ein Pflichtfeld, beim Ändern nicht', function () use ($datenbank) {
        $writer   = new ApiWriter($datenbank(true));
        $resource = ApiRegistry::get('companies');

        $anlegen = $writer->pruefe($resource, ['customer_number' => 'K-1'], true);
        assertFalse($anlegen['ok']);
        assertTrue(isset($anlegen['errors']['name']), 'Der fehlende Name fiel beim Anlegen nicht auf');

        $aendern = $writer->pruefe($resource, ['customer_number' => 'K-1'], false);
        assertTrue($aendern['ok'], 'Beim Ändern darf ein Feld fehlen');
        assertSame(['customer_number' => 'K-1'], $aendern['values']);
    });

    test('Jeder Mangel wird auf einmal genannt', function () use ($datenbank) {
        $writer   = new ApiWriter($datenbank(true));
        $resource = ApiRegistry::get('contracts');

        $ergebnis = $writer->pruefe($resource, [
            'title'         => str_repeat('x', 300),
            'status'        => 'gibt-es-nicht',
            'start_date'    => '2026-02-30',
            'cost_amount'   => 'viel',
        ], true);

        assertFalse($ergebnis['ok']);
        foreach (['title', 'status', 'start_date', 'cost_amount', 'company_id'] as $feld) {
            assertTrue(isset($ergebnis['errors'][$feld]), 'Der Mangel bei ' . $feld . ' fehlt in der Antwort');
        }
    });

    test('Ein Datum muss es im Kalender geben', function () use ($datenbank) {
        $writer   = new ApiWriter($datenbank(true));
        $resource = ApiRegistry::get('contracts');

        foreach (['2026-02-30', '2025-13-01', '30.04.2026', '2026-4-1'] as $falsch) {
            $ergebnis = $writer->pruefe($resource, ['start_date' => $falsch], false);
            assertFalse($ergebnis['ok'], 'Das Datum ' . $falsch . ' kam durch');
        }

        $gut = $writer->pruefe($resource, ['start_date' => '2028-02-29'], false);
        assertTrue($gut['ok'], 'Ein Schaltjahrestag wurde abgewiesen');
        assertSame('2028-02-29', $gut['values']['start_date']);
    });

    test('Eine Zahl bleibt eine Zahl', function () use ($datenbank) {
        $writer   = new ApiWriter($datenbank(true));
        $resource = ApiRegistry::get('stock');

        foreach (['3,5', '4.7', 'zwölf', '', true] as $falsch) {
            assertFalse($writer->pruefe($resource, ['quantity' => $falsch], false)['ok'], 'Eine Falschangabe kam durch');
        }
        assertFalse($writer->pruefe($resource, ['quantity' => -1], false)['ok'], 'Ein Bestand unter null kam durch');

        $gut = $writer->pruefe($resource, ['quantity' => '12'], false);
        assertTrue($gut['ok']);
        assertSame(12, $gut['values']['quantity']);
    });

    test('Ein Ja-Nein-Wert wird zur Spalte, kein Text', function () use ($datenbank) {
        $writer   = new ApiWriter($datenbank(true));
        $resource = ApiRegistry::get('stock');

        assertSame(1, $writer->pruefe($resource, ['is_active' => true], false)['values']['is_active']);
        assertSame(0, $writer->pruefe($resource, ['is_active' => '0'], false)['values']['is_active']);
        assertFalse($writer->pruefe($resource, ['is_active' => 'ja'], false)['ok'], 'Ein Text kam als Ja durch');
    });

    test('Ein Verweis muss vorhanden sein', function () use ($datenbank) {
        $resource = ApiRegistry::get('contacts');

        $mit  = (new ApiWriter($datenbank(true)))->pruefe($resource, ['company_id' => 7], false);
        assertTrue($mit['ok']);
        assertSame(7, $mit['values']['company_id']);

        $ohne = (new ApiWriter($datenbank(false)))->pruefe($resource, ['company_id' => 7], false);
        assertFalse($ohne['ok'], 'Eine Kennung ins Leere wurde angenommen');
    });

    test('Ein ausdrückliches null leert nur, was leer sein darf', function () use ($datenbank) {
        $writer   = new ApiWriter($datenbank(true));
        $resource = ApiRegistry::get('companies');

        $leeren = $writer->pruefe($resource, ['customer_number' => null], false);
        assertTrue($leeren['ok']);
        assertTrue(array_key_exists('customer_number', $leeren['values']));
        assertSame(null, $leeren['values']['customer_number']);

        assertFalse($writer->pruefe($resource, ['name' => null], false)['ok'], 'Ein Pflichtfeld ließ sich leeren');
    });

    // ----------------------------------------------------------------
    // Zugang und Beschreibung
    // ----------------------------------------------------------------

    test('Schreiben braucht einen eigenen Bereich', function () {
        // Wer lesen darf, darf damit noch nicht schreiben: Der Schreibbereich
        // ist ausdrücklich zu vergeben und deckt sich mit keinem Lesebereich.
        $alle = \OpenNIT\Api\ApiScopes::all();

        foreach (ApiRegistry::all() as $r) {
            if (!$r->schreibbar()) {
                assertFalse(isset($alle[$r->writeScope()]), 'Eine nur lesbare Sammlung bietet einen Schreibbereich an');
                continue;
            }
            assertTrue(isset($alle[$r->writeScope()]), 'Der Sammlung ' . $r->key . ' fehlt ihr Schreibbereich');
            assertTrue($alle[$r->writeScope()]['explizit'], 'Der Schreibbereich ' . $r->writeScope() . ' gilt als mitgemeint');
            assertSame('Schreiben je Modul', $alle[$r->writeScope()]['gruppe']);
            assertTrue($r->writeScope() !== $r->scope());
        }
    });

    test('Die schreibenden Wege stehen in der Beschreibung', function () use ($wurzel) {
        $klasse = new ReflectionClass(OpenNIT\Controllers\Admin\WordPressAdminController::class);
        $m      = $klasse->getMethod('buildOpenapiSpec');
        $m->setAccessible(true);
        $spec   = $m->invoke($klasse->newInstanceWithoutConstructor(), 'https://example.test');

        foreach (ApiRegistry::all() as $key => $r) {
            $liste  = $spec['paths']['/api/ex/v1/' . $key] ?? [];
            $einzel = $spec['paths']['/api/ex/v1/' . $key . '/{id}'] ?? [];

            assertSame($r->schreibbar(), isset($liste['post']), 'Bei ' . $key . ' passt das Anlegen nicht zur Sammlung');
            assertSame($r->schreibbar(), isset($einzel['patch']), 'Bei ' . $key . ' passt das Ändern nicht zur Sammlung');
            // Gelöscht wird über diese Schnittstelle bewusst nicht.
            assertFalse(isset($liste['delete']) || isset($einzel['delete']), 'Bei ' . $key . ' steht ein Löschweg');

            if (!$r->schreibbar()) {
                continue;
            }

            $rumpf = $liste['post']['requestBody']['content']['application/json']['schema'] ?? [];
            assertSame(array_keys($r->write), array_keys($rumpf['properties'] ?? []), 'Die Felder bei ' . $key . ' weichen ab');

            $pflicht = [];
            foreach ($r->write as $feld => $regel) {
                if (!empty($regel['required'])) {
                    $pflicht[] = $feld;
                }
            }
            assertSame($pflicht, $rumpf['required'] ?? [], 'Die Pflichtfelder bei ' . $key . ' weichen ab');

            // Beim Ändern ist nichts Pflicht – ein Rumpf mit einem Feld genügt.
            $aendern = $einzel['patch']['requestBody']['content']['application/json']['schema'] ?? [];
            assertFalse(isset($aendern['required']), 'Das Ändern bei ' . $key . ' verlangt einen vollständigen Rumpf');

            assertTrue(str_contains((string)($liste['post']['description'] ?? ''), $r->writeScope()));
        }
    });

    test('Jeder Verweis der Beschreibung führt irgendwohin', function () {
        $klasse = new ReflectionClass(OpenNIT\Controllers\Admin\WordPressAdminController::class);
        $m      = $klasse->getMethod('buildOpenapiSpec');
        $m->setAccessible(true);
        $spec   = $m->invoke($klasse->newInstanceWithoutConstructor(), 'https://example.test');

        $refs = [];
        $tags = [];
        $lauf = static function ($schluessel, $wert) use (&$lauf, &$refs, &$tags): void {
            if (!is_array($wert)) {
                return;
            }
            if ($schluessel === 'tags' && array_is_list($wert)) {
                foreach ($wert as $t) {
                    if (is_string($t)) {
                        $tags[$t] = true;
                    }
                }
            }
            foreach ($wert as $k => $v) {
                if ($k === '$ref' && is_string($v)) {
                    $refs[$v] = true;
                }
                $lauf($k, $v);
            }
        };
        $lauf('', $spec['paths']);

        foreach (array_keys($refs) as $ref) {
            if (!str_starts_with($ref, '#/components/responses/')) {
                continue;
            }
            assertTrue(
                isset($spec['components']['responses'][substr($ref, 23)]),
                'Die Beschreibung verweist auf ' . $ref . ' – das gibt es nicht'
            );
        }

        $erklaert = array_column($spec['tags'], 'name');
        foreach (array_keys($tags) as $tag) {
            assertTrue(in_array($tag, $erklaert, true), 'Der Abschnitt ' . $tag . ' ist nirgends erklärt');
        }
        // Gegliedert wird nach Sammlung, nicht nach Art des Aufrufs: Lesen,
        // Anlegen und Ändern einer Sammlung stehen unter einer Überschrift.
        foreach (\OpenNIT\Api\ApiRegistry::all() as $key => $resource) {
            $pfad = $spec['paths']['/api/ex/v1/' . $key] ?? null;
            assertTrue($pfad !== null, 'Sammlung ohne Pfad: ' . $key);
            assertSame([$resource->title], $pfad['get']['tags'], $key . ' steht nicht unter seinem Modul');

            if ($resource->schreibbar()) {
                assertSame([$resource->title], $pfad['post']['tags'], $key . ': Anlegen steht anderswo');
                assertSame(
                    [$resource->title],
                    $spec['paths']['/api/ex/v1/' . $key . '/{id}']['patch']['tags'],
                    $key . ': Ändern steht anderswo'
                );
                assertTrue(
                    str_contains($spec['tags'][array_search($resource->title, $erklaert, true)]['description'],
                        $resource->writeScope()),
                    $key . ': Der Abschnitt nennt den Schreibbereich nicht'
                );
            }
        }
    });

    test('Der schreibende Weg prüft den Schreibbereich', function () use ($quelle) {
        $ctrl = $quelle('src/Controllers/Api/ResourceController.php');

        $start = strpos($ctrl, 'private function schreibVorlauf(');
        assertTrue($start !== false, 'Der gemeinsame Vorlauf des Schreibens fehlt');
        $rumpf = substr($ctrl, $start, 1600);
        assertTrue(str_contains($rumpf, '$this->hasScope($resource->writeScope())'), 'Der Schreibbereich wird nicht geprüft');
        assertTrue(str_contains($rumpf, '$resource->schreibbar()'), 'Eine nur lesbare Sammlung wird nicht abgewiesen');
        assertFalse(str_contains($rumpf, '$this->hasScope($resource->scope())'), 'Der Lesebereich öffnet den Schreibweg');

        // Gelöscht wird hier nicht – keine Handlung, die es doch täte.
        assertFalse(str_contains($ctrl, 'public function destroy('), 'Es gibt einen Löschweg');
        assertFalse(str_contains($ctrl, '->delete('), 'Es wird gelöscht');
    });

    test('Das Protokoll hält die Felder fest, nicht die Werte', function () use ($quelle) {
        // Ein Wert kann personenbezogen sein; im Protokoll steht deshalb nur,
        // was angefasst wurde.
        $ctrl  = $quelle('src/Controllers/Api/ResourceController.php');
        $start = strpos($ctrl, 'private function protokolliereSchreiben(');
        assertTrue($start !== false, 'Der Protokolleintrag des Schreibens fehlt');

        $rumpf = substr($ctrl, $start, 1200);
        assertTrue(str_contains($rumpf, "implode(', ', array_keys(\$werte))"), 'Es werden nicht die Feldnamen protokolliert');
        assertFalse(str_contains($rumpf, "'werte' => \$werte"), 'Die Werte selbst gehen ins Protokoll');
        assertTrue(str_contains($rumpf, "'api'"), 'Die Herkunft des Schreibvorgangs fehlt');
    });

    test('Der Urheber kommt aus dem Token, nicht aus dem Rumpf', function () use ($quelle) {
        $ctrl = $quelle('src/Controllers/Api/ResourceController.php');

        $start = strpos($ctrl, 'public function store(');
        assertTrue($start !== false, 'Das Anlegen fehlt');
        $rumpf = substr($ctrl, $start, 1800);
        assertTrue(str_contains($rumpf, "\$this->currentToken['created_by']"), 'Der Urheber wird nicht aus dem Token genommen');

        // Beide Zugänge müssen die Spalte auch tatsächlich abfragen – geprüft
        // wird die Spaltenliste der Abfrage, nicht das Wort irgendwo im Umfeld.
        $trait = $quelle('src/Api/TokenAuth.php');
        foreach (['authenticateByToken', 'authenticateTokenOnly'] as $methode) {
            $s = strpos($trait, 'function ' . $methode . '(');
            assertTrue($s !== false, 'Die Methode ' . $methode . ' fehlt');
            assertTrue(
                (bool)preg_match(
                    '/SELECT\s+(.*?)\s+FROM wordpress_api_tokens/i',
                    substr($trait, $s, 1200),
                    $spalten
                ),
                $methode . ' fragt den Token nicht ab'
            );
            assertTrue(
                in_array('created_by', array_map('trim', explode(',', $spalten[1])), true),
                $methode . ' lädt die ausstellende Person nicht'
            );
        }

        // Ein Rumpf darf den Urheber nicht setzen können.
        foreach (ApiRegistry::all() as $key => $r) {
            if ($r->ownerField === '') {
                continue;
            }
            assertFalse(
                isset($r->write[$r->ownerField]),
                'Die Sammlung ' . $key . ' lässt den Urheber von außen setzen'
            );
        }
    });

    test('Ein E-Mail-Feld im Rumpf gilt hier als Angabe, nicht als Person', function () use ($quelle) {
        // Die Portal-Endpunkte binden die handelnde Person über den Rumpf in die
        // Signatur ein. Bei den Sammlungen ist `email` ein Feld wie jedes andere
        // – wer es mitschickt, änderte sonst unbeabsichtigt die Signatur.
        $trait = $quelle('src/Api/TokenAuth.php');
        assertTrue(
            str_contains($trait, '$this->emailImRumpfIstIdentitaet()'),
            'Die Signatur fragt nicht, ob der Rumpf eine Person benennt'
        );

        $ctrl = $quelle('src/Controllers/Api/ResourceController.php');
        assertTrue(
            (bool)preg_match(
                '/protected function emailImRumpfIstIdentitaet\(\): bool\s*\{\s*return false;/',
                $ctrl
            ),
            'Die Sammlungen behandeln ein E-Mail-Feld weiterhin als Person'
        );

        // Das Portal muss dabei bleiben, wie es war.
        $portal = $quelle('src/Controllers/WordPressApiController.php');
        assertFalse(
            str_contains($portal, 'function emailImRumpfIstIdentitaet('),
            'Das Portal weicht von der gemeinsamen Vorgabe ab'
        );

        // Nur Sammlungen ohne Personenbezug dürfen ein E-Mail-Feld schreiben.
        foreach (ApiRegistry::all() as $key => $r) {
            if (!isset($r->write['email'])) {
                continue;
            }
            assertTrue(
                in_array($key, ['companies', 'contacts'], true),
                'Die Sammlung ' . $key . ' schreibt ein E-Mail-Feld – das ist zu prüfen'
            );
        }
    });

    test('Beide Schreibweisen des Query-Strings werden anerkannt', function () {
        // Dieselben Parameter, zwei übliche Kodierungen des Leerzeichens: Welche
        // ein Gegenüber wählt, hängt an seiner Sprache – die Signatur darf nicht
        // daran scheitern.
        $geheim = str_repeat('a1b2', 16);

        // Der Replay-Schutz schreibt jede Nonce weg; hier nimmt sie eine
        // Attrappe entgegen, damit allein die Signatur über das Ergebnis
        // entscheidet.
        $controller = new class extends OpenNIT\Controllers\Api\ResourceController {
            public function __construct()
            {
            }

            protected function db()
            {
                return new class extends OpenNIT\Core\Database {
                    public function __construct()
                    {
                    }

                    public function insert(string $table, array $data): int
                    {
                        return 1;
                    }

                    public function execute(string $sql, array $params = []): PDOStatement
                    {
                        return new PDOStatement();
                    }
                };
            }
        };

        $pruefe = new ReflectionMethod($controller, 'verifyRequestSignature');
        $pruefe->setAccessible(true);

        $stellen = static function (string $query, int $kodierung) use ($geheim): void {
            $ts    = (string)time();
            $nonce = bin2hex(random_bytes(16));

            parse_str($query, $teile);
            ksort($teile);
            $kanon = implode("\n", [
                'v2', 'GET', '/api/ex/v1/companies',
                http_build_query($teile, '', '&', $kodierung),
                '', hash('sha256', ''), $ts, $nonce,
            ]);

            $_SERVER['REQUEST_METHOD']           = 'GET';
            $_SERVER['REQUEST_URI']              = '/api/ex/v1/companies?' . $query;
            $_SERVER['HTTP_X_OPENNIT_TIMESTAMP'] = $ts;
            $_SERVER['HTTP_X_OPENNIT_NONCE']     = $nonce;
            $_SERVER['HTTP_X_OPENNIT_SIGNATURE'] = hash_hmac('sha256', $kanon, $geheim);
            $_GET = $teile;
        };

        $query = 'limit=5&q=' . rawurlencode('ZZ Wegwerf');

        $stellen($query, PHP_QUERY_RFC3986);
        assertTrue($pruefe->invoke($controller, $geheim, 0), 'Ein Leerzeichen als %20 wurde abgewiesen');

        $stellen($query, PHP_QUERY_RFC1738);
        assertTrue($pruefe->invoke($controller, $geheim, 0), 'Ein Leerzeichen als + wurde abgewiesen');

        // Eine falsche Signatur bleibt falsch.
        $stellen($query, PHP_QUERY_RFC3986);
        $_SERVER['HTTP_X_OPENNIT_SIGNATURE'] = str_repeat('0', 64);
        assertFalse($pruefe->invoke($controller, $geheim, 0), 'Eine falsche Signatur kam durch');

        // Ein anderer Pfad ebenso – der Query allein trägt die Signatur nicht.
        $stellen($query, PHP_QUERY_RFC3986);
        $_SERVER['REQUEST_URI'] = '/api/ex/v1/contracts?' . $query;
        assertFalse($pruefe->invoke($controller, $geheim, 0), 'Der Pfad geht nicht in die Signatur ein');

        $_GET = [];
    });

    test('Ändern ist als eigene Methode registriert', function () use ($quelle) {
        $app = $quelle('src/Core/Application.php');

        assertTrue(str_contains($app, "post('/api/ex/v1/{resource}',"), 'Das Anlegen hat keine Route');
        assertTrue(str_contains($app, "patch('/api/ex/v1/{resource}/{id}',"), 'Das Ändern hat keine Route');
        assertFalse(str_contains($app, "delete('/api/ex/v1/"), 'Es gibt doch eine Löschroute');

        // PATCH ist ohne Sitzung unterwegs und muss wie POST behandelt werden.
        assertTrue(
            str_contains($app, "['POST', 'PUT', 'PATCH', 'DELETE']"),
            'PATCH steht nicht in den Methoden, die einen CSRF-Token verlangen'
        );

        $router = $quelle('src/Core/Router.php');
        assertTrue(str_contains($router, 'public function patch('), 'Der Router kennt PATCH nicht');
    });
};