diff --git a/VERSION b/VERSION index 03412e6..a0f18ea 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2503.150248 +0.2503.192123 diff --git a/composer.lock b/composer.lock index 813ab02..6e7116e 100644 --- a/composer.lock +++ b/composer.lock @@ -1034,16 +1034,16 @@ }, { "name": "phpunit/php-code-coverage", - "version": "12.0.4", + "version": "12.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "79e5ef5897068689c7c325554d6df905480ce942" + "reference": "d331a5ced3d9a2b917baa9841b2211e72f9e780d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/79e5ef5897068689c7c325554d6df905480ce942", - "reference": "79e5ef5897068689c7c325554d6df905480ce942", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/d331a5ced3d9a2b917baa9841b2211e72f9e780d", + "reference": "d331a5ced3d9a2b917baa9841b2211e72f9e780d", "shasum": "" }, "require": { @@ -1070,7 +1070,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.0.x-dev" + "dev-main": "12.1.x-dev" } }, "autoload": { @@ -1099,7 +1099,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.0.4" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.1.0" }, "funding": [ { @@ -1107,7 +1107,7 @@ "type": "github" } ], - "time": "2025-02-25T13:27:48+00:00" + "time": "2025-03-17T13:56:07+00:00" }, { "name": "phpunit/php-file-iterator", @@ -1356,16 +1356,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.0.7", + "version": "12.0.9", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "2845e49082ef7acc4a71a2ef71bbf32f31da22c9" + "reference": "7835bb4276780e0bbb385ce0a777b839e03096db" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/2845e49082ef7acc4a71a2ef71bbf32f31da22c9", - "reference": "2845e49082ef7acc4a71a2ef71bbf32f31da22c9", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/7835bb4276780e0bbb385ce0a777b839e03096db", + "reference": "7835bb4276780e0bbb385ce0a777b839e03096db", "shasum": "" }, "require": { @@ -1379,7 +1379,7 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.0.4", + "phpunit/php-code-coverage": "^12.1.0", "phpunit/php-file-iterator": "^6.0.0", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", @@ -1391,7 +1391,7 @@ "sebastian/exporter": "^7.0.0", "sebastian/global-state": "^8.0.0", "sebastian/object-enumerator": "^7.0.0", - "sebastian/type": "^6.0.0", + "sebastian/type": "^6.0.2", "sebastian/version": "^6.0.0", "staabm/side-effects-detector": "^1.0.5" }, @@ -1433,7 +1433,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.0.7" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.0.9" }, "funding": [ { @@ -1449,7 +1449,7 @@ "type": "tidelift" } ], - "time": "2025-03-07T07:32:22+00:00" + "time": "2025-03-19T13:47:33+00:00" }, { "name": "sebastian/cli-parser", @@ -2155,16 +2155,16 @@ }, { "name": "sebastian/type", - "version": "6.0.0", + "version": "6.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "533fe082889a616f330bcba6f50965135f4f2fab" + "reference": "1d7cd6e514384c36d7a390347f57c385d4be6069" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/533fe082889a616f330bcba6f50965135f4f2fab", - "reference": "533fe082889a616f330bcba6f50965135f4f2fab", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/1d7cd6e514384c36d7a390347f57c385d4be6069", + "reference": "1d7cd6e514384c36d7a390347f57c385d4be6069", "shasum": "" }, "require": { @@ -2200,7 +2200,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/type/tree/6.0.2" }, "funding": [ { @@ -2208,7 +2208,7 @@ "type": "github" } ], - "time": "2025-02-07T05:00:19+00:00" + "time": "2025-03-18T13:37:31+00:00" }, { "name": "sebastian/version", diff --git a/src/Http/Routing/AccessControl/AccessControl.php b/src/Http/Routing/AccessControl/AccessControl.php new file mode 100644 index 0000000..196bb18 --- /dev/null +++ b/src/Http/Routing/AccessControl/AccessControl.php @@ -0,0 +1,170 @@ +<?php +// AccessControl.php +// Created: 2025-03-19 +// Updated: 2025-03-19 + +namespace Index\Http\Routing\AccessControl; + +use Attribute; +use Closure; +use ReflectionAttribute; +use ReflectionFunction; +use RuntimeException; +use Index\XArray; +use Index\Http\Routing\Routes\RouteInfo; + +/** + * Contains access control info for cross origin requests. + */ +#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD)] +final class AccessControl { + /** + * Headers that are always allowed regardless of Access-Control-Allow-Headers. + * Also included the CORS ones cus they basically are anyway. + * + * @see https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_response_header + * @var string[] + */ + public const array HEADER_SAFELIST = [ + 'Access-Control-Allow-Headers', + 'Access-Control-Allow-Methods', + 'Access-Control-Allow-Origin', + 'Access-Control-Expose-Headers', + 'Access-Control-Request-Headers', + 'Access-Control-Request-Method', + 'Cache-Control', + 'Content-Language', + 'Content-Length', + 'Content-Type', + 'Expires', + 'Last-Modified', + 'Pragma', + ]; + + /** + * @param bool $allow Whether to allow cross origin requests for this route. + * If false, everything else will be ignored since there's no need to specify it. + * Provided if necessary to explicitly deny. + * @param bool $credentials Whether to permit the client to include the credentials for our domain in requests using withCredentials = true. + * @param string[]|true $allowMethods What additional methods to allow for this route. true function as wildcard. + * Setting $allow to true will already include the $method provided in the RouteInfo constructor. + * @param string[]|true $allowHeaders What headers to allow for this route. true functions as wildcard. + * @param string[]|true $exposeHeaders What headers to expose to the remote origin. true function as wildcard, false as an explicit empty list. + * In addition to Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified and Pragma. + * false and empty array are seemingly swapped in terms of functionality at first glance + * but i think having false represent an explicit Nothing will be nicer in the long run since it changes the type entirely + */ + public function __construct( + public private(set) bool $allow = true, + public private(set) bool $credentials = false, + public private(set) array|true $allowMethods = [], + public private(set) array|true $allowHeaders = [], + public private(set) array|true $exposeHeaders = [], + ) {} + + /** + * Verifies if provided headers are allowed. + * + * @param string[] $headers Request headers. + * @return bool + */ + public function verifyHeadersAllowed(array $headers): bool { + return $this->allowHeaders === true || XArray::all($headers, fn($name) => ( + XArray::any(self::HEADER_SAFELIST, fn($safeName) => strcasecmp($safeName, $name) === 0) + || XArray::any($this->allowHeaders, fn($allowName) => strcasecmp($allowName, $name) === 0) + )); + } + + /** + * Reads methods on a provided Closure and creates or amends an AccessControl object. + * + * @param Closure $closure Closure to read attributes from. + * @param ?AccessControl $info Base access control info object. + * @return ?AccessControl + */ + public static function read(Closure $closure, ?AccessControl $info): ?AccessControl { + $attrs = (new ReflectionFunction($closure))->getAttributes(self::class, ReflectionAttribute::IS_INSTANCEOF); + $attr = array_shift($attrs); + if($attr === null) + return $info; + + $attr = $attr->newInstance(); + + // explicit allow === false always takes precedence over everything else + if(!$attr->allow) + return $attr; + + if($info !== null) { + // see the above + if(!$info->allow) + return $info; + + // false is default so true takes precedence + // maybe not the best strat but its the one you're getting + if($info->credentials) + $attr->credentials = true; + + if($attr->allowMethods !== true) + $attr->allowMethods = $info->allowMethods === true + ? true : array_values(array_unique(array_merge($attr->allowMethods, $info->allowMethods))); + + if($attr->allowHeaders !== true) + $attr->allowHeaders = $info->allowHeaders === true + ? true : array_values(array_unique(array_merge($attr->allowHeaders, $info->allowHeaders))); + + if($attr->exposeHeaders !== true) + $attr->exposeHeaders = $info->exposeHeaders === true + ? true : array_values(array_unique(array_merge($attr->exposeHeaders, $info->exposeHeaders))); + } + + return $attr; + } + + /** + * Aggregrates access control declarations for multiple + * + * @param array<object{info: RouteInfo}|RouteInfo> $routes Routes that were caught for this preflight request. + * @param string $requestMethod Intended request method for this preflight. + * @return AccessControl Aggregated access control info. + */ + public static function aggregate(array $routes, string $requestMethod): AccessControl { + $allow = false; + $credentials = false; + $allowMethods = []; + $allowHeaders = []; + $exposeHeaders = []; + + foreach($routes as $routeInfo) { + if(!($routeInfo instanceof RouteInfo)) { + if(property_exists($routeInfo, 'info') && $routeInfo->info instanceof RouteInfo) + $routeInfo = $routeInfo->info; + else continue; + } + + $accessControl = $routeInfo->accessControl; + if($accessControl?->allow !== true) + continue; + + $allow = true; + + if($allowMethods !== true) + if($accessControl->allowMethods === true) { + $allowMethods = true; + } else { + $allowMethods[] = $routeInfo->method; + if(!empty($accessControl->allowMethods)) + $allowMethods = array_merge($allowMethods, $accessControl->allowMethods); + } + + if($routeInfo->method === $requestMethod) { + if($accessControl->credentials) + $credentials = true; + + $allowHeaders = $accessControl->allowHeaders; + $exposeHeaders = $accessControl->exposeHeaders; + } + } + + return new AccessControl($allow, $credentials, $allowMethods, $allowHeaders, $exposeHeaders); + } +} diff --git a/src/Http/Routing/AccessControl/AccessControlHandler.php b/src/Http/Routing/AccessControl/AccessControlHandler.php new file mode 100644 index 0000000..538c9eb --- /dev/null +++ b/src/Http/Routing/AccessControl/AccessControlHandler.php @@ -0,0 +1,52 @@ +<?php +// AccessControlHandler.php +// Created: 2025-03-19 +// Updated: 2025-03-19 + +namespace Index\Http\Routing\AccessControl; + +use Index\XArray; +use Index\Http\HttpUri; +use Index\Http\Routing\HandlerContext; +use Index\Http\Routing\Routes\RouteInfo; + +/** + * CORS handler interface. + */ +interface AccessControlHandler { + /** + * Aggregates info needed for built-in HTTP OPTIONS method handler. + * + * @param HandlerContext $context Route handler context. + * @param AccessControl $accessControl Aggregated access control rules. + * @param RouteInfo[] $routes Routes that this OPTIONS request concern. + * @param HttpUri $requestOrigin HTTP request Origin header value. + * @param string $requestMethod Intended HTTP request method. + * @param string[]|null $requestHeaders Intended HTTP request headers. + * @return AccessControlPreflight + */ + public function handlePreflight( + HandlerContext $context, + AccessControl $accessControl, + array $routes, + HttpUri $requestOrigin, + string $requestMethod, + array|null $requestHeaders, + ): AccessControlPreflight; + + /** + * Adds CORS headers to a request if applicable. + * + * @param HandlerContext $context Route handler context. + * @param RouteInfo $routeInfo Route info. + * @param AccessControl $accessControl Access control for the given route info. + * @param HttpUri $requestOrigin HTTP request Origin header value. + * @return AccessControlResult + */ + public function handleRequest( + HandlerContext $context, + RouteInfo $routeInfo, + AccessControl $accessControl, + HttpUri $requestOrigin, + ): AccessControlResult; +} diff --git a/src/Http/Routing/AccessControl/AccessControlPreflight.php b/src/Http/Routing/AccessControl/AccessControlPreflight.php new file mode 100644 index 0000000..363b06e --- /dev/null +++ b/src/Http/Routing/AccessControl/AccessControlPreflight.php @@ -0,0 +1,69 @@ +<?php +// AccessControlPreflight.php +// Created: 2025-03-19 +// Updated: 2025-03-19 + +namespace Index\Http\Routing\AccessControl; + +use Index\Http\HttpResponseBuilder; + +/** + * Contains the result for a preflight request. + */ +class AccessControlPreflight { + /** + * @param string|bool $origin Origin to use in the Access-Control-Allow-Origin header, true for *, false for deny. + * @param string[]|true $methods Methods to use in the Access-Control-Allow-Methods header, true for *. + * @param string[]|true|null $headers Headers to use in the Access-Control-Allow-Headers header, true for *, null for none. + * @param bool $credentials Value for the Access-Control-Allow-Credentials header. + * @param ?int $ttl Override value for Access-Control-Max-Age header, null for default supplied in AccessControlHandler constructor. + */ + public function __construct( + public private(set) string|bool $origin = false, + public private(set) array|true $methods = [], + public private(set) array|true|null $headers = null, + public private(set) bool $credentials = false, + public private(set) ?int $ttl = null, + ) {} + + /** + * Applies this collection of headers to a HttpResponseBuilder. + * + * @param HttpResponseBuilder $response Response to apply to. + * @param int $ttl Default TTL value, if $ttl is specified in the constructor it should be overridden. + */ + public function applyPreflight(HttpResponseBuilder $response, int $ttl): void { + if($this->origin === false) + return; + + if($this->credentials) + $response->setHeader('Access-Control-Allow-Credentials', 'true'); + + if($this->headers !== null) { + if($this->headers === true) + $response->setHeader('Access-Control-Allow-Headers', '*'); + else { + $headers = array_unique($this->headers); + sort($headers); + $response->setHeader('Access-Control-Allow-Headers', implode(', ', $headers)); + } + } + + if($this->methods === true) { + $response->setHeader('Access-Control-Allow-Methods', '*'); + } elseif(!empty($this->methods)) { + $methods = array_unique($this->methods); + sort($methods); + $response->setHeader('Access-Control-Allow-Methods', implode(', ', $methods)); + } + + if($this->origin === true) { + $response->setHeader('Access-Control-Allow-Origin', '*'); + } else { + $response->addVary('Origin'); + $response->setHeader('Access-Control-Allow-Origin', $this->origin); + } + + $response->setHeader('Access-Control-Max-Age', (string)($this->ttl ?? $ttl)); + } +} diff --git a/src/Http/Routing/AccessControl/AccessControlResult.php b/src/Http/Routing/AccessControl/AccessControlResult.php new file mode 100644 index 0000000..afbb1ab --- /dev/null +++ b/src/Http/Routing/AccessControl/AccessControlResult.php @@ -0,0 +1,54 @@ +<?php +// AccessControlResult.php +// Created: 2025-03-19 +// Updated: 2025-03-19 + +namespace Index\Http\Routing\AccessControl; + +use Index\Http\HttpResponseBuilder; + +/** + * Contains the result for a normal request. + */ +class AccessControlResult { + /** + * @param string|bool $origin Origin to use in the Access-Control-Allow-Origin header, true for *, false for deny. + * @param string[]|bool $headers Headers to use in the Access-Control-Expose-Headers header, true for *, false for none. + * @param bool $credentials Value for the Access-Control-Allow-Credentials header. + */ + public function __construct( + public private(set) string|bool $origin = false, + public private(set) array|bool $headers = [], + public private(set) bool $credentials = false, + ) {} + + /** + * Applies this collection of headers to a HttpResponseBuilder. + * + * @param HttpResponseBuilder $response Response to apply to. + */ + public function applyResult(HttpResponseBuilder $response): void { + if($this->origin === false) + return; + + if($this->credentials) + $response->setHeader('Access-Control-Allow-Credentials', 'true'); + + if($this->origin === true) { + $response->setHeader('Access-Control-Allow-Origin', '*'); + } else { + $response->addVary('Origin'); + $response->setHeader('Access-Control-Allow-Origin', $this->origin); + } + + if($this->headers === true) + $response->setHeader('Access-Control-Expose-Headers', '*'); + elseif($this->headers === false) + $response->setHeader('Access-Control-Expose-Headers', ''); + elseif(!empty($this->headers)) { + $headers = array_unique($this->headers); + sort($headers); + $response->setHeader('Access-Control-Expose-Headers', implode(', ', $headers)); + } + } +} diff --git a/src/Http/Routing/AccessControl/SimpleAccessControlHandler.php b/src/Http/Routing/AccessControl/SimpleAccessControlHandler.php new file mode 100644 index 0000000..4801b78 --- /dev/null +++ b/src/Http/Routing/AccessControl/SimpleAccessControlHandler.php @@ -0,0 +1,64 @@ +<?php +// SimpleAccessControlHandler.php +// Created: 2025-03-19 +// Updated: 2025-03-19 + +namespace Index\Http\Routing\AccessControl; + +use Index\XArray; +use Index\Http\HttpUri; +use Index\Http\Routing\HandlerContext; +use Index\Http\Routing\Routes\RouteInfo; + +/** + * Base CORS handler. + * + * Override checkAccess to make this not function as a wildcard. + */ +class SimpleAccessControlHandler implements AccessControlHandler { + public function checkAccess( + HandlerContext $context, + AccessControl $accessControl, + HttpUri $origin, + ?RouteInfo $routeInfo = null, + ): string|bool { + return $accessControl->credentials ? (string)$origin : true; + } + + public function handlePreflight( + HandlerContext $context, + AccessControl $accessControl, + array $routes, + HttpUri $requestOrigin, + string $requestMethod, + array|null $requestHeaders, + ): AccessControlPreflight { + $requestOrigin = $this->checkAccess($context, $accessControl, $requestOrigin); + if($requestOrigin === false) + return new AccessControlPreflight; + + return new AccessControlPreflight( + $requestOrigin, + $accessControl->allowMethods, + $requestHeaders === null ? null : ($accessControl->allowHeaders === true ? true : $requestHeaders), + $accessControl->credentials, + ); + } + + public function handleRequest( + HandlerContext $context, + RouteInfo $routeInfo, + AccessControl $accessControl, + HttpUri $requestOrigin, + ): AccessControlResult { + $requestOrigin = $this->checkAccess($context, $accessControl, $requestOrigin, $routeInfo); + if($requestOrigin === false) + return new AccessControlResult; + + return new AccessControlResult( + $requestOrigin, + $accessControl->exposeHeaders, + $accessControl->credentials, + ); + } +} diff --git a/src/Http/Routing/Router.php b/src/Http/Routing/Router.php index 3dae8a4..eaca839 100644 --- a/src/Http/Routing/Router.php +++ b/src/Http/Routing/Router.php @@ -1,7 +1,7 @@ <?php // Router.php // Created: 2024-03-28 -// Updated: 2025-03-15 +// Updated: 2025-03-19 namespace Index\Http\Routing; @@ -9,8 +9,10 @@ use InvalidArgumentException; use JsonSerializable; use RuntimeException; use Stringable; +use Index\XArray; use Index\Bencode\{Bencode,BencodeSerializable}; -use Index\Http\HttpRequest; +use Index\Http\{HttpRequest,HttpUri}; +use Index\Http\Routing\AccessControl\{AccessControl,AccessControlHandler,SimpleAccessControlHandler}; use Index\Http\Routing\ErrorHandling\{ErrorHandler,HtmlErrorHandler,PlainErrorHandler}; use Index\Http\Routing\Filters\FilterInfo; use Index\Http\Routing\Processors\{ProcessorInfo,ProcessorTarget}; @@ -46,22 +48,32 @@ class Router implements RequestHandlerInterface { private array $postprocs = []; /** - * @param ErrorHandler|'html'|'plain' $errorHandler Error handling to use for error responses with an empty body. 'html' for the default HTML implementation, 'plain' for the plaintext implementation. - * @param bool $registerDefaultProcessors Whether the default processors should be registered. + * Access control handler instance. */ - public function __construct( - ErrorHandler|string $errorHandler = 'html', - bool $registerDefaultProcessors = true - ) { - $this->setErrorHandler($errorHandler); - if($registerDefaultProcessors) - $this->register(new RouterProcessors); - } + public private(set) AccessControlHandler $accessControlHandler; /** * Error handler instance. */ - public ErrorHandler $errorHandler; + public private(set) ErrorHandler $errorHandler; + + /** + * @param ErrorHandler|'html'|'plain' $errorHandler Error handling to use for error responses with an empty body. 'html' for the default HTML implementation, 'plain' for the plaintext implementation. + * @param ?AccessControlHandler $accessControlHandler Handler for CORS requests. + * @param int $accessControlTtl Default Access-Control-Max-Age value. + * @param bool $registerDefaultProcessors Whether the default processors should be registered. + */ + public function __construct( + ErrorHandler|string $errorHandler = 'html', + ?AccessControlHandler $accessControlHandler = null, + public private(set) int $accessControlTtl = 300, + bool $registerDefaultProcessors = true + ) { + $this->setErrorHandler($errorHandler); + $this->accessControlHandler = $accessControlHandler ?? new SimpleAccessControlHandler; + if($registerDefaultProcessors) + $this->register(new RouterProcessors); + } /** * Sets an error handler. @@ -250,14 +262,17 @@ class Router implements RequestHandlerInterface { } } + /** @var array<string, object{info: RouteInfo, matches: ?array<int|string, mixed>}> */ $methods = []; foreach($this->routes as $routeInfo) { $match = $routeInfo->matcher->match($context->request->uri); - if($match !== false) + if($match !== false) { + $routeInfo->readAccessControl(); $methods[$routeInfo->method] = (object)[ 'info' => $routeInfo, 'matches' => is_array($match) ? $match : null, ]; + } } if(empty($methods)) { @@ -276,14 +291,39 @@ class Router implements RequestHandlerInterface { $allow[] = 'OPTIONS'; if(in_array('GET', $allow)) $allow[] = 'HEAD'; - $context->response->setAllow($allow); $context->response->reasonPhrase = ''; if($context->request->method === 'OPTIONS') { - // this should include CORS stuff - // should have an entry in RouteInfo which get aggregated here, but also attributes - $context->response->statusCode = 204; + $context->response->statusCode = 200; + $context->response->setHeader('Content-Length', '0'); + + if($context->request->hasHeader('Origin')) { + $requestMethod = strtoupper($context->request->getHeaderLine('Access-Control-Request-Method')); + $routeInfos = array_map(fn($item) => $item->info, $methods); + $accessControl = AccessControl::aggregate($routeInfos, $requestMethod); + + if($accessControl->allow) { + if($context->request->hasHeader('Access-Control-Request-Headers')) { + $requestHeaders = trim($context->request->getHeaderLine('Access-Control-Request-Headers')); + $requestHeaders = $requestHeaders === '' ? [] : XArray::select( + explode(',', $requestHeaders), + fn($part) => trim($part) + ); + } else + $requestHeaders = null; + + if($requestHeaders === null || $accessControl->verifyHeadersAllowed($requestHeaders)) + $this->accessControlHandler->handlePreflight( + $context, + $accessControl, + $routeInfos, + HttpUri::createUri($context->request->getHeaderLine('Origin')), + $requestMethod, + $requestHeaders, + )->applyPreflight($context->response, $this->accessControlTtl); + } + } } else { $context->response->statusCode = 405; $this->errorHandler->handle($context); @@ -295,6 +335,18 @@ class Router implements RequestHandlerInterface { } $route = $methods[$context->request->method]; + + // if you're overriding OPTIONS you're on your own + if($context->request->method !== 'OPTIONS' + && $context->request->hasHeader('Origin') + && $route->info->accessControl?->allow === true) + $this->accessControlHandler->handleRequest( + $context, + $route->info, + $route->info->accessControl, + HttpUri::createUri($context->request->getHeaderLine('Origin')), + )->applyResult($context->response); + $route->info->readProcessors(); foreach($route->info->preprocs as $name => $args) { diff --git a/src/Http/Routing/Routes/RouteInfo.php b/src/Http/Routing/Routes/RouteInfo.php index 19dd78a..6213d4e 100644 --- a/src/Http/Routing/Routes/RouteInfo.php +++ b/src/Http/Routing/Routes/RouteInfo.php @@ -1,12 +1,13 @@ <?php // RouteInfo.php // Created: 2025-03-02 -// Updated: 2025-03-07 +// Updated: 2025-03-19 namespace Index\Http\Routing\Routes; use Closure; use InvalidArgumentException; +use Index\Http\Routing\AccessControl\AccessControl; use Index\Http\Routing\Processors\ProcessAttribute; use Index\Http\Routing\UriMatchers\{ExactPathUriMatcher,PatternPathUriMatcher,UriMatcher}; @@ -33,6 +34,12 @@ class RouteInfo { */ public private(set) array $postprocs = []; + /** + * CORS info for this route. + */ + public ?AccessControl $accessControl = null; + + private bool $aclRead = false; private bool $processorsRead = false; /** @@ -97,6 +104,17 @@ class RouteInfo { $this->before($name, $args); } + /** + * Reads access control attributes and merges them with the existing declarations. + */ + public function readAccessControl(): void { + if($this->aclRead) + return; + $this->aclRead = true; + + $this->accessControl = AccessControl::read($this->handler, $this->accessControl); + } + /** * Creates RouteInfo instance using ExactPathUriMatcher. * diff --git a/tests/RouterTest.php b/tests/RouterTest.php index 63a139b..1511e23 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -1,7 +1,7 @@ <?php // RouterTest.php // Created: 2022-01-20 -// Updated: 2025-03-15 +// Updated: 2025-03-19 declare(strict_types=1); @@ -10,6 +10,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use Index\Http\{HttpHeaders,HttpResponseBuilder,HttpRequest,HttpUri}; use Index\Http\Content\{FormContent,MultipartFormContent,UrlEncodedFormContent}; use Index\Http\Routing\{HandlerContext,RouteHandler,RouteHandlerCommon,Router}; +use Index\Http\Routing\AccessControl\{AccessControl}; use Index\Http\Routing\Filters\{FilterInfo,PrefixFilter}; use Index\Http\Routing\Processors\{After,Before,Postprocessor,Preprocessor,ProcessorInfo}; use Index\Http\Routing\Routes\{ExactRoute,PatternRoute,RouteInfo}; @@ -27,6 +28,7 @@ use Index\Http\Streams\Stream; #[CoversClass(RouteHandlerCommon::class)] #[CoversClass(PatternRoute::class)] #[CoversClass(FilterInfo::class)] +#[CoversClass(AccessControl::class)] final class RouterTest extends TestCase { public function testRouter(): void { $router1 = new Router; @@ -38,8 +40,8 @@ final class RouterTest extends TestCase { $router1->route(RouteInfo::exact('PUT', '/', fn() => 'put')); $router1->route(RouteInfo::exact('CUSTOM', '/', fn() => 'wacky')); - $this->assertEquals('get', (string)$router1->handle(HttpRequest::createRequestWithoutBody('GET', '/'))->getBody()); - $this->assertEquals('wacky', (string)$router1->handle(HttpRequest::createRequestWithoutBody('CUSTOM', '/'))->getBody()); + $this->assertSame('get', (string)$router1->handle(HttpRequest::createRequestWithoutBody('GET', '/'))->getBody()); + $this->assertSame('wacky', (string)$router1->handle(HttpRequest::createRequestWithoutBody('CUSTOM', '/'))->getBody()); $router1->filter(FilterInfo::prefix('/', function() { /* this one intentionally does nothing */ })); @@ -53,7 +55,7 @@ final class RouterTest extends TestCase { $router1->route(RouteInfo::pattern('GET', '#^/user/([A-Za-z0-9]+)$#uD', fn(string $user) => $user)); $router1->route(RouteInfo::pattern('GET', '#^/user/([A-Za-z0-9]+)/below$#uD', fn(string $user) => 'below ' . $user)); - $this->assertEquals('warioware below static', (string)$router1->handle(HttpRequest::createRequestWithoutBody('GET', '/user/static/below'))->getBody()); + $this->assertSame('warioware below static', (string)$router1->handle(HttpRequest::createRequestWithoutBody('GET', '/user/static/below'))->getBody()); $router2 = new Router; $router2->filter(FilterInfo::prefix('/', fn() => 'meow')); @@ -61,7 +63,7 @@ final class RouterTest extends TestCase { $router2->route(RouteInfo::exact('GET', '/contact', fn() => 'contact page')); $router2->route(RouteInfo::exact('GET', '/25252', fn() => 'numeric test')); - $this->assertEquals('meow', (string)$router2->handle(HttpRequest::createRequestWithoutBody('GET', '/rules'))->getBody()); + $this->assertSame('meow', (string)$router2->handle(HttpRequest::createRequestWithoutBody('GET', '/rules'))->getBody()); } public function testAttribute(): void { @@ -117,13 +119,13 @@ final class RouterTest extends TestCase { $router->register($handler); - $this->assertEquals('index', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/'))->getBody()); - $this->assertEquals('avatar', (string)$router->handle(HttpRequest::createRequestWithoutBody('POST', '/avatar'))->getBody()); - $this->assertEquals('static', (string)$router->handle(HttpRequest::createRequestWithoutBody('PUT', '/static'))->getBody()); - $this->assertEquals('meow', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/meow'))->getBody()); - $this->assertEquals('meow', (string)$router->handle(HttpRequest::createRequestWithoutBody('POST', '/meow'))->getBody()); - $this->assertEquals('profile of Cool134', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/profile/Cool134'))->getBody()); - $this->assertEquals('still the profile of Cool134', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/profile-but-raw/Cool134'))->getBody()); + $this->assertSame('index', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/'))->getBody()); + $this->assertSame('avatar', (string)$router->handle(HttpRequest::createRequestWithoutBody('POST', '/avatar'))->getBody()); + $this->assertSame('static', (string)$router->handle(HttpRequest::createRequestWithoutBody('PUT', '/static'))->getBody()); + $this->assertSame('meow', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/meow'))->getBody()); + $this->assertSame('meow', (string)$router->handle(HttpRequest::createRequestWithoutBody('POST', '/meow'))->getBody()); + $this->assertSame('profile of Cool134', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/profile/Cool134'))->getBody()); + $this->assertSame('still the profile of Cool134', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/profile-but-raw/Cool134'))->getBody()); } public function testEEPROMSituation(): void { @@ -132,8 +134,8 @@ final class RouterTest extends TestCase { $router->route(RouteInfo::pattern('DELETE', '#^/uploads/([A-Za-z0-9\-_]+)$#uD', fn(string $id) => "Delete {$id}")); // make sure both GET and DELETE are able to execute with a different pattern - $this->assertEquals('Get AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/uploads/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'))->getBody()); - $this->assertEquals('Delete BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', (string)$router->handle(HttpRequest::createRequestWithoutBody('DELETE', '/uploads/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'))->getBody()); + $this->assertSame('Get AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/uploads/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'))->getBody()); + $this->assertSame('Delete BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', (string)$router->handle(HttpRequest::createRequestWithoutBody('DELETE', '/uploads/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'))->getBody()); } public function testFilterInterceptionOnRoot(): void { @@ -142,9 +144,9 @@ final class RouterTest extends TestCase { $router->route(RouteInfo::exact('GET', '/', fn() => 'unexpected')); $router->route(RouteInfo::exact('GET', '/test', fn() => 'also unexpected')); - $this->assertEquals('expected', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/'))->getBody()); - $this->assertEquals('expected', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/test'))->getBody()); - $this->assertEquals('expected', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/error'))->getBody()); + $this->assertSame('expected', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/'))->getBody()); + $this->assertSame('expected', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/test'))->getBody()); + $this->assertSame('expected', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/error'))->getBody()); } public function testDefaultOptionsImplementation(): void { @@ -154,9 +156,10 @@ final class RouterTest extends TestCase { $router->route(RouteInfo::exact('PATCH', '/test', fn() => 'patch')); $response = $router->handle(HttpRequest::createRequestWithoutBody('OPTIONS', '/test')); - $this->assertEquals(204, $response->getStatusCode()); - $this->assertEquals('No Content', $response->getReasonPhrase()); - $this->assertEquals('GET, HEAD, OPTIONS, PATCH, POST', $response->getHeaderLine('Allow')); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('OK', $response->getReasonPhrase()); + $this->assertSame('0', $response->getHeaderLine('Content-Length')); + $this->assertSame('GET, HEAD, OPTIONS, PATCH, POST', $response->getHeaderLine('Allow')); } public function testDefaultProcessorsNoRegister(): void { @@ -238,69 +241,69 @@ final class RouterTest extends TestCase { return '<!doctype html><h1>ensuring autodetect gets skipped</h1>'; } + /** @return array{wow: string} */ #[After('output:json', flags: 0)] #[ExactRoute('GET', '/output/json')] - /** @return array{wow: string} */ public function testOutputJson(): array { return ['wow' => 'objects?? / epic!!']; } + /** @return array{benben: int} */ #[After('output:bencode')] #[ExactRoute('GET', '/output/bencode')] - /** @return array{benben: int} */ public function testOutputBencode(): array { return ['benben' => 12345]; } }); $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/stream')); - $this->assertEquals('application/octet-stream', $response->getHeaderLine('Content-Type')); - $this->assertEquals('<!doctype html><h1>ensuring autodetect gets skipped</h1>', (string)$response->getBody()); + $this->assertSame('application/octet-stream', $response->getHeaderLine('Content-Type')); + $this->assertSame('<!doctype html><h1>ensuring autodetect gets skipped</h1>', (string)$response->getBody()); $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/plain')); - $this->assertEquals('text/plain;charset=utf-8', $response->getHeaderLine('Content-Type')); - $this->assertEquals('<!doctype html><h1>ensuring autodetect gets skipped</h1>', (string)$response->getBody()); + $this->assertSame('text/plain;charset=utf-8', $response->getHeaderLine('Content-Type')); + $this->assertSame('<!doctype html><h1>ensuring autodetect gets skipped</h1>', (string)$response->getBody()); $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/html')); - $this->assertEquals('text/html;charset=us-ascii', $response->getHeaderLine('Content-Type')); - $this->assertEquals('<?xml idk how xml opens the prefix is enough><beans></beans>', (string)$response->getBody()); + $this->assertSame('text/html;charset=us-ascii', $response->getHeaderLine('Content-Type')); + $this->assertSame('<?xml idk how xml opens the prefix is enough><beans></beans>', (string)$response->getBody()); $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/xml')); - $this->assertEquals('application/xml', $response->getHeaderLine('Content-Type')); - $this->assertEquals('soup', (string)$response->getBody()); + $this->assertSame('application/xml', $response->getHeaderLine('Content-Type')); + $this->assertSame('soup', (string)$response->getBody()); $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/css')); - $this->assertEquals('text/css', $response->getHeaderLine('Content-Type')); - $this->assertEquals('<!doctype html><h1>ensuring autodetect gets skipped</h1>', (string)$response->getBody()); + $this->assertSame('text/css', $response->getHeaderLine('Content-Type')); + $this->assertSame('<!doctype html><h1>ensuring autodetect gets skipped</h1>', (string)$response->getBody()); $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/js')); - $this->assertEquals('application/javascript', $response->getHeaderLine('Content-Type')); - $this->assertEquals('<!doctype html><h1>ensuring autodetect gets skipped</h1>', (string)$response->getBody()); + $this->assertSame('application/javascript', $response->getHeaderLine('Content-Type')); + $this->assertSame('<!doctype html><h1>ensuring autodetect gets skipped</h1>', (string)$response->getBody()); $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/json')); - $this->assertEquals('application/json', $response->getHeaderLine('Content-Type')); - $this->assertEquals('{"wow":"objects?? \/ epic!!"}', (string)$response->getBody()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + $this->assertSame('{"wow":"objects?? \/ epic!!"}', (string)$response->getBody()); $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/bencode')); - $this->assertEquals('application/x-bittorrent', $response->getHeaderLine('Content-Type')); - $this->assertEquals('d6:benbeni12345ee', (string)$response->getBody()); + $this->assertSame('application/x-bittorrent', $response->getHeaderLine('Content-Type')); + $this->assertSame('d6:benbeni12345ee', (string)$response->getBody()); $response = $router->handle(HttpRequest::createRequestWithoutBody('POST', '/optional-form')); - $this->assertEquals(200, $response->getStatusCode()); - $this->assertEquals('none', (string)$response->getBody()); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('none', (string)$response->getBody()); $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/optional-form', [], Stream::createStream('test=mewow'))); - $this->assertEquals(200, $response->getStatusCode()); - $this->assertEquals('none', (string)$response->getBody()); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('none', (string)$response->getBody()); $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/optional-form', ['Content-Type' => ['application/x-www-form-urlencoded']], Stream::createStream('test=mewow'))); - $this->assertEquals(200, $response->getStatusCode()); - $this->assertEquals('urlencoded:mewow', (string)$response->getBody()); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('urlencoded:mewow', (string)$response->getBody()); // an empty string is valid too $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/optional-form', ['Content-Type' => ['application/x-www-form-urlencoded']], Stream::createStream(''))); - $this->assertEquals(200, $response->getStatusCode()); - $this->assertEquals('urlencoded:', (string)$response->getBody()); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('urlencoded:', (string)$response->getBody()); $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/optional-form', ['Content-Type' => ['multipart/form-data; boundary="--soap12345"']], Stream::createStream(implode("\r\n", [ '----soap12345', @@ -310,25 +313,25 @@ final class RouterTest extends TestCase { '----soap12345--', '', ])))); - $this->assertEquals(200, $response->getStatusCode()); - $this->assertEquals('multipart:wowof', (string)$response->getBody()); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('multipart:wowof', (string)$response->getBody()); $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/optional-form', ['Content-Type' => ['multipart/form-data; boundary="--soap12345"']], Stream::createStream(implode("\r\n", [ '----soap12345--', '', ])))); - $this->assertEquals(200, $response->getStatusCode()); - $this->assertEquals('multipart:', (string)$response->getBody()); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('multipart:', (string)$response->getBody()); $response = $router->handle(HttpRequest::createRequestWithoutBody('POST', '/required-urlencoded')); - $this->assertEquals(400, $response->getStatusCode()); + $this->assertSame(400, $response->getStatusCode()); $response = $router->handle(HttpRequest::createRequestWithoutBody('POST', '/required-multipart')); - $this->assertEquals(400, $response->getStatusCode()); + $this->assertSame(400, $response->getStatusCode()); $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/required-urlencoded', ['Content-Type' => ['application/x-www-form-urlencoded']], Stream::createStream('test=meow'))); - $this->assertEquals(200, $response->getStatusCode()); - $this->assertEquals('meow', (string)$response->getBody()); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('meow', (string)$response->getBody()); $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/required-multipart', ['Content-Type' => ['multipart/form-data; boundary="--soap56789"']], Stream::createStream(implode("\r\n", [ '----soap56789', @@ -338,11 +341,11 @@ final class RouterTest extends TestCase { '----soap56789--', '', ])))); - $this->assertEquals(200, $response->getStatusCode()); - $this->assertEquals('woof', (string)$response->getBody()); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('woof', (string)$response->getBody()); $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/required-urlencoded', ['Content-Type' => ['multipart/form-data; boundary="--soap56789"']], Stream::createStream('test=meow'))); - $this->assertEquals(400, $response->getStatusCode()); + $this->assertSame(400, $response->getStatusCode()); $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/required-multipart', ['Content-Type' => ['application/x-www-form-urlencoded']], Stream::createStream(implode("\r\n", [ '----soap56789', @@ -352,7 +355,7 @@ final class RouterTest extends TestCase { '----soap56789--', '', ])))); - $this->assertEquals(400, $response->getStatusCode()); + $this->assertSame(400, $response->getStatusCode()); $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/required-multipart', ['Content-Type' => ['multipart/form-data']], Stream::createStream(implode("\r\n", [ '----aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', @@ -362,7 +365,7 @@ final class RouterTest extends TestCase { '----aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa--', '', ])))); - $this->assertEquals(400, $response->getStatusCode()); + $this->assertSame(400, $response->getStatusCode()); } public function testProcessors(): void { @@ -424,15 +427,716 @@ final class RouterTest extends TestCase { )); $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/test', [], Stream::createStream(base64_encode('mewow')))); - $this->assertEquals(200, $response->getStatusCode()); - $this->assertEquals("{\n \"data\": \"mewow\"\n}", (string)$response->getBody()); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame("{\n \"data\": \"mewow\"\n}", (string)$response->getBody()); $response = $router->handle(HttpRequest::createRequestWithBody('PUT', '/alternate', [], Stream::createStream(base64_encode('soap')))); - $this->assertEquals(200, $response->getStatusCode()); - $this->assertEquals("{\n \"path\": \"/alternate\",\n \"data\": \"soap\"\n}", (string)$response->getBody()); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame("{\n \"path\": \"/alternate\",\n \"data\": \"soap\"\n}", (string)$response->getBody()); $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/filtered')); - $this->assertEquals(403, $response->getStatusCode()); - $this->assertEquals('it can work like a filter too', (string)$response->getBody()); + $this->assertSame(403, $response->getStatusCode()); + $this->assertSame('it can work like a filter too', (string)$response->getBody()); + } + + public function testAccessControl(): void { + $router = new Router; + + $router->register(new class implements RouteHandler { + use RouteHandlerCommon; + + + #[ExactRoute('GET', '/nothing')] + public function nothing(): void {} + + + #[AccessControl] + #[ExactRoute('GET', '/acld')] + public function getAccessControlDifferent(): void {} + + #[ExactRoute('POST', '/acld')] + public function postAccessControlDifferent(): void {} + + + #[AccessControl] + #[ExactRoute('GET', '/copdodnop')] + public function getCredentialsOnPostDenyOnDeleteNothingOnPut(): void {} + + #[AccessControl(credentials: true)] + #[ExactRoute('POST', '/copdodnop')] + public function postCredentialsOnPostDenyOnDeleteNothingOnPut(): void {} + + #[AccessControl(allow: false)] + #[ExactRoute('DELETE', '/copdodnop')] + public function deleteCredentialsOnPostDenyOnDeleteNothingOnPut(): void {} + + #[ExactRoute('PUT', '/copdodnop')] + public function putCredentialsOnPostDenyOnDeleteNothingOnPut(): void {} + + + #[AccessControl(allowMethods: ['HEAD'])] + #[ExactRoute('GET', '/methods/extra')] + public function getMethodsExtra(): void {} + + + #[AccessControl(allowHeaders: true)] + #[ExactRoute('GET', '/headers/allow-all')] + public function getHeadersAllowAll(): void {} + + #[AccessControl(allowHeaders: ['Authorization', 'X-Content-Index', 'Content-Type'])] + #[ExactRoute('GET', '/headers/allow-specific')] + public function getHeadersAllowSpecific(): void {} + + + #[AccessControl] + #[ExactRoute('GET', '/headers/expose-safe')] + public function getHeadersExposeSafe(): void {} + + #[AccessControl(exposeHeaders: true)] + #[ExactRoute('GET', '/headers/expose-all')] + public function getHeadersExposeAll(): void {} + + #[AccessControl(exposeHeaders: ['X-EEPROM-Max-Size', 'Vary'])] + #[ExactRoute('GET', '/headers/expose-specific')] + public function getHeadersExposeSpecific(): void {} + + // including a little curveball! i'm sure this won't lead to confusion + #[AccessControl(allow: true, credentials: true, allowMethods: ['POST', 'HEAD'], allowHeaders: ['Content-Length', 'X-Content-Index'], exposeHeaders: ['X-Satori-Time', 'X-Satori-Hash'])] + #[ExactRoute('GET', '/all-together-now')] + public function getAllTogetherNow(): void {} + + #[ExactRoute('POST', '/all-together-now')] + public function postAllTogetherNow(): void {} + }); + + $routeWithNo = RouteInfo::exact( + 'PUT', '/no-override', + #[AccessControl(allow: false)] + function(): void {} + ); + $routeWithNo->accessControl = new AccessControl; // allow: true + $router->route($routeWithNo); + + $response = $router->handle(HttpRequest::createRequestWithoutBody('OPTIONS', '/nothing')); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('0', $response->getHeaderLine('Content-Length')); + $this->assertSame('GET, HEAD, OPTIONS', $response->getHeaderLine('Allow')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'OPTIONS', '/nothing', + [ + 'Origin' => ['https://railgun.sh'], + 'Access-Control-Request-Method' => ['GET'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('0', $response->getHeaderLine('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'GET', '/nothing', + [ + 'Origin' => ['https://railgun.sh'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertFalse($response->hasHeader('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'OPTIONS', '/no-override', + [ + 'Origin' => ['https://railgun.sh'], + 'Access-Control-Request-Method' => ['PUT'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('0', $response->getHeaderLine('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'PUT', '/no-override', + [ + 'Origin' => ['https://railgun.sh'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertFalse($response->hasHeader('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'OPTIONS', '/acld', + [ + 'Origin' => ['https://railgun.sh'], + 'Access-Control-Request-Method' => ['GET'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('0', $response->getHeaderLine('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertSame('GET', $response->getHeaderLine('Access-Control-Allow-Methods')); + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'GET', '/acld', + [ + 'Origin' => ['https://railgun.sh'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertFalse($response->hasHeader('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'POST', '/acld', + [ + 'Origin' => ['https://railgun.sh'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertFalse($response->hasHeader('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'OPTIONS', '/copdodnop', + [ + 'Origin' => ['https://railgun.sh'], + 'Access-Control-Request-Method' => ['GET'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('0', $response->getHeaderLine('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertSame('GET, POST', $response->getHeaderLine('Access-Control-Allow-Methods')); + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'GET', '/copdodnop', + [ + 'Origin' => ['https://railgun.sh'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertFalse($response->hasHeader('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'OPTIONS', '/copdodnop', + [ + 'Origin' => ['https://railgun.sh'], + 'Access-Control-Request-Method' => ['POST'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('0', $response->getHeaderLine('Content-Length')); + $this->assertSame('true', $response->getHeaderLine('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertSame('GET, POST', $response->getHeaderLine('Access-Control-Allow-Methods')); + $this->assertSame('https://railgun.sh', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'POST', '/copdodnop', + [ + 'Origin' => ['https://railgun.sh'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertFalse($response->hasHeader('Content-Length')); + $this->assertSame('true', $response->getHeaderLine('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); + $this->assertSame('https://railgun.sh', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'OPTIONS', '/copdodnop', + [ + 'Origin' => ['https://railgun.sh'], + 'Access-Control-Request-Method' => ['DELETE'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('0', $response->getHeaderLine('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertSame('GET, POST', $response->getHeaderLine('Access-Control-Allow-Methods')); + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'DELETE', '/copdodnop', + [ + 'Origin' => ['https://railgun.sh'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertFalse($response->hasHeader('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'OPTIONS', '/copdodnop', + [ + 'Origin' => ['https://railgun.sh'], + 'Access-Control-Request-Method' => ['PUT'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('0', $response->getHeaderLine('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertSame('GET, POST', $response->getHeaderLine('Access-Control-Allow-Methods')); + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'PUT', '/copdodnop', + [ + 'Origin' => ['https://railgun.sh'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertFalse($response->hasHeader('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'OPTIONS', '/methods/extra', + [ + 'Origin' => ['https://railgun.sh'], + 'Access-Control-Request-Method' => ['HEAD'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('0', $response->getHeaderLine('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertSame('GET, HEAD', $response->getHeaderLine('Access-Control-Allow-Methods')); + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'GET', '/methods/extra', + [ + 'Origin' => ['https://railgun.sh'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertFalse($response->hasHeader('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'HEAD', '/methods/extra', + [ + 'Origin' => ['https://railgun.sh'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertFalse($response->hasHeader('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'OPTIONS', '/headers/allow-all', + [ + 'Origin' => ['https://railgun.sh'], + 'Access-Control-Request-Method' => ['GET'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('0', $response->getHeaderLine('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertSame('GET', $response->getHeaderLine('Access-Control-Allow-Methods')); + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'OPTIONS', '/headers/allow-all', + [ + 'Origin' => ['https://railgun.sh'], + 'Access-Control-Request-Method' => ['GET'], + 'Access-Control-Request-Headers' => ['X-Soap'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('0', $response->getHeaderLine('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Headers')); + $this->assertSame('GET', $response->getHeaderLine('Access-Control-Allow-Methods')); + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'GET', '/headers/allow-all', + [ + 'Origin' => ['https://railgun.sh'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertFalse($response->hasHeader('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'OPTIONS', '/headers/allow-all', + [ + 'Origin' => ['https://railgun.sh'], + 'Access-Control-Request-Method' => ['GET'], + 'Access-Control-Request-Headers' => [''], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('0', $response->getHeaderLine('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Headers')); + $this->assertSame('GET', $response->getHeaderLine('Access-Control-Allow-Methods')); + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'GET', '/headers/allow-all', + [ + 'Origin' => ['https://railgun.sh'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertFalse($response->hasHeader('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'OPTIONS', '/headers/allow-specific', + [ + 'Origin' => ['https://railgun.sh'], + 'Access-Control-Request-Method' => ['GET'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('0', $response->getHeaderLine('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertSame('GET', $response->getHeaderLine('Access-Control-Allow-Methods')); + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'OPTIONS', '/headers/allow-specific', + [ + 'Origin' => ['https://railgun.sh'], + 'Access-Control-Request-Method' => ['GET'], + 'Access-Control-Request-Headers' => ['X-Content-Index', 'Authorization'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('0', $response->getHeaderLine('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertSame('Authorization, X-Content-Index', $response->getHeaderLine('Access-Control-Allow-Headers')); + $this->assertSame('GET', $response->getHeaderLine('Access-Control-Allow-Methods')); + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'OPTIONS', '/headers/allow-specific', + [ + 'Origin' => ['https://railgun.sh'], + 'Access-Control-Request-Method' => ['GET'], + 'Access-Control-Request-Headers' => ['X-Beans'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('0', $response->getHeaderLine('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'GET', '/headers/allow-specific', + [ + 'Origin' => ['https://railgun.sh'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertFalse($response->hasHeader('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'OPTIONS', '/headers/expose-safe', + [ + 'Origin' => ['https://railgun.sh'], + 'Access-Control-Request-Method' => ['GET'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('0', $response->getHeaderLine('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertSame('GET', $response->getHeaderLine('Access-Control-Allow-Methods')); + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'GET', '/headers/expose-safe', + [ + 'Origin' => ['https://railgun.sh'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertFalse($response->hasHeader('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'OPTIONS', '/headers/expose-all', + [ + 'Origin' => ['https://railgun.sh'], + 'Access-Control-Request-Method' => ['GET'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('0', $response->getHeaderLine('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertSame('GET', $response->getHeaderLine('Access-Control-Allow-Methods')); + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'GET', '/headers/expose-all', + [ + 'Origin' => ['https://railgun.sh'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertFalse($response->hasHeader('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertTrue($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertSame('*', $response->getHeaderLine('Access-Control-Expose-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'OPTIONS', '/headers/expose-specific', + [ + 'Origin' => ['https://railgun.sh'], + 'Access-Control-Request-Method' => ['GET'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('0', $response->getHeaderLine('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertSame('GET', $response->getHeaderLine('Access-Control-Allow-Methods')); + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'GET', '/headers/expose-specific', + [ + 'Origin' => ['https://railgun.sh'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertFalse($response->hasHeader('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertTrue($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertSame('Vary, X-EEPROM-Max-Size', $response->getHeaderLine('Access-Control-Expose-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'OPTIONS', '/all-together-now', + [ + 'Origin' => ['https://flash.moe:8443'], + 'Access-Control-Request-Method' => ['GET'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('0', $response->getHeaderLine('Content-Length')); + $this->assertSame('true', $response->getHeaderLine('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertSame('GET, HEAD, POST', $response->getHeaderLine('Access-Control-Allow-Methods')); + $this->assertSame('https://flash.moe:8443', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'OPTIONS', '/all-together-now', + [ + 'Origin' => ['https://flash.moe:8443'], + 'Access-Control-Request-Method' => ['GET'], + 'Access-Control-Request-Headers' => [''], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('0', $response->getHeaderLine('Content-Length')); + $this->assertSame('true', $response->getHeaderLine('Access-Control-Allow-Credentials')); + $this->assertTrue($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertSame('', $response->getHeaderLine('Access-Control-Allow-Headers')); + $this->assertSame('GET, HEAD, POST', $response->getHeaderLine('Access-Control-Allow-Methods')); + $this->assertSame('https://flash.moe:8443', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'OPTIONS', '/all-together-now', + [ + 'Origin' => ['https://flash.moe:8443'], + 'Access-Control-Request-Method' => ['GET'], + 'Access-Control-Request-Headers' => ['x-content-index'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('0', $response->getHeaderLine('Content-Length')); + $this->assertSame('true', $response->getHeaderLine('Access-Control-Allow-Credentials')); + $this->assertSame('x-content-index', $response->getHeaderLine('Access-Control-Allow-Headers')); + $this->assertSame('GET, HEAD, POST', $response->getHeaderLine('Access-Control-Allow-Methods')); + $this->assertSame('https://flash.moe:8443', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'GET', '/all-together-now', + [ + 'Origin' => ['https://flash.moe:8443'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertFalse($response->hasHeader('Content-Length')); + $this->assertSame('true', $response->getHeaderLine('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); + $this->assertSame('https://flash.moe:8443', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertSame('X-Satori-Hash, X-Satori-Time', $response->getHeaderLine('Access-Control-Expose-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Max-Age')); + + // although routes can declare other methods than their own as CORS supported, + // allow credentials and headers ONLY apply to their own method + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'OPTIONS', '/all-together-now', + [ + 'Origin' => ['https://flash.moe:8443'], + 'Access-Control-Request-Method' => ['POST'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('0', $response->getHeaderLine('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertSame('GET, HEAD, POST', $response->getHeaderLine('Access-Control-Allow-Methods')); + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age')); + + $response = $router->handle(HttpRequest::createRequestWithoutBody( + 'POST', '/all-together-now', + [ + 'Origin' => ['https://flash.moe:8443'], + ], + )); + $this->assertSame(200, $response->getStatusCode()); + $this->assertFalse($response->hasHeader('Content-Length')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + $this->assertFalse($response->hasHeader('Access-Control-Max-Age')); } }