From b99d435ccd76b73f1135fec732ca5a811e421cba Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 13 Nov 2026 17:14:58 +0000 Subject: [PATCH 1/3] fix(openapi): declare optional sign-request headers Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Controller/IdDocsController.php | 4 +++ .../SignatureElementsController.php | 3 +++ .../Middleware/InjectionMiddlewareTest.php | 25 +++++++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/lib/Controller/IdDocsController.php b/lib/Controller/IdDocsController.php index f7d6823084..b83a5af186 100644 --- a/lib/Controller/IdDocsController.php +++ b/lib/Controller/IdDocsController.php @@ -21,6 +21,7 @@ use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\Attribute\RequestHeader; use OCP\AppFramework\Http\DataResponse; use OCP\IL10N; use OCP\IRequest; @@ -63,6 +64,7 @@ public function __construct( #[AnonRateLimit(limit: 30, period: 60)] #[NoAdminRequired] #[NoCSRFRequired] + #[RequestHeader(name: 'libresign-sign-request-uuid', description: 'The UUID of the sign request, used to identify the request', indirect: true)] #[RequireSignRequestUuid(skipIfAuthenticated: true)] #[OpenAPI(tags: ['identity'])] #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/id-docs', requirements: ['apiVersion' => '(v1)'])] @@ -113,6 +115,7 @@ public function addFiles(array $files): DataResponse { #[AnonRateLimit(limit: 30, period: 60)] #[NoAdminRequired] #[NoCSRFRequired] + #[RequestHeader(name: 'libresign-sign-request-uuid', description: 'The UUID of the sign request, used to identify the request', indirect: true)] #[RequireSignRequestUuid(skipIfAuthenticated: true)] #[OpenAPI(tags: ['identity'])] #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/id-docs/{nodeId}', requirements: ['apiVersion' => '(v1)'])] @@ -153,6 +156,7 @@ public function delete(int $nodeId, ?string $uuid = null): DataResponse { #[PublicPage] #[AnonRateLimit(limit: 30, period: 60)] #[NoCSRFRequired] + #[RequestHeader(name: 'libresign-sign-request-uuid', description: 'The UUID of the sign request, used to identify the request', indirect: true)] #[RequireSignRequestUuid(skipIfAuthenticated: true)] #[OpenAPI(tags: ['identity'])] #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/id-docs', requirements: ['apiVersion' => '(v1)'])] diff --git a/lib/Controller/SignatureElementsController.php b/lib/Controller/SignatureElementsController.php index bbb1f0fad1..4fdb810246 100644 --- a/lib/Controller/SignatureElementsController.php +++ b/lib/Controller/SignatureElementsController.php @@ -239,6 +239,7 @@ public function useSavedSignatureElements(string $token = ''): DataResponse { #[NoAdminRequired] #[PublicPage] #[NoCSRFRequired] + #[RequestHeader(name: 'libresign-sign-request-uuid', description: 'The UUID of the sign request, used to identify the request', indirect: true)] #[RequireSignRequestUuid(skipIfAuthenticated: true)] #[OpenAPI(tags: ['appearance'])] #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/signature/elements/preview/{nodeId}', requirements: ['apiVersion' => '(v1)'])] @@ -322,6 +323,7 @@ public function getSignatureElement(int $nodeId): DataResponse { #[NoAdminRequired] #[PublicPage] #[NoCSRFRequired] + #[RequestHeader(name: 'libresign-sign-request-uuid', description: 'The UUID of the sign request, used to identify the request', indirect: true)] #[RequireSignRequestUuid(skipIfAuthenticated: true)] #[OpenAPI(tags: ['appearance'])] #[ApiRoute(verb: 'PATCH', url: '/api/{apiVersion}/signature/elements/{nodeId}', requirements: ['apiVersion' => '(v1)'])] @@ -380,6 +382,7 @@ public function patchSignatureElement(int $nodeId, string $type = '', array $fil #[NoAdminRequired] #[NoCSRFRequired] #[PublicPage] + #[RequestHeader(name: 'libresign-sign-request-uuid', description: 'The UUID of the sign request, used to identify the request', indirect: true)] #[RequireSignRequestUuid(skipIfAuthenticated: true)] #[OpenAPI(tags: ['appearance'])] #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/signature/elements/{nodeId}', requirements: ['apiVersion' => '(v1)'])] diff --git a/tests/php/Unit/Middleware/InjectionMiddlewareTest.php b/tests/php/Unit/Middleware/InjectionMiddlewareTest.php index bb6b94bc3b..4846bd0406 100644 --- a/tests/php/Unit/Middleware/InjectionMiddlewareTest.php +++ b/tests/php/Unit/Middleware/InjectionMiddlewareTest.php @@ -115,6 +115,31 @@ public function setUp(): void { $this->userId = null; } + /** @return array */ + public static function uuidSourceProvider(): array { + return [ + 'header without route UUID' => [null, 'header-uuid', 'header-uuid'], + 'route UUID without header' => ['route-uuid', '', 'route-uuid'], + 'route UUID takes precedence over header' => ['route-uuid', 'header-uuid', 'route-uuid'], + 'neither source provided' => [null, '', ''], + 'unsupported UUID is forwarded to the authorization validator' => [null, 'invalid-uuid', 'invalid-uuid'], + ]; + } + + #[DataProvider('uuidSourceProvider')] + public function testUuidSourcePrecedence(?string $routeUuid, string $headerUuid, ?string $expectedUuid): void { + $this->request->expects($this->once()) + ->method('getHeader') + ->with('libresign-sign-request-uuid') + ->willReturn($headerUuid); + $this->request->expects($this->once()) + ->method('getParam') + ->with('uuid', $headerUuid) + ->willReturn($routeUuid ?? $headerUuid); + + self::assertSame($expectedUuid, self::invokePrivate($this->getInjectionMiddleware(), 'getUuidFromRequest')); + } + public function testGuestSigningUuidReplacesEarlierRequestInSession(): void { $this->userSession->method('getUser')->willReturn(null); $this->session->method('get')->with('libresign-uuid')->willReturn('earlier-sign-request'); From 357abe12104b04f788833a29b03ebf400a6af8d1 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 13 Nov 2026 17:15:25 +0000 Subject: [PATCH 2/3] test(openapi): assert sign-request headers and regenerate SDK types Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- openapi-full.json | 48 ++++++++++++++ openapi.json | 48 ++++++++++++++ src/types/openapi/openapi-full.ts | 12 ++++ src/types/openapi/openapi.ts | 12 ++++ .../php/openapi/OpenApiRequestHeadersTest.php | 66 +++++++++++++++++++ tests/php/openapi/phpunit.xml | 1 + 6 files changed, 187 insertions(+) create mode 100644 tests/php/openapi/OpenApiRequestHeadersTest.php diff --git a/openapi-full.json b/openapi-full.json index 567fa483c0..9ebcb7784d 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -8367,6 +8367,14 @@ "default": "v1" } }, + { + "name": "libresign-sign-request-uuid", + "in": "header", + "description": "The UUID of the sign request, used to identify the request", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -8510,6 +8518,14 @@ "default": null } }, + { + "name": "libresign-sign-request-uuid", + "in": "header", + "description": "The UUID of the sign request, used to identify the request", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -8634,6 +8650,14 @@ "default": null } }, + { + "name": "libresign-sign-request-uuid", + "in": "header", + "description": "The UUID of the sign request, used to identify the request", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -14379,6 +14403,14 @@ "format": "int64" } }, + { + "name": "libresign-sign-request-uuid", + "in": "header", + "description": "The UUID of the sign request, used to identify the request", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -14638,6 +14670,14 @@ "format": "int64" } }, + { + "name": "libresign-sign-request-uuid", + "in": "header", + "description": "The UUID of the sign request, used to identify the request", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -14750,6 +14790,14 @@ "format": "int64" } }, + { + "name": "libresign-sign-request-uuid", + "in": "header", + "description": "The UUID of the sign request, used to identify the request", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", diff --git a/openapi.json b/openapi.json index bdd25f2ca3..8a5f3ccde9 100644 --- a/openapi.json +++ b/openapi.json @@ -7630,6 +7630,14 @@ "default": "v1" } }, + { + "name": "libresign-sign-request-uuid", + "in": "header", + "description": "The UUID of the sign request, used to identify the request", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -7773,6 +7781,14 @@ "default": null } }, + { + "name": "libresign-sign-request-uuid", + "in": "header", + "description": "The UUID of the sign request, used to identify the request", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -7897,6 +7913,14 @@ "default": null } }, + { + "name": "libresign-sign-request-uuid", + "in": "header", + "description": "The UUID of the sign request, used to identify the request", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -13642,6 +13666,14 @@ "format": "int64" } }, + { + "name": "libresign-sign-request-uuid", + "in": "header", + "description": "The UUID of the sign request, used to identify the request", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -13901,6 +13933,14 @@ "format": "int64" } }, + { + "name": "libresign-sign-request-uuid", + "in": "header", + "description": "The UUID of the sign request, used to identify the request", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -14013,6 +14053,14 @@ "format": "int64" } }, + { + "name": "libresign-sign-request-uuid", + "in": "header", + "description": "The UUID of the sign request, used to identify the request", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 84d80aa12f..23bc90583e 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -4340,6 +4340,8 @@ export interface operations { length?: number | null; }; header: { + /** @description The UUID of the sign request, used to identify the request */ + "libresign-sign-request-uuid"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; @@ -4384,6 +4386,8 @@ export interface operations { parameters: { query?: never; header: { + /** @description The UUID of the sign request, used to identify the request */ + "libresign-sign-request-uuid"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; @@ -4438,6 +4442,8 @@ export interface operations { uuid?: string | null; }; header: { + /** @description The UUID of the sign request, used to identify the request */ + "libresign-sign-request-uuid"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; @@ -6886,6 +6892,8 @@ export interface operations { parameters: { query?: never; header: { + /** @description The UUID of the sign request, used to identify the request */ + "libresign-sign-request-uuid"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; @@ -6987,6 +6995,8 @@ export interface operations { parameters: { query?: never; header: { + /** @description The UUID of the sign request, used to identify the request */ + "libresign-sign-request-uuid"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; @@ -7033,6 +7043,8 @@ export interface operations { parameters: { query?: never; header: { + /** @description The UUID of the sign request, used to identify the request */ + "libresign-sign-request-uuid"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 592e6f3506..f89d8136e3 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -3778,6 +3778,8 @@ export interface operations { length?: number | null; }; header: { + /** @description The UUID of the sign request, used to identify the request */ + "libresign-sign-request-uuid"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; @@ -3822,6 +3824,8 @@ export interface operations { parameters: { query?: never; header: { + /** @description The UUID of the sign request, used to identify the request */ + "libresign-sign-request-uuid"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; @@ -3876,6 +3880,8 @@ export interface operations { uuid?: string | null; }; header: { + /** @description The UUID of the sign request, used to identify the request */ + "libresign-sign-request-uuid"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; @@ -6324,6 +6330,8 @@ export interface operations { parameters: { query?: never; header: { + /** @description The UUID of the sign request, used to identify the request */ + "libresign-sign-request-uuid"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; @@ -6425,6 +6433,8 @@ export interface operations { parameters: { query?: never; header: { + /** @description The UUID of the sign request, used to identify the request */ + "libresign-sign-request-uuid"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; @@ -6471,6 +6481,8 @@ export interface operations { parameters: { query?: never; header: { + /** @description The UUID of the sign request, used to identify the request */ + "libresign-sign-request-uuid"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; diff --git a/tests/php/openapi/OpenApiRequestHeadersTest.php b/tests/php/openapi/OpenApiRequestHeadersTest.php new file mode 100644 index 0000000000..615da1bb0c --- /dev/null +++ b/tests/php/openapi/OpenApiRequestHeadersTest.php @@ -0,0 +1,66 @@ + ['openapi.json'], + 'administration scope' => ['openapi-administration.json'], + 'full scope' => ['openapi-full.json'], + ]; + } + + #[DataProvider('specificationProvider')] + public function testCustomHeadersMatchActualMiddlewareConsumers(string $file): void { + $operations = OpenApiFunctionalTags::loadOperations(dirname(__DIR__, 3) . '/' . $file); + $present = []; + foreach ($operations as $operation) { + $id = $operation['operationId']; + $parameters = $operation['raw']['parameters'] ?? []; + $headers = array_values(array_filter( + $parameters, + static fn (array $parameter): bool => ($parameter['in'] ?? null) === 'header' + && strtolower((string)($parameter['name'] ?? '')) === self::SIGN_REQUEST_HEADER, + )); + self::assertCount(in_array($id, self::OPERATIONS, true) ? 1 : 0, $headers, "Unexpected sign-request header in {$file}: {$id}"); + if ($headers !== []) { + $present[] = $id; + self::assertSame(self::SIGN_REQUEST_HEADER, $headers[0]['name']); + self::assertSame(self::DESCRIPTION, $headers[0]['description']); + self::assertFalse($headers[0]['required'] ?? false, "The header is optional for {$id}"); + self::assertSame('string', $headers[0]['schema']['type'] ?? null); + } + } + + $expected = $file === 'openapi-administration.json' ? [] : self::OPERATIONS; + sort($present); + sort($expected); + self::assertSame($expected, $present, "Missing or unexpected sign-request UUID operations in {$file}"); + } +} diff --git a/tests/php/openapi/phpunit.xml b/tests/php/openapi/phpunit.xml index 4ca765e58c..8845dd9e2c 100644 --- a/tests/php/openapi/phpunit.xml +++ b/tests/php/openapi/phpunit.xml @@ -15,6 +15,7 @@ OpenApiFunctionalTagsTest.php OpenApiGenerationTest.php + OpenApiRequestHeadersTest.php From 7a76eb91ef0e236aaf67a0363426b2938b783a0d Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 13 Nov 2026 21:50:33 +0000 Subject: [PATCH 3/3] fix(openapi): discover UUID header consumers dynamically Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Controller/SignFileController.php | 2 + openapi-full.json | 8 ++ openapi.json | 8 ++ src/types/openapi/openapi-full.ts | 2 + src/types/openapi/openapi.ts | 2 + .../php/openapi/OpenApiRequestHeadersTest.php | 126 +++++++++++++++--- 6 files changed, 131 insertions(+), 17 deletions(-) diff --git a/lib/Controller/SignFileController.php b/lib/Controller/SignFileController.php index bd35b85e7f..261b6dbe29 100644 --- a/lib/Controller/SignFileController.php +++ b/lib/Controller/SignFileController.php @@ -40,6 +40,7 @@ use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\Attribute\RequestHeader; use OCP\AppFramework\Http\DataResponse; use OCP\IL10N; use OCP\IRequest; @@ -488,6 +489,7 @@ public function requestCodeBySignerUuid( */ #[NoAdminRequired] #[NoCSRFRequired] + #[RequestHeader(name: 'libresign-sign-request-uuid', description: 'The UUID of the sign request, used to identify the request', indirect: true)] #[RequireSigner] #[PublicPage] #[OpenAPI(tags: ['signing'])] diff --git a/openapi-full.json b/openapi-full.json index 9ebcb7784d..17f075f57f 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -13915,6 +13915,14 @@ "format": "int64" } }, + { + "name": "libresign-sign-request-uuid", + "in": "header", + "description": "The UUID of the sign request, used to identify the request", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", diff --git a/openapi.json b/openapi.json index 8a5f3ccde9..ad7808898c 100644 --- a/openapi.json +++ b/openapi.json @@ -13178,6 +13178,14 @@ "format": "int64" } }, + { + "name": "libresign-sign-request-uuid", + "in": "header", + "description": "The UUID of the sign request, used to identify the request", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 23bc90583e..457ff0cd3e 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -6677,6 +6677,8 @@ export interface operations { parameters: { query?: never; header: { + /** @description The UUID of the sign request, used to identify the request */ + "libresign-sign-request-uuid"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index f89d8136e3..2f6d85fac3 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -6115,6 +6115,8 @@ export interface operations { parameters: { query?: never; header: { + /** @description The UUID of the sign request, used to identify the request */ + "libresign-sign-request-uuid"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; diff --git a/tests/php/openapi/OpenApiRequestHeadersTest.php b/tests/php/openapi/OpenApiRequestHeadersTest.php index 615da1bb0c..1596cb4573 100644 --- a/tests/php/openapi/OpenApiRequestHeadersTest.php +++ b/tests/php/openapi/OpenApiRequestHeadersTest.php @@ -14,18 +14,18 @@ final class OpenApiRequestHeadersTest extends TestCase { private const SIGN_REQUEST_HEADER = 'libresign-sign-request-uuid'; private const DESCRIPTION = 'The UUID of the sign request, used to identify the request'; + private const OCS_PREFIX = '/ocs/v2.php/apps/libresign'; - /** The optional header applies only to methods that can identify a request without a route UUID. */ - private const OPERATIONS = [ - 'signature_elements-create-signature-element', - 'signature_elements-get-signature-elements', - 'signature_elements-use-saved-signature-elements', - 'signature_elements-preview-signature-element', - 'signature_elements-patch-signature-element', - 'signature_elements-delete-signature-element', - 'id_docs-add-files', - 'id_docs-delete', - 'id_docs-list-unauthenticated-signer-documents', + /** + * Middleware attributes that consume a sign request UUID. Unlike operation IDs, + * this list changes only if the authorization contract changes. + */ + private const UUID_CONSUMERS = [ + 'RequireSigner', + 'RequireSignerUuid', + 'RequireSignRequestUuid', + 'RequireParticipantUuid', + 'CanSignRequestUuid', ]; public static function specificationProvider(): array { @@ -38,29 +38,121 @@ public static function specificationProvider(): array { #[DataProvider('specificationProvider')] public function testCustomHeadersMatchActualMiddlewareConsumers(string $file): void { + $expectedRoutes = self::discoverUuidConsumerRoutes(); + $expected = array_keys(array_filter( + $expectedRoutes, + static fn (string $scope): bool => $file === 'openapi-full.json' + || ($file === 'openapi-administration.json' ? $scope === 'administration' : $scope === 'default'), + )); + $operations = OpenApiFunctionalTags::loadOperations(dirname(__DIR__, 3) . '/' . $file); $present = []; foreach ($operations as $operation) { - $id = $operation['operationId']; + $route = OpenApiFunctionalTags::routeKey($operation); $parameters = $operation['raw']['parameters'] ?? []; $headers = array_values(array_filter( $parameters, static fn (array $parameter): bool => ($parameter['in'] ?? null) === 'header' && strtolower((string)($parameter['name'] ?? '')) === self::SIGN_REQUEST_HEADER, )); - self::assertCount(in_array($id, self::OPERATIONS, true) ? 1 : 0, $headers, "Unexpected sign-request header in {$file}: {$id}"); + self::assertCount(in_array($route, $expected, true) ? 1 : 0, $headers, "Unexpected sign-request header in {$file}: {$route}"); if ($headers !== []) { - $present[] = $id; + $present[] = $route; self::assertSame(self::SIGN_REQUEST_HEADER, $headers[0]['name']); self::assertSame(self::DESCRIPTION, $headers[0]['description']); - self::assertFalse($headers[0]['required'] ?? false, "The header is optional for {$id}"); + // A request parameter named uuid is also accepted by InjectionMiddleware. + self::assertFalse($headers[0]['required'] ?? false, "The header can be replaced by a request UUID for {$route}"); self::assertSame('string', $headers[0]['schema']['type'] ?? null); } } - $expected = $file === 'openapi-administration.json' ? [] : self::OPERATIONS; sort($present); sort($expected); - self::assertSame($expected, $present, "Missing or unexpected sign-request UUID operations in {$file}"); + self::assertSame($expected, $present, "Missing or unexpected sign-request UUID routes in {$file}"); + } + + /** @return array */ + private static function discoverUuidConsumerRoutes(): array { + $files = glob(dirname(__DIR__, 3) . '/lib/Controller/*Controller.php'); + self::assertNotFalse($files); + + $routes = []; + foreach ($files as $file) { + $source = file_get_contents($file); + self::assertNotFalse($source); + foreach (self::consumerRoutesInSource($source, $file) as $route => $scope) { + self::assertArrayNotHasKey($route, $routes, "Duplicate source route {$route}"); + $routes[$route] = $scope; + } + } + return $routes; + } + + /** + * Read controller attributes, not RequestHeader declarations or generated OpenAPI. + * Every ApiRoute must be parsed so a new syntax cannot silently skip consumers. + * + * @return array + */ + private static function consumerRoutesInSource(string $source, string $file): array { + $matches = []; + preg_match_all('/((?:^[ \t]*#\[[^\r\n]*\]\r?\n)+)[ \t]*public function [A-Za-z_]\w*/m', $source, $matches, PREG_SET_ORDER); + $foundRoutes = 0; + $routes = []; + + foreach ($matches as $match) { + $attributes = $match[1]; + if (!str_contains($attributes, '#[ApiRoute(')) { + continue; + } + $foundRoutes++; + + if (str_contains($attributes, 'SCOPE_IGNORE')) { + continue; + } + $consumesUuid = false; + foreach (self::UUID_CONSUMERS as $attribute) { + if (preg_match('/#\[' . $attribute . '(?:\]|\()/', $attributes)) { + $consumesUuid = true; + break; + } + } + if (!$consumesUuid) { + continue; + } + + self::assertSame(1, preg_match("/#\\[ApiRoute\\(verb:\\s*'([A-Z]+)',\\s*url:\\s*'([^']+)'/", $attributes, $route), "Cannot read ApiRoute in {$file}"); + // Routes already carrying {uuid} do not need a separate header. + if (str_contains($route[2], '{uuid}')) { + continue; + } + $key = $route[1] . ' ' . self::OCS_PREFIX . $route[2]; + self::assertArrayNotHasKey($key, $routes, "Duplicate route in {$file}: {$key}"); + $routes[$key] = str_contains($attributes, 'SCOPE_ADMINISTRATION') ? 'administration' : 'default'; + } + + self::assertSame(substr_count($source, '#[ApiRoute('), $foundRoutes, "Unparsed ApiRoute in {$file}; update the source discovery"); + return $routes; + } + + public function testNewUuidConsumerIsDiscoveredWithoutUpdatingAnOperationList(): void { + $source = <<<'PHP' + 'default'], + self::consumerRoutesInSource($source, 'synthetic controller'), + ); } }