Quelltext des aktuellen Standes
<?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');
}
});
};