mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-11-11 10:55:22 +00:00
156 lines
6.7 KiB
PHP
156 lines
6.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Mailcow\Tests\Api;
|
|
|
|
use Symfony\Component\Yaml\Yaml;
|
|
|
|
/** data/web/api/openapi.yaml against json_api.php: every route documented, every documented route real, the spec consistent */
|
|
class OpenApiSpecTest extends ApiTestCase
|
|
{
|
|
private const SPEC = __DIR__ . '/../../../data/web/api/openapi.yaml';
|
|
private const JSON_API = __DIR__ . '/../../../data/web/json_api.php';
|
|
|
|
/** Routes of the mailcow UI that an API client cannot use, with the reason */
|
|
private const UNDOCUMENTED = [
|
|
// WebAuthn ceremonies of the browser: the challenge lives in the UI session
|
|
'add/fido2-registration' => 'WebAuthn',
|
|
'add/webauthn-tfa-registration' => 'WebAuthn',
|
|
'get/fido2-registration' => 'WebAuthn',
|
|
'get/fido2-get-args' => 'WebAuthn',
|
|
'get/webauthn-tfa-registration' => 'WebAuthn',
|
|
'get/webauthn-tfa-get-args' => 'WebAuthn',
|
|
// the own account of a UI session (domain admin, mailbox user): an API key gets an empty answer
|
|
'edit/self' => 'own account',
|
|
// renames a FIDO2 key of the logged-in account, the API account has none
|
|
'edit/fido2-fn' => 'own account',
|
|
];
|
|
|
|
private static ?array $spec = null;
|
|
|
|
private static function spec(): array
|
|
{
|
|
return self::$spec ??= Yaml::parseFile(self::SPEC);
|
|
}
|
|
|
|
/** @return list<array{0: string, 1: string, 2: array}> path, method, operation */
|
|
private static function operations(): array
|
|
{
|
|
$operations = [];
|
|
foreach (self::spec()['paths'] as $path => $item) {
|
|
foreach ($item as $method => $operation) {
|
|
if (in_array($method, ['get', 'post', 'put', 'patch', 'delete'], true)) {
|
|
$operations[] = [$path, $method, $operation];
|
|
}
|
|
}
|
|
}
|
|
return $operations;
|
|
}
|
|
|
|
public function testEveryRouteIsDocumented(): void
|
|
{
|
|
$paths = array_keys(self::spec()['paths']);
|
|
$missing = [];
|
|
foreach (RouteMap::fromFile(self::JSON_API) as $route) {
|
|
$covered = isset(self::UNDOCUMENTED[$route]) || !empty(array_filter($paths, fn(string $path): bool => RouteMap::covers($path, $route)));
|
|
if (!$covered) {
|
|
$missing[] = $route;
|
|
}
|
|
}
|
|
$this->assertSame([], $missing, 'routes of json_api.php missing in openapi.yaml');
|
|
}
|
|
|
|
public function testTheUndocumentedRoutesExist(): void
|
|
{
|
|
$this->assertSame([], array_values(array_diff(array_keys(self::UNDOCUMENTED), RouteMap::fromFile(self::JSON_API))));
|
|
}
|
|
|
|
public function testEveryDocumentedRouteExists(): void
|
|
{
|
|
$unknown = [];
|
|
foreach (self::operations() as [$path, $method, $operation]) {
|
|
$route = preg_replace_callback('/\{([^}]+)\}/', fn(array $m): string => self::exampleOf($operation, $m[1]), $path);
|
|
$action = explode('/', trim(preg_replace('#^/api/v1/#', '', $path), '/'))[0];
|
|
$body = match ($action) {
|
|
'edit' => ['items' => [], 'attr' => (object)[]],
|
|
'delete' => [],
|
|
default => (object)[],
|
|
};
|
|
$result = ($method === 'get') ? $this->get($route, ['allow_error' => true]) : $this->post($route, $body, ['allow_error' => true]);
|
|
$answer = $result->json();
|
|
if ($result->status === 404 && is_array($answer) && ($answer['type'] ?? '') === 'error') {
|
|
$unknown[] = strtoupper($method) . ' ' . $path . ': ' . ($answer['msg'] ?? '');
|
|
}
|
|
}
|
|
$this->assertSame([], $unknown, 'documented routes that json_api.php does not know');
|
|
}
|
|
|
|
public function testMethodsFollowTheAction(): void
|
|
{
|
|
foreach (self::operations() as [$path, $method]) {
|
|
$action = explode('/', trim(preg_replace('#^/api/v1/#', '', $path), '/'))[0];
|
|
$this->assertSame(($action === 'get') ? 'get' : 'post', $method, $path);
|
|
}
|
|
}
|
|
|
|
public function testOperationsAreComplete(): void
|
|
{
|
|
$tags = array_column(self::spec()['tags'], 'name');
|
|
$ids = [];
|
|
foreach (self::operations() as [$path, $method, $operation]) {
|
|
$where = strtoupper($method) . ' ' . $path;
|
|
$this->assertNotEmpty($operation['summary'] ?? null, "$where: summary");
|
|
$this->assertNotEmpty($operation['operationId'] ?? null, "$where: operationId");
|
|
$ids[] = $operation['operationId'];
|
|
$this->assertNotEmpty($operation['tags'] ?? [], "$where: tags");
|
|
$this->assertSame([], array_values(array_diff($operation['tags'] ?? [], $tags)), "$where: tags not in the tag list");
|
|
$this->assertArrayHasKey('200', $operation['responses'] ?? [], "$where: 200 response");
|
|
$this->assertArrayHasKey('401', $operation['responses'] ?? [], "$where: 401 response");
|
|
preg_match_all('/\{([^}]+)\}/', $path, $placeholders);
|
|
$declared = array_column(array_filter($operation['parameters'] ?? [], fn(array $p): bool => ($p['in'] ?? '') === 'path'), 'name');
|
|
sort($declared);
|
|
$expected = $placeholders[1];
|
|
sort($expected);
|
|
$this->assertSame($expected, $declared, "$where: path parameters");
|
|
}
|
|
$this->assertSame(array_values(array_unique($ids)), $ids, 'operationIds must be unique');
|
|
}
|
|
|
|
public function testEveryTagIsUsed(): void
|
|
{
|
|
$used = array_merge(...array_map(fn(array $op): array => $op[2]['tags'] ?? [], self::operations()));
|
|
$this->assertSame([], array_values(array_diff(array_column(self::spec()['tags'], 'name'), $used)));
|
|
}
|
|
|
|
public function testReferencesResolve(): void
|
|
{
|
|
$refs = [];
|
|
$spec = self::spec();
|
|
array_walk_recursive($spec, function (mixed $value, mixed $key) use (&$refs): void {
|
|
if ($key === '$ref') {
|
|
$refs[] = $value;
|
|
}
|
|
});
|
|
foreach (array_unique($refs) as $ref) {
|
|
$node = self::spec();
|
|
foreach (explode('/', substr($ref, 2)) as $part) {
|
|
$this->assertIsArray($node, $ref);
|
|
$this->assertArrayHasKey(str_replace(['~1', '~0'], ['/', '~'], $part), $node, "unresolved $ref");
|
|
$node = $node[str_replace(['~1', '~0'], ['/', '~'], $part)];
|
|
}
|
|
}
|
|
}
|
|
|
|
private static function exampleOf(array $operation, string $name): string
|
|
{
|
|
foreach ($operation['parameters'] ?? [] as $parameter) {
|
|
if (($parameter['name'] ?? null) === $name) {
|
|
$example = $parameter['example'] ?? $parameter['schema']['example'] ?? $parameter['schema']['enum'][0] ?? 'test';
|
|
return rawurlencode((string)$example);
|
|
}
|
|
}
|
|
return 'test';
|
|
}
|
|
}
|