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