diff --git a/src/http/src/Cors.php b/src/http/src/Cors.php index 7b7c14d7..f1701307 100644 --- a/src/http/src/Cors.php +++ b/src/http/src/Cors.php @@ -11,12 +11,19 @@ namespace Hypervel\Http; +use Hyperf\Context\Context; use Hypervel\Context\ApplicationContext; use Hypervel\Http\Contracts\RequestContract; use Hypervel\Http\Contracts\ResponseContract; use Psr\Http\Message\ResponseInterface; /** + * CORS service with coroutine-safe options storage. + * + * Options are stored in coroutine-local Context as an immutable CorsOptions + * object, making this service safe for concurrent use in Swoole's coroutine + * environment. Each request gets its own isolated CORS configuration. + * * @phpstan-type CorsInputOptions array{ * 'allowedOrigins'?: string[], * 'allowedOriginsPatterns'?: string[], @@ -36,87 +43,89 @@ */ class Cors { - /** @var string[] */ - private array $allowedOrigins = []; - - /** @var string[] */ - private array $allowedOriginsPatterns = []; - - /** @var string[] */ - private array $allowedMethods = []; - - /** @var string[] */ - private array $allowedHeaders = []; - - /** @var string[] */ - private array $exposedHeaders = []; - - private bool $supportsCredentials = false; - - private ?int $maxAge = 0; - - private bool $allowAllOrigins = false; - - private bool $allowAllMethods = false; - - private bool $allowAllHeaders = false; + /** + * Context key for storing CORS options. + */ + private const CONTEXT_KEY = '__cors.options'; /** * @param CorsInputOptions $options */ public function __construct(array $options = []) { - if ($options) { + if ($options !== []) { $this->setOptions($options); } } /** + * Set CORS options for the current request. + * + * Options are normalized and stored in coroutine-local Context as an + * immutable CorsOptions object, ensuring isolation between concurrent + * requests. Type validation is handled by CorsOptions' typed properties. + * * @param CorsInputOptions $options */ public function setOptions(array $options): void { - $this->allowedOrigins = $options['allowedOrigins'] ?? $options['allowed_origins'] ?? $this->allowedOrigins; - $this->allowedOriginsPatterns - = $options['allowedOriginsPatterns'] ?? $options['allowed_origins_patterns'] ?? $this->allowedOriginsPatterns; - $this->allowedMethods = $options['allowedMethods'] ?? $options['allowed_methods'] ?? $this->allowedMethods; - $this->allowedHeaders = $options['allowedHeaders'] ?? $options['allowed_headers'] ?? $this->allowedHeaders; - $this->supportsCredentials - = $options['supportsCredentials'] ?? $options['supports_credentials'] ?? $this->supportsCredentials; - - $maxAge = $this->maxAge; + $current = $this->getOptions(); + + $allowedOrigins = $options['allowedOrigins'] ?? $options['allowed_origins'] ?? $current->allowedOrigins; + $allowedOriginsPatterns = $options['allowedOriginsPatterns'] ?? $options['allowed_origins_patterns'] ?? $current->allowedOriginsPatterns; + $allowedMethods = $options['allowedMethods'] ?? $options['allowed_methods'] ?? $current->allowedMethods; + $allowedHeaders = $options['allowedHeaders'] ?? $options['allowed_headers'] ?? $current->allowedHeaders; + $supportsCredentials = $options['supportsCredentials'] ?? $options['supports_credentials'] ?? $current->supportsCredentials; + + $maxAge = $current->maxAge; if (array_key_exists('maxAge', $options)) { $maxAge = $options['maxAge']; } elseif (array_key_exists('max_age', $options)) { $maxAge = $options['max_age']; } - $this->maxAge = $maxAge === null ? null : (int) $maxAge; - - $exposedHeaders = $options['exposedHeaders'] ?? $options['exposed_headers'] ?? $this->exposedHeaders; - $this->exposedHeaders = $exposedHeaders === false ? [] : $exposedHeaders; + $maxAge = $maxAge === null ? null : (int) $maxAge; - $this->normalizeOptions(); - } + $exposedHeaders = $options['exposedHeaders'] ?? $options['exposed_headers'] ?? $current->exposedHeaders; + $exposedHeaders = $exposedHeaders === false ? [] : $exposedHeaders; - private function normalizeOptions(): void - { // Normalize case - $this->allowedHeaders = array_map('strtolower', $this->allowedHeaders); - $this->allowedMethods = array_map('strtoupper', $this->allowedMethods); + $allowedHeaders = array_map('strtolower', $allowedHeaders); + $allowedMethods = array_map('strtoupper', $allowedMethods); - // Normalize ['*'] to true - $this->allowAllOrigins = in_array('*', $this->allowedOrigins); - $this->allowAllHeaders = in_array('*', $this->allowedHeaders); - $this->allowAllMethods = in_array('*', $this->allowedMethods); + // Normalize ['*'] to flags + $allowAllOrigins = in_array('*', $allowedOrigins); + $allowAllHeaders = in_array('*', $allowedHeaders); + $allowAllMethods = in_array('*', $allowedMethods); - // Transform wildcard pattern - if (! $this->allowAllOrigins) { - foreach ($this->allowedOrigins as $origin) { + // Transform wildcard patterns in origins + if (! $allowAllOrigins) { + foreach ($allowedOrigins as $origin) { if (strpos($origin, '*') !== false) { - $this->allowedOriginsPatterns[] = $this->convertWildcardToPattern($origin); + $allowedOriginsPatterns[] = $this->convertWildcardToPattern($origin); } } } + + Context::set(self::CONTEXT_KEY, new CorsOptions( + allowedOrigins: $allowedOrigins, + allowedOriginsPatterns: $allowedOriginsPatterns, + supportsCredentials: $supportsCredentials, + allowedHeaders: $allowedHeaders, + allowedMethods: $allowedMethods, + exposedHeaders: $exposedHeaders, + maxAge: $maxAge, + allowAllOrigins: $allowAllOrigins, + allowAllHeaders: $allowAllHeaders, + allowAllMethods: $allowAllMethods, + )); + } + + /** + * Get the current CORS options from Context. + */ + private function getOptions(): CorsOptions + { + return Context::get(self::CONTEXT_KEY) ?? new CorsOptions(); } /** @@ -172,17 +181,19 @@ public function addPreflightRequestHeaders(ResponseInterface $response, RequestC public function isOriginAllowed(RequestContract $request): bool { - if ($this->allowAllOrigins === true) { + $options = $this->getOptions(); + + if ($options->allowAllOrigins) { return true; } $origin = $request->header('Origin') ?: ''; - if (in_array($origin, $this->allowedOrigins)) { + if (in_array($origin, $options->allowedOrigins)) { return true; } - foreach ($this->allowedOriginsPatterns as $pattern) { + foreach ($options->allowedOriginsPatterns as $pattern) { if (preg_match($pattern, $origin)) { return true; } @@ -205,12 +216,14 @@ public function addActualRequestHeaders(ResponseInterface $response, RequestCont private function configureAllowedOrigin(ResponseInterface $response, RequestContract $request): ResponseInterface { - if ($this->allowAllOrigins === true && ! $this->supportsCredentials) { + $options = $this->getOptions(); + + if ($options->allowAllOrigins && ! $options->supportsCredentials) { // Safe+cacheable, allow everything $response = $response->withHeader('Access-Control-Allow-Origin', '*'); } elseif ($this->isSingleOriginAllowed()) { // Single origins can be safely set - $response = $response->withHeader('Access-Control-Allow-Origin', array_values($this->allowedOrigins)[0]); + $response = $response->withHeader('Access-Control-Allow-Origin', array_values($options->allowedOrigins)[0]); } else { // For dynamic headers, set the requested Origin header when set and allowed if ($this->isCorsRequest($request) && $this->isOriginAllowed($request)) { @@ -225,20 +238,24 @@ private function configureAllowedOrigin(ResponseInterface $response, RequestCont private function isSingleOriginAllowed(): bool { - if ($this->allowAllOrigins === true || count($this->allowedOriginsPatterns) > 0) { + $options = $this->getOptions(); + + if ($options->allowAllOrigins || count($options->allowedOriginsPatterns) > 0) { return false; } - return count($this->allowedOrigins) === 1; + return count($options->allowedOrigins) === 1; } private function configureAllowedMethods(ResponseInterface $response, RequestContract $request): ResponseInterface { - if ($this->allowAllMethods === true) { + $options = $this->getOptions(); + + if ($options->allowAllMethods) { $allowMethods = strtoupper($request->header('Access-Control-Request-Method')); $response = $this->varyHeader($response, 'Access-Control-Request-Method'); } else { - $allowMethods = implode(', ', $this->allowedMethods); + $allowMethods = implode(', ', $options->allowedMethods); } return $response->withHeader('Access-Control-Allow-Methods', $allowMethods); @@ -246,11 +263,13 @@ private function configureAllowedMethods(ResponseInterface $response, RequestCon private function configureAllowedHeaders(ResponseInterface $response, RequestContract $request): ResponseInterface { - if ($this->allowAllHeaders === true) { + $options = $this->getOptions(); + + if ($options->allowAllHeaders) { $allowHeaders = $request->header('Access-Control-Request-Headers'); $this->varyHeader($response, 'Access-Control-Request-Headers'); } else { - $allowHeaders = implode(', ', $this->allowedHeaders); + $allowHeaders = implode(', ', $options->allowedHeaders); } return $response->withHeader('Access-Control-Allow-Headers', $allowHeaders); @@ -258,7 +277,9 @@ private function configureAllowedHeaders(ResponseInterface $response, RequestCon private function configureAllowCredentials(ResponseInterface $response, RequestContract $request): ResponseInterface { - if ($this->supportsCredentials) { + $options = $this->getOptions(); + + if ($options->supportsCredentials) { $response = $response->withHeader('Access-Control-Allow-Credentials', 'true'); } @@ -267,8 +288,10 @@ private function configureAllowCredentials(ResponseInterface $response, RequestC private function configureExposedHeaders(ResponseInterface $response, RequestContract $request): ResponseInterface { - if ($this->exposedHeaders) { - $response = $response->withHeader('Access-Control-Expose-Headers', implode(', ', $this->exposedHeaders)); + $options = $this->getOptions(); + + if ($options->exposedHeaders !== []) { + $response = $response->withHeader('Access-Control-Expose-Headers', implode(', ', $options->exposedHeaders)); } return $response; @@ -276,8 +299,10 @@ private function configureExposedHeaders(ResponseInterface $response, RequestCon private function configureMaxAge(ResponseInterface $response, RequestContract $request): ResponseInterface { - if ($this->maxAge !== null) { - $response = $response->withHeader('Access-Control-Max-Age', (string) $this->maxAge); + $options = $this->getOptions(); + + if ($options->maxAge !== null) { + $response = $response->withHeader('Access-Control-Max-Age', (string) $options->maxAge); } return $response; diff --git a/src/http/src/CorsOptions.php b/src/http/src/CorsOptions.php new file mode 100644 index 00000000..b550c02e --- /dev/null +++ b/src/http/src/CorsOptions.php @@ -0,0 +1,41 @@ +cors->setOptions( - $this->config = $container->get(ConfigInterface::class)->get('cors', []) - ); + $this->config = $container->get(ConfigInterface::class)->get('cors', []); } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface @@ -37,6 +35,9 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface return $handler->handle($request); } + // Set CORS options per-request for coroutine isolation + $this->cors->setOptions($this->getCorsConfig()); + if ($this->cors->isPreflightRequest($this->request)) { $response = $this->cors->handlePreflightRequest($this->request); return $this->cors->varyHeader($response, 'Access-Control-Request-Method'); @@ -101,4 +102,15 @@ protected function getPathsByHost(string $host): array return is_string($path); }); } + + /** + * Get the CORS configuration. + * + * Override this method to provide custom CORS configuration, + * such as tenant-specific allowed origins. + */ + protected function getCorsConfig(): array + { + return $this->config; + } } diff --git a/tests/Http/CorsTest.php b/tests/Http/CorsTest.php index 40a5a3db..7cbd4ea8 100644 --- a/tests/Http/CorsTest.php +++ b/tests/Http/CorsTest.php @@ -4,30 +4,33 @@ namespace Hypervel\Tests\Http; +use Hyperf\Context\Context; +use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; use Hypervel\Http\Cors; -use PHPUnit\Framework\TestCase; -use ReflectionClass; -use ReflectionProperty; +use Hypervel\Http\CorsOptions; +use Hypervel\Tests\TestCase; use TypeError; /** - * @phpstan-type CorsNormalizedOptions array{ - * 'allowedOrigins': string[], - * 'allowedOriginsPatterns': string[], - * 'supportsCredentials': bool, - * 'allowedHeaders': string[], - * 'allowedMethods': string[], - * 'exposedHeaders': string[], - * 'maxAge': int|bool|null, - * 'allowAllOrigins': bool, - * 'allowAllHeaders': bool, - * 'allowAllMethods': bool, - * } * @internal * @coversNothing */ class CorsTest extends TestCase { + use RunTestsInCoroutine; + + /** + * Context key used by Cors class. + */ + private const CORS_CONTEXT_KEY = '__cors.options'; + + protected function tearDown(): void + { + // Clean up context between tests + Context::destroy(self::CORS_CONTEXT_KEY); + parent::tearDown(); + } + public function testCanHaveOptions(): void { $options = [ @@ -42,22 +45,22 @@ public function testCanHaveOptions(): void $service = new Cors($options); - $normalized = $this->getOptionsFromService($service); + $corsOptions = $this->getOptionsFromContext(); - $this->assertEquals($options['allowedOrigins'], $normalized['allowedOrigins']); - $this->assertEquals($options['allowedOriginsPatterns'], $normalized['allowedOriginsPatterns']); - $this->assertEquals($options['allowedHeaders'], $normalized['allowedHeaders']); - $this->assertEquals($options['allowedMethods'], $normalized['allowedMethods']); - $this->assertEquals($options['maxAge'], $normalized['maxAge']); - $this->assertEquals($options['supportsCredentials'], $normalized['supportsCredentials']); - $this->assertEquals($options['exposedHeaders'], $normalized['exposedHeaders']); + $this->assertEquals($options['allowedOrigins'], $corsOptions->allowedOrigins); + $this->assertEquals($options['allowedOriginsPatterns'], $corsOptions->allowedOriginsPatterns); + $this->assertEquals(['x-custom'], $corsOptions->allowedHeaders); // lowercased + $this->assertEquals($options['allowedMethods'], $corsOptions->allowedMethods); + $this->assertEquals($options['maxAge'], $corsOptions->maxAge); + $this->assertEquals($options['supportsCredentials'], $corsOptions->supportsCredentials); + $this->assertEquals($options['exposedHeaders'], $corsOptions->exposedHeaders); } public function testCanSetOptions(): void { $service = new Cors(); - $normalized = $this->getOptionsFromService($service); - $this->assertEquals([], $normalized['allowedOrigins']); + $corsOptions = $this->getOptionsFromContext(); + $this->assertEquals([], $corsOptions->allowedOrigins); $options = [ 'allowedOrigins' => ['localhost'], @@ -71,23 +74,23 @@ public function testCanSetOptions(): void $service->setOptions($options); - $normalized = $this->getOptionsFromService($service); + $corsOptions = $this->getOptionsFromContext(); - $this->assertEquals($options['allowedOrigins'], $normalized['allowedOrigins']); - $this->assertEquals($options['allowedOriginsPatterns'], $normalized['allowedOriginsPatterns']); - $this->assertEquals($options['allowedHeaders'], $normalized['allowedHeaders']); - $this->assertEquals($options['allowedMethods'], $normalized['allowedMethods']); - $this->assertEquals($options['maxAge'], $normalized['maxAge']); - $this->assertEquals($options['supportsCredentials'], $normalized['supportsCredentials']); - $this->assertEquals($options['exposedHeaders'], $normalized['exposedHeaders']); + $this->assertEquals($options['allowedOrigins'], $corsOptions->allowedOrigins); + $this->assertEquals($options['allowedOriginsPatterns'], $corsOptions->allowedOriginsPatterns); + $this->assertEquals(['x-custom'], $corsOptions->allowedHeaders); // lowercased + $this->assertEquals($options['allowedMethods'], $corsOptions->allowedMethods); + $this->assertEquals($options['maxAge'], $corsOptions->maxAge); + $this->assertEquals($options['supportsCredentials'], $corsOptions->supportsCredentials); + $this->assertEquals($options['exposedHeaders'], $corsOptions->exposedHeaders); } public function testCanOverwriteSetOptions(): void { $service = new Cors(['allowedOrigins' => ['example.com']]); - $normalized = $this->getOptionsFromService($service); + $corsOptions = $this->getOptionsFromContext(); - $this->assertEquals(['example.com'], $normalized['allowedOrigins']); + $this->assertEquals(['example.com'], $corsOptions->allowedOrigins); $options = [ 'allowedOrigins' => ['localhost'], @@ -101,68 +104,68 @@ public function testCanOverwriteSetOptions(): void $service->setOptions($options); - $normalized = $this->getOptionsFromService($service); + $corsOptions = $this->getOptionsFromContext(); - $this->assertEquals($options['allowedOrigins'], $normalized['allowedOrigins']); - $this->assertEquals($options['allowedOriginsPatterns'], $normalized['allowedOriginsPatterns']); - $this->assertEquals($options['allowedHeaders'], $normalized['allowedHeaders']); - $this->assertEquals($options['allowedMethods'], $normalized['allowedMethods']); - $this->assertEquals($options['maxAge'], $normalized['maxAge']); - $this->assertEquals($options['supportsCredentials'], $normalized['supportsCredentials']); - $this->assertEquals($options['exposedHeaders'], $normalized['exposedHeaders']); + $this->assertEquals($options['allowedOrigins'], $corsOptions->allowedOrigins); + $this->assertEquals($options['allowedOriginsPatterns'], $corsOptions->allowedOriginsPatterns); + $this->assertEquals(['x-custom'], $corsOptions->allowedHeaders); // lowercased + $this->assertEquals($options['allowedMethods'], $corsOptions->allowedMethods); + $this->assertEquals($options['maxAge'], $corsOptions->maxAge); + $this->assertEquals($options['supportsCredentials'], $corsOptions->supportsCredentials); + $this->assertEquals($options['exposedHeaders'], $corsOptions->exposedHeaders); } public function testCanHaveNoOptions(): void { $service = new Cors(); - $normalized = $this->getOptionsFromService($service); - - $this->assertEquals([], $normalized['allowedOrigins']); - $this->assertEquals([], $normalized['allowedOriginsPatterns']); - $this->assertEquals([], $normalized['allowedHeaders']); - $this->assertEquals([], $normalized['allowedMethods']); - $this->assertEquals([], $normalized['exposedHeaders']); - $this->assertEquals(0, $normalized['maxAge']); - $this->assertFalse($normalized['supportsCredentials']); + $corsOptions = $this->getOptionsFromContext(); + + $this->assertEquals([], $corsOptions->allowedOrigins); + $this->assertEquals([], $corsOptions->allowedOriginsPatterns); + $this->assertEquals([], $corsOptions->allowedHeaders); + $this->assertEquals([], $corsOptions->allowedMethods); + $this->assertEquals([], $corsOptions->exposedHeaders); + $this->assertEquals(0, $corsOptions->maxAge); + $this->assertFalse($corsOptions->supportsCredentials); } public function testCanHaveEmptyOptions(): void { $service = new Cors([]); - $normalized = $this->getOptionsFromService($service); - - $this->assertEquals([], $normalized['allowedOrigins']); - $this->assertEquals([], $normalized['allowedOriginsPatterns']); - $this->assertEquals([], $normalized['allowedHeaders']); - $this->assertEquals([], $normalized['allowedMethods']); - $this->assertEquals([], $normalized['exposedHeaders']); - $this->assertEquals(0, $normalized['maxAge']); - $this->assertFalse($normalized['supportsCredentials']); + $corsOptions = $this->getOptionsFromContext(); + + $this->assertEquals([], $corsOptions->allowedOrigins); + $this->assertEquals([], $corsOptions->allowedOriginsPatterns); + $this->assertEquals([], $corsOptions->allowedHeaders); + $this->assertEquals([], $corsOptions->allowedMethods); + $this->assertEquals([], $corsOptions->exposedHeaders); + $this->assertEquals(0, $corsOptions->maxAge); + $this->assertFalse($corsOptions->supportsCredentials); } public function testNormalizesFalseExposedHeaders(): void { $service = new Cors(['exposedHeaders' => false]); - $this->assertEquals([], $this->getOptionsFromService($service)['exposedHeaders']); + $this->assertEquals([], $this->getOptionsFromContext()->exposedHeaders); } public function testAllowsNullMaxAge(): void { $service = new Cors(['maxAge' => null]); - $this->assertNull($this->getOptionsFromService($service)['maxAge']); + $this->assertNull($this->getOptionsFromContext()->maxAge); } public function testAllowsZeroMaxAge(): void { $service = new Cors(['maxAge' => 0]); - $this->assertEquals(0, $this->getOptionsFromService($service)['maxAge']); + $this->assertEquals(0, $this->getOptionsFromContext()->maxAge); } public function testThrowsExceptionOnInvalidExposedHeaders(): void { $this->expectException(TypeError::class); - /** @phpstan-ignore-next-line */ + /** @phpstan-ignore argument.type */ $service = new Cors(['exposedHeaders' => true]); } @@ -170,33 +173,33 @@ public function testThrowsExceptionOnInvalidOriginsArray(): void { $this->expectException(TypeError::class); - /** @phpstan-ignore-next-line */ + /** @phpstan-ignore argument.type */ $service = new Cors(['allowedOrigins' => 'string']); } public function testNormalizesWildcardOrigins(): void { $service = new Cors(['allowedOrigins' => ['*']]); - $this->assertTrue($this->getOptionsFromService($service)['allowAllOrigins']); + $this->assertTrue($this->getOptionsFromContext()->allowAllOrigins); } public function testNormalizesWildcardHeaders(): void { $service = new Cors(['allowedHeaders' => ['*']]); - $this->assertTrue($this->getOptionsFromService($service)['allowAllHeaders']); + $this->assertTrue($this->getOptionsFromContext()->allowAllHeaders); } public function testNormalizesWildcardMethods(): void { $service = new Cors(['allowedMethods' => ['*']]); - $this->assertTrue($this->getOptionsFromService($service)['allowAllMethods']); + $this->assertTrue($this->getOptionsFromContext()->allowAllMethods); } public function testConvertsWildcardOriginPatterns(): void { $service = new Cors(['allowedOrigins' => ['*.mydomain.com']]); - $patterns = $this->getOptionsFromService($service)['allowedOriginsPatterns']; + $patterns = $this->getOptionsFromContext()->allowedOriginsPatterns; $this->assertEquals(['#^.*\.mydomain\.com\z#u'], $patterns); } @@ -213,38 +216,170 @@ public function testNormalizesUnderscoreOptions(): void ]; $service = new Cors($options); + $corsOptions = $this->getOptionsFromContext(); + + $this->assertEquals($options['allowed_origins'], $corsOptions->allowedOrigins); + $this->assertEquals($options['allowed_origins_patterns'], $corsOptions->allowedOriginsPatterns); + $this->assertEquals(['x-custom'], $corsOptions->allowedHeaders); // lowercased + $this->assertEquals($options['allowed_methods'], $corsOptions->allowedMethods); + $this->assertEquals($options['exposed_headers'], $corsOptions->exposedHeaders); + $this->assertEquals($options['max_age'], $corsOptions->maxAge); + $this->assertEquals($options['supports_credentials'], $corsOptions->supportsCredentials); + } + + public function testOptionsAreIsolatedBetweenCoroutines(): void + { + $service = new Cors(['allowedOrigins' => ['main.com']]); + + $this->assertEquals(['main.com'], $this->getOptionsFromContext()->allowedOrigins); + + // Simulate another coroutine with different options + \Hyperf\Coroutine\Coroutine::create(function () use ($service) { + // In a new coroutine, options should be empty (defaults) + $this->assertEquals([], $this->getOptionsFromContext()->allowedOrigins); + + // Set different options in this coroutine + $service->setOptions(['allowedOrigins' => ['other.com']]); + $this->assertEquals(['other.com'], $this->getOptionsFromContext()->allowedOrigins); + }); + + // Back in original coroutine, options should be unchanged + $this->assertEquals(['main.com'], $this->getOptionsFromContext()->allowedOrigins); + } + + public function testLowercasesAllowedHeaders(): void + { + $service = new Cors(['allowedHeaders' => ['X-Custom-Header', 'CONTENT-TYPE', 'Accept']]); - $this->assertEquals($options['allowed_origins'], $this->getOptionsFromService($service)['allowedOrigins']); $this->assertEquals( - $options['allowed_origins_patterns'], - $this->getOptionsFromService($service)['allowedOriginsPatterns'] + ['x-custom-header', 'content-type', 'accept'], + $this->getOptionsFromContext()->allowedHeaders ); - $this->assertEquals($options['allowed_headers'], $this->getOptionsFromService($service)['allowedHeaders']); - $this->assertEquals($options['allowed_methods'], $this->getOptionsFromService($service)['allowedMethods']); - $this->assertEquals($options['exposed_headers'], $this->getOptionsFromService($service)['exposedHeaders']); - $this->assertEquals($options['max_age'], $this->getOptionsFromService($service)['maxAge']); + } + + public function testUppercasesAllowedMethods(): void + { + $service = new Cors(['allowedMethods' => ['get', 'Post', 'DELETE']]); + $this->assertEquals( - $options['supports_credentials'], - $this->getOptionsFromService($service)['supportsCredentials'] + ['GET', 'POST', 'DELETE'], + $this->getOptionsFromContext()->allowedMethods ); } - /** - * @return CorsNormalizedOptions - */ - private function getOptionsFromService(Cors $service): array + public function testPartialOptionsUpdatePreservesExisting(): void + { + $service = new Cors([ + 'allowedOrigins' => ['example.com'], + 'allowedMethods' => ['GET', 'POST'], + 'maxAge' => 600, + ]); + + // Update only maxAge + $service->setOptions(['maxAge' => 3600]); + + $options = $this->getOptionsFromContext(); + $this->assertEquals(['example.com'], $options->allowedOrigins); + $this->assertEquals(['GET', 'POST'], $options->allowedMethods); + $this->assertEquals(3600, $options->maxAge); + } + + public function testIsOriginAllowedWithExactMatch(): void + { + $service = new Cors(['allowedOrigins' => ['https://example.com', 'https://other.com']]); + + $request = $this->createMockRequest('https://example.com'); + $this->assertTrue($service->isOriginAllowed($request)); + + $request = $this->createMockRequest('https://other.com'); + $this->assertTrue($service->isOriginAllowed($request)); + + $request = $this->createMockRequest('https://notallowed.com'); + $this->assertFalse($service->isOriginAllowed($request)); + } + + public function testIsOriginAllowedWithPatternMatch(): void { - $reflected = new ReflectionClass($service); + $service = new Cors(['allowedOrigins' => ['https://*.example.com']]); - $properties = $reflected->getProperties(ReflectionProperty::IS_PRIVATE); + $request = $this->createMockRequest('https://sub.example.com'); + $this->assertTrue($service->isOriginAllowed($request)); - $options = []; - foreach ($properties as $property) { - $property->setAccessible(true); - $options[$property->getName()] = $property->getValue($service); - } + $request = $this->createMockRequest('https://deep.sub.example.com'); + $this->assertTrue($service->isOriginAllowed($request)); - /** @var CorsNormalizedOptions $options */ - return $options; + $request = $this->createMockRequest('https://example.com'); + $this->assertFalse($service->isOriginAllowed($request)); + + $request = $this->createMockRequest('https://other.com'); + $this->assertFalse($service->isOriginAllowed($request)); + } + + public function testIsOriginAllowedWithWildcard(): void + { + $service = new Cors(['allowedOrigins' => ['*']]); + + $request = $this->createMockRequest('https://anything.com'); + $this->assertTrue($service->isOriginAllowed($request)); + } + + public function testVaryHeaderAddsNewHeader(): void + { + $service = new Cors(); + $response = new \Hyperf\HttpMessage\Server\Response(); + + $response = $service->varyHeader($response, 'Origin'); + + $this->assertEquals('Origin', $response->getHeaderLine('Vary')); + } + + public function testVaryHeaderAppendsToExisting(): void + { + $service = new Cors(); + $response = new \Hyperf\HttpMessage\Server\Response(); + $response = $response->withHeader('Vary', 'Accept-Encoding'); + + $response = $service->varyHeader($response, 'Origin'); + + $this->assertEquals('Accept-Encoding, Origin', $response->getHeaderLine('Vary')); + } + + public function testVaryHeaderDoesNotDuplicate(): void + { + $service = new Cors(); + $response = new \Hyperf\HttpMessage\Server\Response(); + $response = $response->withHeader('Vary', 'Origin'); + + $response = $service->varyHeader($response, 'Origin'); + + $this->assertEquals('Origin', $response->getHeaderLine('Vary')); + } + + /** + * Get CORS options from Context. + */ + private function getOptionsFromContext(): CorsOptions + { + return Context::get(self::CORS_CONTEXT_KEY) ?? new CorsOptions(); + } + + /** + * Create a mock request with the given Origin header. + */ + private function createMockRequest(string $origin): \Hypervel\Http\Contracts\RequestContract + { + $request = $this->createMock(\Hypervel\Http\Contracts\RequestContract::class); + $request->expects($this->any()) + ->method('header') + ->willReturnCallback(function ($name) use ($origin) { + return $name === 'Origin' ? $origin : null; + }); + $request->expects($this->any()) + ->method('hasHeader') + ->willReturnCallback(function ($name) use ($origin) { + return $name === 'Origin' && $origin !== ''; + }); + + return $request; } }