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