From e4c47de692c1d3c8a81d3d6504a92ad9979fc5b3 Mon Sep 17 00:00:00 2001 From: Richard Bairwell Date: Wed, 30 Dec 2015 20:21:44 +0000 Subject: [PATCH] First released version --- .gitignore | 6 + CONTRIBUTING.md | 34 ++ LICENSE.md | 7 + README.md | 63 ++ composer.json | 32 ++ phpcs.xml.dist | 186 ++++++ phpunit.xml.dist | 47 ++ src/Cors.php | 272 +++++++++ src/Cors/Exceptions/BadOrigin.php | 24 + src/Cors/Exceptions/ExceptionAbstract.php | 79 +++ src/Cors/Exceptions/HeaderNotAllowed.php | 22 + src/Cors/Exceptions/MethodNotAllowed.php | 24 + src/Cors/Exceptions/NoHeadersAllowed.php | 22 + src/Cors/Exceptions/NoMethod.php | 24 + src/Cors/Traits/Parse.php | 255 +++++++++ src/Cors/Traits/Preflight.php | 244 ++++++++ src/Cors/Traits/Validate.php | 169 ++++++ tests/Cors/Exceptions/ExceptionTest.php | 116 ++++ tests/Cors/FunctionalTests/SlimTest.php | 295 ++++++++++ tests/Cors/Traits/ParseTest.php | 645 +++++++++++++++++++++ tests/Cors/Traits/PreflightTest.php | 422 ++++++++++++++ tests/Cors/Traits/RunInvokeArrays.php | 242 ++++++++ tests/Cors/Traits/ValidateTest.php | 122 ++++ tests/CorsTest.php | 666 ++++++++++++++++++++++ 24 files changed, 4018 insertions(+) create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 composer.json create mode 100644 phpcs.xml.dist create mode 100644 phpunit.xml.dist create mode 100644 src/Cors.php create mode 100644 src/Cors/Exceptions/BadOrigin.php create mode 100644 src/Cors/Exceptions/ExceptionAbstract.php create mode 100644 src/Cors/Exceptions/HeaderNotAllowed.php create mode 100644 src/Cors/Exceptions/MethodNotAllowed.php create mode 100644 src/Cors/Exceptions/NoHeadersAllowed.php create mode 100644 src/Cors/Exceptions/NoMethod.php create mode 100644 src/Cors/Traits/Parse.php create mode 100644 src/Cors/Traits/Preflight.php create mode 100644 src/Cors/Traits/Validate.php create mode 100644 tests/Cors/Exceptions/ExceptionTest.php create mode 100644 tests/Cors/FunctionalTests/SlimTest.php create mode 100644 tests/Cors/Traits/ParseTest.php create mode 100644 tests/Cors/Traits/PreflightTest.php create mode 100644 tests/Cors/Traits/RunInvokeArrays.php create mode 100644 tests/Cors/Traits/ValidateTest.php create mode 100644 tests/CorsTest.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d82976 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/phpcs.xml +/phpunit.xml +.idea/* +/vendor/ +/build +composer.lock diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e1d6641 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,34 @@ +Contributing +------------- + +## Pull Requests + +1. Fork the repository +2. Create a new branch for each feature or improvement +3. Send a pull request from each feature branch to the **develop** branch + +It is very important to separate new features or improvements into separate feature branches, and to send a +pull request for each branch. This allows me to review and pull in new features or improvements individually. + +## Style Guide + +All pull requests must adhere to the [PSR-2 standard](http://www.php-fig.org/psr/psr-2/). + +This can be checked via, you can run the following commands to check if everything is ready to submit: + + cd cors + vendor/bin/phpcs -np + +Which should give you no output, indicating that there are no coding standard errors. And then: + + +## Unit Testing + +All pull requests must be accompanied by passing unit tests and complete code coverage. The Bairwell\Cors library uses phpunit for testing. + +[Learn about PHPUnit](https://github.com/sebastianbergmann/phpunit/) + + cd cors + vendor/bin/phpunit + +Which should give you no failures or errors. You can ignore any skipped tests as these are for external tools. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..7f7f324 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,7 @@ +Copyright (c) 2016 Bairwell Ltd. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e39b65a --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# Bairwell\Cors + +This is a PHP 7 [Composer](https://getcomposer.org/) compatible library for providing a [PSR-7]((http://www.php-fig.org/psr/psr-7/) compatible middleware layer for handling +"[CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS)" (Cross Origin Request Security/Cross-Origin Http Request/HTTP access control) headers and security. + +## What does this library provides over other CORs librarys? + +* PHP-7 type declarations. +* Works as a piece of [PSR-7]((http://www.php-fig.org/psr/psr-7/) middleware making it compatible with many frameworks (such as [Slim 3](http://slimframework.com) and [Symfony](http://symfony.com/blog/psr-7-support-in-symfony-is-here)) +* Massively flexibility over configuration settings (most can be strings, arrays or callbacks). +* Follows the [CORs flowchart](http://www.html5rocks.com/static/images/cors_server_flowchart.png) and actively rejects invalid requests. +* Only sends the appropriate headers when necessary. +* On CORs "OPTIONS" request, ensure a blank page 204 "No Content" page is returned instead of returning unwanted content bodies. +* Supports [PSR-3](http://www.php-fig.org/psr/psr-3/) based loggers for debugging purposes. +* Ignores non-CORs "OPTIONS" requests (for example, on REST services). A CORs request is indicated by the presence of the Origin: header on the inbound request. +* Fully unit tested. +* Licensed under the [MIT License](https://opensource.org/licenses/MIT) allowing you to practically do whatever you want. +* Uses namespaces and is 100% object orientated. +* Blocks invalid settings. +* Minimal third party requirements (just the definition files "psr/http-message" and "psr/log" for main, and PHPUnit, PHPCodeSniffer, SlimFramework and Monolog for development/testing). + +## Standards + +The following [PHP FIG](http://www.php-fig.org/psr/) standards should be followed: + + * [PSR 1 - Basic Coding Standard](http://www.php-fig.org/psr/psr-1/) + * [PSR 2 - Coding Style Guide](http://www.php-fig.org/psr/psr-2/) + * [PSR 3 - Logger Interface](http://www.php-fig.org/psr/psr-3/) + * [PSR 4 - Autoloading Standard](http://www.php-fig.org/psr/psr-4/) + * [PSR 5 - PHPDoc Standard](https://github.com/phpDocumentor/fig-standards/tree/master/proposed) - (still in draft) + * [PSR 7 - HTTP Message Interface](http://www.php-fig.org/psr/psr-7/) + * [PSR 12 - Extended Coding Style Guide](https://github.com/php-fig/fig-standards/blob/master/proposed/extended-coding-style-guide.md) - (still in draft) + +### Standards Checking +[PHP Code Sniffer](https://github.com/squizlabs/PHP_CodeSniffer/) highlights potential coding standards issues. + +`vendor/bin/phpcs` + +PHP CS will use the configuration in `phpcs.xml.dist` by default. + +To see which sniffs are running add "-s" + +## Unit Tests +[PHPUnit](http://phpunit.de) is installed for unit testing (tests are in `tests`) + +To run unit tests: +`vendor/bin/phpunit` + +For a list of the tests that have ran: +`vendor/bin/phpunit --tap` + +To restrict the tests run: +`vendor/bin/phpunit --filter 'Cors\\Exceptions\\BadOrigin'` + +or just + +`vendor/bin/phpunit --filter 'ExceptionTest'` + +for all tests which have "Exception" in them and: +`vendor/bin/phpunit --filter '(ExceptionTest::testEverything|ExceptionTest::testStub)'` + +to test the two testEverything and testStub methods in the ExceptionTest class. + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..0273b5f --- /dev/null +++ b/composer.json @@ -0,0 +1,32 @@ +{ + "name": "bairwell/cors", + "description": "A PSR-7 middleware layer for providing CORS (Cross Origin Request Security) headers and security provisions. Instead of just allowing invalid CORs requests to come through, this middleware actively blocks them after validating.", + "keywords": ["psr-7","middleware","cors"], + "homepage": "https://bitbucket.org/bairwell/cors", + "license": "MIT", + "authors": [ + { + "name": "Richard Bairwell", + "email": "richard@bairwell.com", + "homepage": "http://www.bairwell.com" + } + ], + "type": "library", + "require": { + "php": "^7.0", + "psr/http-message": "^1.0", + "psr/log": "~1.0" + }, + "require-dev": { + "phpunit/phpunit": "^5.1", + "squizlabs/php_codesniffer": "^2.5", + "slim/slim": "^3.0", + "monolog/monolog": "^1.13" + }, + "autoload": { + "psr-4": {"Bairwell\\": "src/"} + }, + "autoload-dev": { + "psr-4": {"Bairwell\\": "tests/"} + } +} diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..b2778f6 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,186 @@ + + + Bairwell PHP Coding standards + + src + tests + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..7ca65fb --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,47 @@ + + + + + + + + + + + tests + + + + + + src + + + + vendor + tests + + + + + + diff --git a/src/Cors.php b/src/Cors.php new file mode 100644 index 0000000..88aaf89 --- /dev/null +++ b/src/Cors.php @@ -0,0 +1,272 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +declare (strict_types = 1); + +namespace Bairwell; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Bairwell\Cors\Exceptions\BadOrigin; +use Psr\Log\LoggerInterface; + +/** + * Class Cors. + * + * A piece of PSR7 compliant middleware (in that it takes a PSR7 request and response + * fields, a "next" field and handles them appropriate) which adds appropriate CORS + * (Cross-domain Origin Request System) headers to the response to enable browsers + * to correctly enforce security. + * Supports https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS + * What should happen: + * - No "Origin" header: + * - "next" middleware called [not for us] + * - (no CORs related headers generated no matter which method is used) + * - "Origin" header called and it is NOT on our allowed origins + * - error page (bad origin) + * - "Origin" valid and this is a non-OPTIONS request + * - Add Access-Control-Allow-Origin + * - Add Access-Control-Allow-Credentials if applicable + * - Add Access-Control-Expose-Headers if applicable + * - "next" middleware called + * - "Origin" valid, OPTIONS, and NO Access-Control-Request-Method header + * - error page (invalid options request - no method provided) + * - "Origin" valid, OPTIONS, and Access-Control-Request-Method header but not allowed + * - error page (invalid options request - method not allowed) + * - "Origin" valid, OPTIONS,and ACRM header allowed, but Access-Control-Request-Headers provided but not allowed + * - error page (invalid options request - header not allowed) + * - "Origin" valid, OPTIONS, ACRM header allowed and ACRH provided+valid or not provided + * - Add Access-Control-Allow-Origin + * - Add Access-Control-Allow-Credentials if applicable + * - Add Access-Control-Allow-Methods + * - Add Access-Control-Allow-Headers + * - Add Access-Control-Max-Age (optional) + * - Add Vary:Origin if Origin is not * + */ +class Cors +{ + + use Cors\Traits\Validate, Cors\Traits\Parse, Cors\Traits\Preflight; + + /** + * The settings configuration. + * + * @var array + */ + protected $settings; + + /** + * A list of allowed settings and their parameters/types. + * + * @var array + */ + protected $allowedSettings = [ + 'exposeHeaders' => ['string', 'array', 'callable'], + 'allowMethods' => ['string', 'array', 'callable'], + 'allowHeaders' => ['string', 'array', 'callable'], + 'origin' => ['string', 'array', 'callable'], + 'maxAge' => ['int', 'callable'], + 'allowCredentials' => ['bool', 'callable'] + ]; + + /** + * The logger (if we have one set). + * + * @var LoggerInterface $logger + */ + protected $logger = null; + + /** + * Cors constructor. + * + * @param array $settings Our list of CORs related settings. + */ + public function __construct(array $settings = []) + { + $this->settings = $this->getDefaults(); + $this->setSettings($settings); + }//end __construct() + + /** + * Set the logger. + * + * @param LoggerInterface $logger Logger. + */ + public function setLogger(LoggerInterface $logger = null) + { + $this->logger = $logger; + }//end setLogger() + + /** + * Add a log string if we have a logger. + * + * @param string $string String to log. + * + * @return bool True if logged, false if no logger. + */ + protected function addLog(string $string) : bool + { + if (null !== $this->logger) { + $this->logger->debug($string, ['cors']); + return true; + } + + return false; + }//end addLog() + + + /** + * Get the default settings. + * + * @return array + */ + public function getDefaults() : array + { + // our default settings + $return = [ + 'origin' => '*', + 'exposeHeaders' => '', + 'maxAge' => 0, + 'allowCredentials' => false, + 'allowMethods' => 'GET,HEAD,PUT,POST,DELETE', + 'allowHeaders' => '', + ]; + + return $return; + }//end getDefaults() + + /** + * Get the settings. + * + * @return array + */ + public function getSettings() : array + { + return $this->settings; + }//end getSettings() + + /** + * Set the settings. + * + * @param array $settings The new settings to be merged in. + * + * @return self + */ + public function setSettings(array $settings = []) : self + { + $this->settings = array_merge($this->settings, $settings); + // loop through checking each setting + foreach ($this->allowedSettings as $name => $allowed) { + $this->validateSetting($name, $this->settings[$name], $allowed); + } + + return $this; + }//end setSettings() + + /** + * Get the allowed settings. + * + * @return array + */ + public function getAllowedSettings() : array + { + return $this->allowedSettings; + }//end getAllowedSettings() + + /** + * Invoke middleware. + * + * The __invoke call is used to allow this class to be called as a function. + * PSR7 middleware should work like this. + * + * @param ServerRequestInterface $request PSR7 request object. + * @param ResponseInterface $response PSR7 response object. + * @param callable $next Next middleware callable. + * + * @throws BadOrigin If the Origin is not set correctly. + * @return ResponseInterface PSR7 response object + */ + public function __invoke( + ServerRequestInterface $request, + ResponseInterface $response, + callable $next + ) : ResponseInterface + { + // if there is no origin header set, then this isn't a CORs related + // call and we should therefore return. + if ('' === $request->getHeaderLine('origin')) { + $this->addLog('Request does not have an origin setting'); + // return the next bit of middleware + $next = $next($request, $response); + + return $next; + } + + $this->addLog('Request has an origin setting and is being treated like a CORs request'); + // All CORs related requests should have the origin field + // and the credentials field returned (if they are applicable). + // All other fields are "request specific" (either preflight or not). + // set the Access-Control-Allow-Origin header. Uses origin configuration setting. + $origin = $this->parseOrigin($request); + // check the origin is one of the allowed ones. + if ('' === $origin) { + $exception = new BadOrigin('Bad Origin'); + $exception->setSent($request->getHeaderLine('origin')); + throw $exception; + } + + $this->addLog('Processing with origin of "'.$origin.'"'); + $headers = []; + $headers['Access-Control-Allow-Origin'] = $origin; + // sets the Access-Control-Allow-Credentials header if allowCredentials configuration setting is true. + $allow = $this->parseAllowCredentials($request); + // if allowCredentials isn't exactly true, we won't allow the header to be set + if (true === $allow) { + $this->addLog('Adding Access-Control-Allow-Credentials header'); + // set the header if true, omit if not + $headers['Access-Control-Allow-Credentials'] = 'true'; + } + + // http://www.html5rocks.com/static/images/cors_server_flowchart.png + // An "OPTIONS" request is a "Preflight" request and we should + // add all our appropriate headers. + if ('OPTIONS' === strtoupper($request->getMethod())) { + $this->addLog('Preflighting'); + $return = $this->preflight($request, $response, $headers, $origin); + $this->addLog('Returning from preflight'); + return $return; + } + + // if it was a non-OPTIONs request, just + // set the Access-Control-Expose-Headers header. Uses exposeHeaders configuration setting + $exposeHeaders = $this->parseItem('exposeHeaders', $request, false); + // this header should only be set if there is an appropriate configuration setting + if ('' !== $exposeHeaders) { + $this->addLog('Adding Access-Control-Expose-Header header'); + $headers['Access-Control-Expose-Headers'] = $exposeHeaders; + } + + foreach ($headers as $k => $v) { + $response = $response->withHeader($k, $v); + } + + $this->addLog('Calling next bit of middleware'); + // return the next bit of middleware + $next = $next($request, $response); + + return $next; + }//end __invoke() +}//end class diff --git a/src/Cors/Exceptions/BadOrigin.php b/src/Cors/Exceptions/BadOrigin.php new file mode 100644 index 0000000..175f0cb --- /dev/null +++ b/src/Cors/Exceptions/BadOrigin.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +declare (strict_types = 1); + +namespace Bairwell\Cors\Exceptions; + +/** + * CORS Exception for handling bad/invalid origins which are sent. + * + * A Bad origin is when this is a recognised CORs request, but the user's browser + * send an origin that was not recognised. If there is NO origin sent, then this + * is not classed as a CORs request. + */ +class BadOrigin extends ExceptionAbstract +{ + +}//end class diff --git a/src/Cors/Exceptions/ExceptionAbstract.php b/src/Cors/Exceptions/ExceptionAbstract.php new file mode 100644 index 0000000..99a539c --- /dev/null +++ b/src/Cors/Exceptions/ExceptionAbstract.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +declare (strict_types = 1); + +namespace Bairwell\Cors\Exceptions; + +/** + * Class CorsException. + */ +abstract class ExceptionAbstract extends \Exception +{ + /** + * The item string that was sent. + * + * @var string + */ + protected $sent = ''; + /** + * The items which are allowed. + * + * @var array + */ + protected $allowed = []; + + /** + * Store the item that was sent. + * + * @param string $sent The item that was sent that we have rejected. + * + * @return $this + */ + public function setSent(string $sent) : self + { + $this->sent = $sent; + return $this; + }//end setSent() + + + /** + * Get the item that was sent. + * + * @return string + */ + public function getSent() : string + { + return $this->sent; + }//end getSent() + + /** + * Store the items that were allowed. + * + * @param array $allowed The items that were allowed. + * + * @return $this + */ + public function setAllowed(array $allowed) : self + { + $this->allowed = $allowed; + return $this; + }//end setAllowed() + + + /** + * Get the items that were allowed. + * + * @return array + */ + public function getAllowed() : array + { + return $this->allowed; + }//end getAllowed() +}//end class diff --git a/src/Cors/Exceptions/HeaderNotAllowed.php b/src/Cors/Exceptions/HeaderNotAllowed.php new file mode 100644 index 0000000..97f7ea5 --- /dev/null +++ b/src/Cors/Exceptions/HeaderNotAllowed.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +declare (strict_types = 1); + +namespace Bairwell\Cors\Exceptions; + +/** + * CORS Exception for handling inbound requests which specify that a particular + * header will be sent, but that we do not allow that header. + */ +class HeaderNotAllowed extends ExceptionAbstract +{ + +}//end class diff --git a/src/Cors/Exceptions/MethodNotAllowed.php b/src/Cors/Exceptions/MethodNotAllowed.php new file mode 100644 index 0000000..aca10ec --- /dev/null +++ b/src/Cors/Exceptions/MethodNotAllowed.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +declare (strict_types = 1); + +namespace Bairwell\Cors\Exceptions; + +/** + * CORS Exception for handling bad/invalid methods which are sent. + * + * For example, in a preflight request the user's browser can send an + * Access-Control-Request-Method header of "DELETE" and if that is not in our + * allowed list, we raise this exception. + */ +class MethodNotAllowed extends ExceptionAbstract +{ + +}//end class diff --git a/src/Cors/Exceptions/NoHeadersAllowed.php b/src/Cors/Exceptions/NoHeadersAllowed.php new file mode 100644 index 0000000..e66f63d --- /dev/null +++ b/src/Cors/Exceptions/NoHeadersAllowed.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +declare (strict_types = 1); + +namespace Bairwell\Cors\Exceptions; + +/** + * CORS Exception for handling inbound requests which specify headers will be sent, + * but we specifically do not allow headers. + */ +class NoHeadersAllowed extends ExceptionAbstract +{ + +}//end class diff --git a/src/Cors/Exceptions/NoMethod.php b/src/Cors/Exceptions/NoMethod.php new file mode 100644 index 0000000..6dcbb71 --- /dev/null +++ b/src/Cors/Exceptions/NoMethod.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +declare (strict_types = 1); + +namespace Bairwell\Cors\Exceptions; + +/** + * CORS Exception for handling inbound requests which do not have a method specified. + * + * In CORs requests, the user's browser should be sending a Access-Control-Request-Method header. + * If one is not sent, then this exception is raised. + */ +class NoMethod extends ExceptionAbstract +{ + + +}//end class diff --git a/src/Cors/Traits/Parse.php b/src/Cors/Traits/Parse.php new file mode 100644 index 0000000..355770f --- /dev/null +++ b/src/Cors/Traits/Parse.php @@ -0,0 +1,255 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +declare (strict_types = 1); + +namespace Bairwell\Cors\Traits; + +use Psr\Http\Message\ServerRequestInterface; + +/** + * Trait Parse. + * All the CORs orientated parsing code. + */ +trait Parse +{ + + /** + * Add a log string if we have a logger. + * + * @param string $string String to log. + * + * @return bool True if logged, false if no logger. + */ + abstract protected function addLog(string $string) : bool; + + /** + * Parse an item from string/int/callable/array to an expected value. + * Generic used function for quite a few possible configuration settings. + * + * @param string $itemName Which settings item are we accessing?. + * @param ServerRequestInterface $request What is the request object? (for callables). + * @param boolean $isSingle Are we expecting a single string/int as a return value. + * + * @throws \InvalidArgumentException If the item is invalid. + * @return string + */ + protected function parseItem(string $itemName, ServerRequestInterface $request, bool $isSingle = false) : string + { + $item = $this->settings[$itemName]; + // we allow callables to be set (along with strings) so we can vary things upon requests. + if (true === is_callable($item)) { + // all callbacks are made with the request as the second parameter. + $item = call_user_func($item, $request); + } + + // if it is a boolean, we may as well return. + if (false === $item || null === $item) { + return ''; + } elseif (true === $item) { + throw new \InvalidArgumentException('Cannot have true as a setting for '.$itemName); + } + + // if it is a string, convert it into an array based on position of commas - but trim excess white spaces. + if (true === is_string($item)) { + $item = array_map('trim', explode(',', (string) $item)); + } + + // are we expecting a single item to be returned? + if (true === $isSingle) { + // if we are, and it is an int, return it as a string for type casting + if (true === is_int($item)) { + return (string) $item; + } elseif (count($item) === 1) { + // if we have a single item, return it. + return (string) $item[0]; + } else { + throw new \InvalidArgumentException('Only expected a single string, int or bool'); + } + } + + // we always want to work on arrays + // explode the string and trim it. + if (false === is_array($item)) { + $item = array_map('trim', explode(',', (string) $item)); + } + + // if it is an array, we want to return a comma space separated list + $item = implode(', ', $item); + + // return the string setting. + return $item; + }//end parseItem() + + /** + * Parse the allow credentials setting. + * + * @param ServerRequestInterface $request What is the request object? (for callables). + * + * @throws \InvalidArgumentException If the item is missing from settings or is invalid. + * @return boolean + */ + protected function parseAllowCredentials(ServerRequestInterface $request) : bool + { + // read in the current setting + $item = $this->settings['allowCredentials']; + // we allow callables to be set (along with strings) so we can vary things upon requests. + if (true === is_callable($item)) { + // all callbacks are made with the request as the second parameter. + $item = call_user_func($item, $request); + } + + // if the credentials are still not a boolean, abort. + if (false === is_bool($item)) { + throw new \InvalidArgumentException('allowCredentials should be a boolean value'); + } + + // return the boolean credentials setting + return $item; + }//end parseAllowCredentials() + + /** + * Parse the maxAge setting. + * + * @param ServerRequestInterface $request What is the request object? (for callables). + * + * @throws \InvalidArgumentException If the item is missing from settings or is invalid. + * @return integer + */ + protected function parseMaxAge(ServerRequestInterface $request) : int + { + $item = $this->settings['maxAge']; + // we allow callables to be set (along with strings) so we can vary things upon requests. + if (true === is_callable($item)) { + // all callbacks are made with the request as the second parameter. + $item = call_user_func($item, $request); + } + + // maxAge needs to be an int - if it isn't, throw an exception. + if (false === is_int($item)) { + throw new \InvalidArgumentException('maxAge should be an int value'); + } + + // if it is less than zero, reject it as "faulty" + if ($item < 0) { + throw new \InvalidArgumentException('maxAge should be 0 or more'); + } + + // return our integer maximum age to cache. + return $item; + }//end parseMaxAge() + + /** + * Parse the origin setting using wildcards where necessary. + * Can return * for "all hosts", '' for "no origin/do not allow" or a string/hostname. + * + * @param ServerRequestInterface $request The server request with the origin header. + * + * @return string + */ + protected function parseOrigin(ServerRequestInterface $request) : string + { + // read the client provided origin header + $origin = $request->getHeaderLine('origin'); + // if it isn't a string or is empty, the return as we will not have a matching + // origin setting. + if (false === is_string($origin) || '' === $origin) { + $this->addLog('Origin is empty or is not a string'); + return ''; + } + + $this->addLog('Processing origin of "'.$origin.'"'); + // lowercase the user provided origin for comparison purposes. + $origin = strtolower($origin); + // read the current origin setting + $originSetting = $this->settings['origin']; + + // see if this is a callback + if (true === is_callable($originSetting)) { + // all callbacks are made with the request as the second parameter. + $this->addLog('Origin server request is being passed to callback'); + $originSetting = call_user_func($originSetting, $request); + } + + // set a dummy "matched with" setting + $matched = ''; + // if it is an array (either set via configuration or returned via the call + // back), look through them. + if (true === is_array($originSetting)) { + $this->addLog('Iterating through Origin array'); + foreach ($originSetting as $item) { + // see if the origin matches (the parseOriginMatch function supports + // wildcards) + $matched = $this->parseOriginMatch($item, $origin); + // if anything else but '' was returned, then we have a valid match. + if ('' !== $matched) { + $this->addLog('Iterator found a matched origin of '.$matched); + return $matched; + } + } + } + + // if we've got this far, than nothing so far has matched, our last attempt + // is to try to match it as a string (if applicable) + if ('' === $matched && true === is_string($originSetting)) { + $this->addLog('Attempting to match origin as string'); + $matched = $this->parseOriginMatch($originSetting, $origin); + } + + // return the matched setting (may be '' to indicate nothing matched) + return $matched; + }//end parseOrigin() + + /** + * Check to see if an origin string matches an item (wildcarded or not). + * + * @param string $item The string (possible * wildcarded) to compare against. + * @param string $origin The origin to check. + * + * @return string The matching origin (can be *) or '' for empty/not matched + */ + protected function parseOriginMatch(string $item, string $origin) :string + { + $this->addLog('Checking configuration origin of "'.$item.'" against user "'.$origin.'"'); + if ('' === $item || '*' === $item) { + $this->addLog('Origin is either an empty string or wildcarded star. Returning '.$item); + return $item; + } + + // host names are case insensitive, so lower case it. + $item = strtolower($item); + // if the item does NOT contain a star, make a straight comparison + if (false === strpos($item, '*')) { + if ($item === $origin) { + // if we have a match, then return. + $this->addLog('Origin is an exact case insensitive match'); + return $origin; + } + } else { + // item contains one or more stars/wildcards + // ensure we have no preg characters in the item + $quoted = preg_quote($item, '/'); + // replace the preg_quote escaped star with .* + $quoted = str_replace('\*', '.*', $quoted); + // see if we have a preg_match, and, if we do, return it. + if (1 === preg_match('/^'.$quoted.'$/', $origin)) { + $this->addLog('Wildcarded origin match with '.$origin); + return $origin; + } + } + + // if nothing is matched, then return an empty string. + $this->addLog('Unable to match "'.$item.'" against user "'.$origin.'"'); + return ''; + }//end parseOriginMatch() +} diff --git a/src/Cors/Traits/Preflight.php b/src/Cors/Traits/Preflight.php new file mode 100644 index 0000000..41331e5 --- /dev/null +++ b/src/Cors/Traits/Preflight.php @@ -0,0 +1,244 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +declare (strict_types = 1); + +namespace Bairwell\Cors\Traits; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Bairwell\Cors\Exceptions\NoMethod; +use Bairwell\Cors\Exceptions\MethodNotAllowed; +use Bairwell\Cors\Exceptions\NoHeadersAllowed; +use Bairwell\Cors\Exceptions\HeaderNotAllowed; + +/** + * Trait Preflight. + * All the CORs orientated preflight code. + */ +trait Preflight +{ + + /** + * Handle the preflight requests for the access control headers. + * + * Logic is: + * - Read in our list of allowed methods, if there aren't any, throw an exception + * as that means a bad configuration setting has snuck in. + * - If the client has not provided an Access-Control-Request-Method, then block + * the request by throwing "Invalid options request - no method provided", + * - If the client provided method is not in our list of allowed methods, then block the + * request by throwing "Invalid options request - method not allowed: only ..." + * - If the client provided an allowed method, then list all the allowed methods on the + * header Access-Control-Allow-Methods and return the response object (which should not have + * been modified). + * + * @param ServerRequestInterface $request The server request information. + * @param ResponseInterface $response The response handler (should be filled in at end or on error). + * @param array $headers The headers we have already created. + * + * @throws \DomainException If there are no configured allowed methods. + * @throws NoMethod If no method was provided by the user. + * @throws MethodNotAllowed If the method provided by the user is not allowed. + * @return ResponseInterface + */ + protected function preflightAccessControlAllowMethods( + ServerRequestInterface $request, + ResponseInterface $response, + array &$headers + ) : ResponseInterface + { + // check the allow methods + $allowMethods = $this->parseItem('allowMethods', $request, false); + if ('' === $allowMethods) { + // if no methods are allowed, error + $exception = new \DomainException('No methods configured to be allowed for request'); + throw $exception; + } + + // explode the allowed methods to trimmed arrays + $methods = array_map('trim', explode(',', strtoupper((string) $allowMethods))); + + // check they have provided a method + if ('' === $request->getHeaderLine('access-control-request-method')) { + // if no methods provided, block it. + $exception = new NoMethod('No method provided'); + throw $exception; + } + + // check the requested method is one of our allowed ones. Uppercase it. + $requestedMethod = strtoupper($request->getHeaderLine('access-control-request-method')); + // can we find the requested method (we are presuming they are only supplying one as per + // the CORS specification) in our list of headers. + if (false === in_array($requestedMethod, $methods)) { + // no match, throw it. + $exception = new MethodNotAllowed('Method not allowed'); + $exception->setSent($requestedMethod) + ->setAllowed($methods); + throw $exception; + } + + // if we've got this far, then our access-control-request-method header has been + // validated so we should add our own outbound header. + $headers['Access-Control-Allow-Methods'] = $allowMethods; + + // return the response object + return $response; + }//end preflightAccessControlAllowMethods() + + /** + * Handle the preflight requests for the access control headers. + * Logic is: + * - If there are headers configured, but they client hasn't said they are sending them, just + * add the list to the Access-Control-Allow-Headers header and return the response + * (which should not have been modified). + * - If there are no headers configured, but they client has said they are sending some, then + * call our blockedCallback with the "Invalid options request - header not allowed: (none)" + * message, empty any previously set headers (for security) and return the response object + * (which may have been modified by the blockedCallback). + * - If there are headers configured and the client has said they are sending them, go through + * each of the headers provided by the client matching up to our allow list. If we encounter + * one that is not on our allow list, call our blockedCallback with the + * "Invalid options request - header not allowed: only ..." message listing the allowed + * headers, empty any previously set headers (for security) and return the response object + * (which may have been modified by the blockedCallback). + * - If there are provided headers, and they all match our allow list (we may allow more + * headers than requested), then add the complete list to the Access-Control-Allow-Headers + * header and return the response (which should not have been modified). + * + * @param ServerRequestInterface $request The server request information. + * @param ResponseInterface $response The response handler (should be filled in at end or on error). + * @param array $headers The headers we have already created. + * + * @throws NoHeadersAllowed If headers are not allowed. + * @throws HeaderNotAllowed If a particular header is not allowed. + * @return ResponseInterface + */ + protected function preflightAccessControlRequestHeaders( + ServerRequestInterface $request, + ResponseInterface $response, + array &$headers + ): ResponseInterface + { + // allow headers + $allowHeaders = $this->parseItem('allowHeaders', $request, false); + $requestHeaders = $request->getHeaderLine('access-control-request-headers'); + $originalRequestHeaders = $requestHeaders; + // they aren't requesting any headers, but let's send them our list + if ('' === $requestHeaders) { + $headers['Access-Control-Allow-Headers'] = $allowHeaders; + + // return the response + return $response; + } + + // at this point, they are requesting headers, however, we have no headers configured. + if ('' === $allowHeaders) { + // no headers configured, so let's block it. + $exception = new NoHeadersAllowed('No headers are allowed'); + $exception->setSent($requestHeaders); + throw $exception; + } + + // now parse the headers for comparison + // change the string into an array (separated by ,) and trim spaces + $requestHeaders = array_map('trim', explode(',', strtolower((string) $requestHeaders))); + // and do the same with our allowed headers + $allowedHeaders = array_map('trim', explode(',', strtolower((string) $allowHeaders))); + // loop through each of their provided headers + foreach ($requestHeaders as $header) { + // if we are unable to find a match for the header, block it for security. + if (false === in_array($header, $allowedHeaders)) { + // block it + $exception = new HeaderNotAllowed('Header not allowed'); + $exception->setAllowed($allowedHeaders) + ->setSent($originalRequestHeaders); + throw $exception; + } + } + + // if we've got this far, then our access-control-request-headers header has been + // validated so we should add our own outbound header. + $headers['Access-Control-Allow-Headers'] = $allowHeaders; + + // return the response + return $response; + }//end preflightAccessControlRequestHeaders() + + /** + * Handle the preflight requests. + * Logic is: + * - Receive a list of previously set headers from calling method (which should + * include the Origin: and any credentials related headers) + * - is the access-control-request-method/allowMethods valid (validated via a separate + * method preflightAccessControlAllowMethods). If it has emptied the headers, then it + * was invalid and we should just return the response (as it probably contains an error + * page) and do not set any headers: otherwise it should have added its own header. + * - is the access-control-request-headers/allowHeaders valid (validated via a separate + * method preflightAccessControlRequestHeaders). If it has emptied the headers, then it + * was invalid and we should just return the response (as it probably contains an error + * page) and do not set any headers: otherwise it should have added its own header. + * - If there is a maxAge configuration setting, add that as the Access-Control-Max-Age + * header + * - Add all the set headers to the response object. + * - If the origin is not "*", add a Vary: line to indicate the response may change if + * the origin is difference. + * + * @param ServerRequestInterface $request The server request information. + * @param ResponseInterface $response The response handler (should be filled in at end or on error). + * @param array $headers The headers we have already created. + * @param string $origin The origin setting we have decided upon. + * + * @return ResponseInterface + */ + private function preflight( + ServerRequestInterface $request, + ResponseInterface $response, + array $headers, + string $origin + ) : ResponseInterface + { + // preflight the access control allow methods + $response = $this->preflightAccessControlAllowMethods($request, $response, $headers); + + // preflight the access control request headers + $response = $this->preflightAccessControlRequestHeaders($request, $response, $headers); + + // set the Access-Control-Max-Age header. Uses maxAge configuration setting. + $maxAge = $this->parseItem('maxAge', $request, true); + // this header should only be set if there is an appropriate configuration setting + if ($maxAge > 0) { + $headers['Access-Control-Max-Age'] = (int) $maxAge; + } + + // add all of our set headers + foreach ($headers as $k => $v) { + $response = $response->withHeader($k, $v); + } + + // if the origin is configured and is not * (wildcard), indicate to the client and + // associated proxy servers that the response may vary based on the Origin setting + // that was provided. + if ('*' !== $origin) { + $response = $response->withAddedHeader('Vary', 'Origin'); + } + + // remove headers and set as no-content + $response = $response->withStatus(204, 'No Content') + ->withoutHeader('Content-Type') + ->withoutHeader('Content-Length'); + + // return the response + return $response; + }//end preflight() +} diff --git a/src/Cors/Traits/Validate.php b/src/Cors/Traits/Validate.php new file mode 100644 index 0000000..652a4f8 --- /dev/null +++ b/src/Cors/Traits/Validate.php @@ -0,0 +1,169 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +declare (strict_types = 1); + +namespace Bairwell\Cors\Traits; + +/** + * Trait Validate. + * + * All the CORs orientated validation code. + */ +trait Validate +{ + + /** + * Validate a setting. + * + * @param string $name The name of the setting we are validating. + * @param mixed $value The value we are validating. + * @param array $allowed Which items are allowed. + * + * @throws \InvalidArgumentException If the data is incorrect. + */ + protected function validateSetting(string $name, $value, array $allowed) + { + if ((true === $this->validateSettingString($name, $value, $allowed)) + || (true === $this->validateSettingArray($name, $value, $allowed)) + || (true === $this->validateSettingCallable($name, $value, $allowed)) + || (true === $this->validateSettingInt($name, $value, $allowed)) + || (true === $this->validateSettingBool($name, $value, $allowed)) + ) { + return; + } + + throw new \InvalidArgumentException( + 'Unable to validate settings for '.$name.': allowed types: '.implode(', ', $allowed) + ); + }//end validateSetting() + + /** + * Validates an bool setting. + * + * @param string $name The name of the setting we are validating. + * @param mixed $value The value we are validating. + * @param array $allowed Which items are allowed. + * + * @throws \InvalidArgumentException If the data is inaccurate/incorrect. + * @return bool True if validated, false if not + */ + protected function validateSettingBool(string $name, $value, array $allowed) : bool + { + if (true === in_array('bool', $allowed)) { + if (true === is_bool($value)) { + return true; + } + } + + return false; + }//end validateSettingBool() + + /** + * Validates an string setting. + * + * @param string $name The name of the setting we are validating. + * @param mixed $value The value we are validating. + * @param array $allowed Which items are allowed. + * + * @throws \InvalidArgumentException If the data is inaccurate/incorrect. + * @return bool True if validated, false if not + */ + protected function validateSettingString(string $name, $value, array $allowed) : bool + { + if (true === in_array('string', $allowed)) { + if (true === is_string($value)) { + return true; + } + } + + return false; + }//end validateSettingString() + + /** + * Validates an callable setting. + * + * @param string $name The name of the setting we are validating. + * @param mixed $value The value we are validating. + * @param array $allowed Which items are allowed. + * + * @throws \InvalidArgumentException If the data is inaccurate/incorrect. + * @return bool True if validated, false if not + */ + protected function validateSettingCallable(string $name, $value, array $allowed) : bool + { + if (true === in_array('callable', $allowed)) { + if (true === is_callable($value)) { + return true; + } + } + + return false; + }//end validateSettingCallable() + + /** + * Validates an int setting. + * + * @param string $name The name of the setting we are validating. + * @param mixed $value The value we are validating. + * @param array $allowed Which items are allowed. + * + * @throws \InvalidArgumentException If the data is inaccurate/incorrect. + * @return bool True if validated, false if not + */ + protected function validateSettingInt(string $name, $value, array $allowed) : bool + { + if (true === in_array('int', $allowed)) { + if (true === is_int($value)) { + if ($value >= 0) { + return true; + } else { + throw new \InvalidArgumentException('Int value for '.$name.' is too low'); + } + } + } + + return false; + }//end validateSettingInt() + + /** + * Validates an array setting. + * + * @param string $name The name of the setting we are validating. + * @param mixed $value The value we are validating. + * @param array $allowed Which items are allowed. + * + * @throws \InvalidArgumentException If the data is inaccurate/incorrect. + * @return bool True if validated, false if not + */ + protected function validateSettingArray(string $name, $value, array $allowed) : bool + { + if (true === in_array('array', $allowed)) { + if (true === is_array($value)) { + if (true === empty($value)) { + throw new \InvalidArgumentException('Array for '.$name.' is empty'); + } else { + foreach ($value as $line) { + if (false === is_string($line)) { + throw new \InvalidArgumentException('Array for '.$name.' contains a non-string item'); + } + } + + return true; + } + } + } + + return false; + }//end validateSettingArray() +} diff --git a/tests/Cors/Exceptions/ExceptionTest.php b/tests/Cors/Exceptions/ExceptionTest.php new file mode 100644 index 0000000..812cdc7 --- /dev/null +++ b/tests/Cors/Exceptions/ExceptionTest.php @@ -0,0 +1,116 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +declare (strict_types = 1); + +namespace Bairwell\Cors\Exceptions; + +/** + * Class ExceptionsTest. + */ +class ExceptionsTest extends \PHPUnit_Framework_TestCase +{ + + /** + * Bad Origin. + * + * @test + * @covers \Bairwell\Cors\Exceptions\BadOrigin + * @covers \Bairwell\Cors\Exceptions\ExceptionAbstract + */ + public function testBadOrigin() { + $sut=new BadOrigin(); + $this->assertInstanceOf('\Bairwell\Cors\Exceptions\BadOrigin',$sut); + $this->assertInstanceOf('\Bairwell\Cors\Exceptions\ExceptionAbstract',$sut); + $this->basicChecks($sut); + } + + /** + * Header not allowed. + * + * @test + * @covers \Bairwell\Cors\Exceptions\HeaderNotAllowed + * @covers \Bairwell\Cors\Exceptions\ExceptionAbstract + */ + public function testHeaderNotAllowed() { + $sut=new HeaderNotAllowed(); + $this->assertInstanceOf('\Bairwell\Cors\Exceptions\HeaderNotAllowed',$sut); + $this->assertInstanceOf('\Bairwell\Cors\Exceptions\ExceptionAbstract',$sut); + $this->basicChecks($sut); + } + + /** + * Method not allowed. + * + * @test + * @covers \Bairwell\Cors\Exceptions\MethodNotAllowed + * @covers \Bairwell\Cors\Exceptions\ExceptionAbstract + */ + public function testMethodNotAllowed() { + $sut=new MethodNotAllowed(); + $this->assertInstanceOf('\Bairwell\Cors\Exceptions\MethodNotAllowed',$sut); + $this->assertInstanceOf('\Bairwell\Cors\Exceptions\ExceptionAbstract',$sut); + $this->basicChecks($sut); + } + /** + * No Headers Allowed. + * + * @test + * @covers \Bairwell\Cors\Exceptions\MethodNotAllowed + * @covers \Bairwell\Cors\Exceptions\ExceptionAbstract + */ + public function testNoHeadersAllowed() { + $sut=new NoHeadersAllowed(); + $this->assertInstanceOf('\Bairwell\Cors\Exceptions\NoHeadersAllowed',$sut); + $this->assertInstanceOf('\Bairwell\Cors\Exceptions\ExceptionAbstract',$sut); + $this->basicChecks($sut); + } + /** + * No Method. + * + * @test + * @covers \Bairwell\Cors\Exceptions\MethodNotAllowed + * @covers \Bairwell\Cors\Exceptions\ExceptionAbstract + */ + public function testNoMethod() { + $sut=new NoMethod(); + $this->assertInstanceOf('\Bairwell\Cors\Exceptions\NoMethod',$sut); + $this->assertInstanceOf('\Bairwell\Cors\Exceptions\ExceptionAbstract',$sut); + $this->basicChecks($sut); + } + + /** + * Basic checks of methods provided by the ExceptionAbstract. + * + * @param ExceptionAbstract $sut The exception we are testing. + */ + protected function basicChecks(ExceptionAbstract $sut) { + $this->assertInternalType('string',$sut->getSent()); + $this->assertInternalType('array',$sut->getAllowed()); + $this->assertEmpty($sut->getSent()); + $this->assertEmpty($sut->getAllowed()); + // now try setting the sent + $this->assertSame($sut,$sut->setSent('test 123')); + $this->assertInternalType('string',$sut->getSent()); + $this->assertEquals('test 123',$sut->getSent()); + // not try setting the allowed + $this->assertSame($sut,$sut->setAllowed(['thing','goes','boom',123])); + $this->assertInternalType('array',$sut->getAllowed()); + $this->assertEquals(['thing','goes','boom',123],$sut->getAllowed()); + // ensure sent was not changed + $this->assertInternalType('string',$sut->getSent()); + $this->assertEquals('test 123',$sut->getSent()); + // ensure allowed is not changed + $this->assertSame($sut,$sut->setSent('jeff')); + $this->assertInternalType('array',$sut->getAllowed()); + $this->assertEquals(['thing','goes','boom',123],$sut->getAllowed()); + } +} diff --git a/tests/Cors/FunctionalTests/SlimTest.php b/tests/Cors/FunctionalTests/SlimTest.php new file mode 100644 index 0000000..33159d6 --- /dev/null +++ b/tests/Cors/FunctionalTests/SlimTest.php @@ -0,0 +1,295 @@ +testLogger->getRecords(); + $return=[]; + foreach ($records as $record) { + $return[]=$record['message']; + } + return $return; + } + /** + * Get the CORs system integrated with Slim and FastRoute. + * + * @param ContainerInterface $container The Slim Container. + * + * @return Cors + */ + protected function getCors(ContainerInterface $container) : Cors { + // set our allowed methods callback to integrate with Slim + $corsAllowedMethods = function (ServerRequestInterface $request) use ($container) : array { + // if this closure is called, make sure it has the route available in the container. + /* @var \Slim\Interfaces\RouterInterface $router */ + $router = $container->get('router'); + + $routeInfo = $router->dispatch($request); + $methods = []; + // was the method called allowed? + if ($routeInfo[0] === Dispatcher::METHOD_NOT_ALLOWED) { + $methods = $routeInfo[1]; + } else { + // if it was, see if we can get the routes and then the methods from it. + // @var \Slim\Route $route + $route = $request->getAttribute('route'); + // has the request get a route defined? is so use that + if (null !== $route) { + $methods = $route->getMethods(); + } + } + + // if we have methods, let's list them removing the OPTIONs one. + if (false === empty($methods)) { + // find the OPTIONs method + $key = array_search('OPTIONS', $methods); + // and remove it if set. + if (false !== $key) { + unset($methods[$key]); + $methods = array_values($methods); + } + } + + return $methods; + }; + // setup CORs + $cors = new Cors( + [ + 'origin' => $this->allowedHosts, + 'exposeHeaders' => '', + 'maxAge' => 60, + 'allowCredentials' => true, // we want to allow credentials + 'allowMethods' => $corsAllowedMethods, + 'allowHeaders' => ['Accept-Language', 'Authorization', 'Content-type'], + ] + ); + // setup the logger + $this->logger = new Logger('test'); + $this->testLogger = new TestHandler(); + $this->logger->pushHandler($this->testLogger); + $cors->setLogger($this->logger); + return $cors; + } + + protected function getSlimTest(string $method, + string $url,array $headers=[]) : ResponseInterface { + $slim=new \Slim\App(['settings'=>['displayErrorDetails' => true]]); + + // add the CORs middleware + $cors=$this->getCors($slim->getContainer()); + $slim->add($cors); + // finish adding the CORs middleware + + // add our own error handler. + $errorHandler=function (ContainerInterface $container) : callable { + $handler=function (ServerRequestInterface $request,ResponseInterface $response,\Exception $e) : ResponseInterface { + $body = new Body(fopen('php://temp', 'r+')); + $body->write('Error Handler caught exception type '.get_class($e).': '.$e->getMessage()); + $return=$response + ->withStatus(500) + ->withBody($body); + return $return; + }; + return $handler; + }; + // add the error handler. + $slim->getContainer()['errorHandler']=$errorHandler; + + // add dummy routes + $slim->get('/foo',function (Request $req,Response $res) { $res->write('getted hi');return $res; }); + $slim->post('/foo',function (Request $req,Response $res) { $res->write('postted hi');return $res; }); + + // Prepare request and response objects + $uri = Uri::createFromString($url); + $slimHeaders=new Headers($headers); + $body = new RequestBody(); + $request = new Request($method, $uri, $slimHeaders,[], [], $body); + $response=new Response(); + // override the Slim request and responses with our dummies + $slim->getContainer()['request']=$request; + $slim->getContainer()['response']=$response; + + // invoke Slim + /* @var \Slim\Http\Response $result */ + $result=$slim->run(true); + + // check we got back what we expected + $this->assertInstanceOf('\Psr\Http\Message\ResponseInterface', $result); + $this->assertInstanceOf('\Slim\Http\Response',$result); + return $result; + } + + /** + * Test that Slim works as expected. + * + * No origin is passed so our middleware should not be invoked. + * + * @test + */ + public function testBasicSlim() { + $result=$this->getSlimTest('POST','http://localhost/foo'); + $this->assertEquals(200,$result->getStatusCode()); + $this->assertEquals('OK',$result->getReasonPhrase()); + $headers=$result->getHeaders(); + $this->assertArrayNotHasKey('Access-Control-Allow-Origin',$headers); + $this->assertArrayNotHasKey('Access-Control-Allow-Credentials',$headers); + $this->assertArrayNotHasKey('Access-Control-Expose-Headers',$headers); + $this->assertArrayNotHasKey('Access-Control-Allow-Headers',$headers); + $this->assertArrayNotHasKey('Access-Control-Max-Age',$headers); + $this->assertEquals('',$result->getHeaderLine('content-type')); + $this->assertEquals(10,$result->getBody()->getSize()); + $body=$result->getBody(); + $body->rewind(); + $contents=$body->getContents(); + $this->assertEquals('postted hi',$contents); + // check a 404 request + $result=$this->getSlimTest('GET','http://localhost/XYZ'); + $this->assertEquals(404,$result->getStatusCode()); + $this->assertEquals('Not Found',$result->getReasonPhrase()); + $headers=$result->getHeaders(); + $this->assertArrayNotHasKey('Access-Control-Allow-Origin',$headers); + $this->assertArrayNotHasKey('Access-Control-Allow-Credentials',$headers); + $this->assertArrayNotHasKey('Access-Control-Expose-Headers',$headers); + $this->assertArrayNotHasKey('Access-Control-Allow-Headers',$headers); + $this->assertArrayNotHasKey('Access-Control-Max-Age',$headers); + $body=$result->getBody(); + $body->rewind(); + $contents=$body->getContents(); + $this->assertRegExp('/Page Not Found/',$contents); + // check a 405 request + $result=$this->getSlimTest('DELETE','http://localhost/foo'); + $this->assertEquals(405,$result->getStatusCode()); + $this->assertEquals('Method Not Allowed',$result->getReasonPhrase()); + $headers=$result->getHeaders(); + $this->assertArrayNotHasKey('Access-Control-Allow-Origin',$headers); + $this->assertArrayNotHasKey('Access-Control-Allow-Credentials',$headers); + $this->assertArrayNotHasKey('Access-Control-Expose-Headers',$headers); + $this->assertArrayNotHasKey('Access-Control-Allow-Headers',$headers); + $this->assertArrayNotHasKey('Access-Control-Max-Age',$headers); + $body=$result->getBody(); + $body->rewind(); + $contents=$body->getContents(); + $this->assertRegExp('/Method not allowed/',$contents); + } + + /** + * Test the middleware works with a basic get request. + * + * We pass in an Origin header. We should get back the CORs headers and the body content. + * + * @test + */ + public function testGetWithOrigin() { + $result=$this->getSlimTest('GET','http://localhost/foo',['Origin'=>'test.example.com']); + $this->assertEquals(200,$result->getStatusCode()); + $this->assertEquals('OK',$result->getReasonPhrase()); + $headers=$result->getHeaders(); + $this->assertArrayHasKey('Access-Control-Allow-Origin',$headers); + $this->assertEquals('test.example.com',$result->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertArrayHasKey('Access-Control-Allow-Credentials',$headers); + $this->assertEquals('true',$result->getHeaderLine('Access-Control-Allow-Credentials')); + $this->assertArrayNotHasKey('Access-Control-Expose-Headers',$headers); + $this->assertArrayNotHasKey('Access-Control-Allow-Headers',$headers); + $this->assertArrayNotHasKey('Access-Control-Max-Age',$headers); + $this->assertEquals('',$result->getHeaderLine('content-type')); + $this->assertEquals(9,$result->getBody()->getSize()); + $body=$result->getBody(); + $body->rewind(); + $contents=$body->getContents(); + $this->assertEquals('getted hi',$contents); + } + /** + * Test the middleware works with a basic OPTIONS request. + * + * We pass in an Origin header - but we do not pass in a Access-Control-Request-Method. + * This should raise a "NoMethod" exception which should be caught by the error handler. + * + * @test + */ + public function testOptionsWithOrigin() { + $result=$this->getSlimTest('OPTIONS','http://localhost/foo',['Origin'=>'test.example.com']); + $body=$result->getBody(); + $body->rewind(); + $contents=$body->getContents(); + $this->assertEquals('Error Handler caught exception type Bairwell\Cors\Exceptions\NoMethod: No method provided',$contents); + $this->assertEquals(500,$result->getStatusCode()); + $this->assertEquals('Internal Server Error',$result->getReasonPhrase()); + $headers=$result->getHeaders(); + $this->assertArrayNotHasKey('Access-Control-Allow-Origin',$headers); + $this->assertArrayNotHasKey('Access-Control-Allow-Credentials',$headers); + $this->assertArrayNotHasKey('Access-Control-Expose-Headers',$headers); + $this->assertArrayNotHasKey('Access-Control-Allow-Headers',$headers); + $this->assertArrayNotHasKey('Access-Control-Max-Age',$headers); + + } + /** + * Test the middleware works with a basic OPTIONS request. + * + * We pass in an Origin and Access-Control-Request. We should get back the CORs headers only. + * + * @test + */ + public function testOptionsWithOriginAndMethod() { + $result=$this->getSlimTest('OPTIONS','http://localhost/foo',['Origin'=>'test.example.com','Access-Control-Request-Method'=>'POST']); + $body=$result->getBody(); + $body->rewind(); + $contents=$body->getContents(); + $this->assertEquals(0,$result->getBody()->getSize()); + $this->assertEquals('',$contents); + $this->assertEquals(204,$result->getStatusCode()); + $this->assertEquals('No Content',$result->getReasonPhrase()); + $headers=$result->getHeaders(); + $this->assertArrayHasKey('Access-Control-Allow-Origin',$headers); + $this->assertEquals('test.example.com',$result->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertArrayHasKey('Access-Control-Allow-Credentials',$headers); + $this->assertEquals('true',$result->getHeaderLine('Access-Control-Allow-Credentials')); + $this->assertArrayNotHasKey('Access-Control-Expose-Headers',$headers); + $this->assertArrayHasKey('Access-Control-Allow-Headers',$headers); + $this->assertEquals('Accept-Language, Authorization, Content-type',$result->getHeaderLine('Access-Control-Allow-Headers')); + $this->assertArrayHasKey('Access-Control-Max-Age',$headers); + $this->assertEquals(60,$result->getHeaderLine('Access-Control-Max-Age')); + } + +} diff --git a/tests/Cors/Traits/ParseTest.php b/tests/Cors/Traits/ParseTest.php new file mode 100644 index 0000000..609cebb --- /dev/null +++ b/tests/Cors/Traits/ParseTest.php @@ -0,0 +1,645 @@ +parseItem(['fred', 'george', 'jane'], 'fred, george, jane', false); + // now only allow one. + $this->parseItem(['hello'], 'hello', true); + try { + $this->parseItem(['hello', 'my', 'honey'], 'hello,my,honey', true); + $this->fail('Should have blocked'); + } catch (\InvalidArgumentException $e) { + $this->assertSame('Only expected a single string, int or bool', $e->getMessage()); + } catch (\Exception $e) { + $this->fail('Unexpected exception: '.$e->getMessage()); + } + }//end testParseItemArrays() + + /** + * Test the parse item section - strings + * Uses reflection as this is a protected method. + * + * @test + * @covers \Bairwell\Cors\Traits\Parse::parseItem + */ + public function testParseItemStrings() + { + // first allow multiple requests + $this->parseItem('hello, my, honey', 'hello, my, honey', false); + $this->parseItem('hello, my,honey', 'hello, my, honey', false); + $this->parseItem('rain,bow,climbing', 'rain, bow, climbing', false); + // now only allow one. + $this->parseItem('hello', 'hello', true); + try { + $this->parseItem('hello,my,honey', 'hello,my,honey', true); + $this->fail('Should have blocked'); + } catch (\InvalidArgumentException $e) { + $this->assertSame('Only expected a single string, int or bool', $e->getMessage()); + } catch (\Exception $e) { + $this->fail('Unexpected exception: '.$e->getMessage()); + } + }//end testParseItemStrings() + + /** + * Test the parse item section - bools + * Uses reflection as this is a protected method. + * + * @test + * @covers \Bairwell\Cors\Traits\Parse::parseItem + */ + public function testParseItemBools() + { + // first allow multiple requests + $this->parseItem(false, '', false); + try { + $this->parseItem(true, '', false); + $this->fail('Should have blocked'); + } catch (\InvalidArgumentException $e) { + $this->assertSame('Cannot have true as a setting for testItem', $e->getMessage()); + } catch (\Exception $e) { + $this->fail('Unexpected exception: '.$e->getMessage()); + } + + // now only allow one: doesn't really make a difference for bools + $this->parseItem(false, '', true); + try { + $this->parseItem(true, '', true); + $this->fail('Should have blocked'); + } catch (\InvalidArgumentException $e) { + $this->assertSame('Cannot have true as a setting for testItem', $e->getMessage()); + } catch (\Exception $e) { + $this->fail('Unexpected exception: '.$e->getMessage()); + } + }//end testParseItemBools() + + /** + * Test the parse item section - ints + * Uses reflection as this is a protected method. + * + * @test + * @covers \Bairwell\Cors\Traits\Parse::parseItem + */ + public function testParseItemInts() + { + // first allow multiple requests + $this->parseItem(3, '3', false); + + $this->parseItem(123, '123', false); + // now only allow one: doesn't really make a difference for ints + $this->parseItem(13, '13', true); + + $this->parseItem(12993, '12993', true); + }//end testParseItemInts() + + /** + * Test the parse item section - callables + * Uses reflection as this is a protected method. + * + * @test + * @covers \Bairwell\Cors\Traits\Parse::parseItem + */ + public function testParseItemCallablesAsStrings() + { + // first allow multiple requests + $callable = function () { + return 'hello, my,honey'; + }; + $this->parseItem($callable, 'hello, my, honey', false); + + $callable = function () { + return ['fred', 'george', 'jane']; + }; + $this->parseItem($callable, 'fred, george, jane', false); + + $callable = function () { + return 'rain,bow,climbing'; + }; + $this->parseItem($callable, 'rain, bow, climbing', false); + // now only allow one. + $callable = function () { + return 'hello'; + }; + $this->parseItem($callable, 'hello', true); + try { + $callable = function () { + return 'hello,my,honey'; + }; + $this->parseItem($callable, 'hello,my,honey', true); + $this->fail('Should have blocked'); + } catch (\InvalidArgumentException $e) { + $this->assertSame('Only expected a single string, int or bool', $e->getMessage()); + } catch (\Exception $e) { + $this->fail('Unexpected exception: '.$e->getMessage()); + } + + try { + $callable = function () { + return ['hello', 'my', 'honey']; + }; + $this->parseItem($callable, 'hello,my,honey', true); + $this->fail('Should have blocked'); + } catch (\InvalidArgumentException $e) { + $this->assertSame('Only expected a single string, int or bool', $e->getMessage()); + } catch (\Exception $e) { + $this->fail('Unexpected exception: '.$e->getMessage()); + } + }//end testParseItemCallablesAsStrings() + + /** + * Test the parse item section - callables + * Uses reflection as this is a protected method. + * + * @test + * @covers \Bairwell\Cors\Traits\Parse::parseItem + */ + public function testParseItemCallablesAsInts() + { + // first allow multiple requests + $callable = function () { + return '1,2,3'; + }; + $this->parseItem($callable, '1, 2, 3', false); + + $callable = function () { + return 2; + }; + $this->parseItem($callable, '2', false); + + $callable = function () { + return ['4', '5', '6']; + }; + $this->parseItem($callable, '4, 5, 6', false); + + $callable = function () { + return '7, 8, 9'; + }; + $this->parseItem($callable, '7, 8, 9', false); + // now only allow one. + $callable = function () { + return '1'; + }; + $this->parseItem($callable, '1', true); + $callable = function () { + return 3; + }; + $this->parseItem($callable, '3', true); + try { + $callable = function () { + return '5,6,7'; + }; + $this->parseItem($callable, 'hello,my,honey', true); + $this->fail('Should have blocked'); + } catch (\InvalidArgumentException $e) { + $this->assertSame('Only expected a single string, int or bool', $e->getMessage()); + } catch (\Exception $e) { + $this->fail('Unexpected exception: '.$e->getMessage()); + } + + try { + $callable = function () { + return ['9', '10', '11']; + }; + $this->parseItem($callable, 'hello,my,honey', true); + $this->fail('Should have blocked'); + } catch (\InvalidArgumentException $e) { + $this->assertSame('Only expected a single string, int or bool', $e->getMessage()); + } catch (\Exception $e) { + $this->fail('Unexpected exception: '.$e->getMessage()); + } + }//end testParseItemCallablesAsInts() + + /** + * Test the parse item section - callables + * Uses reflection as this is a protected method. + * + * @test + * @covers \Bairwell\Cors\Traits\Parse::parseItem + */ + public function testParseItemCallablesAsBools() + { + // first allow multiple requests + $callable = function () { + return false; + }; + $this->parseItem($callable, '', false); + $callable = function () { + return; + }; + $this->parseItem($callable, '', false); + try { + $callable = function () { + return true; + }; + $this->parseItem($callable, '', false); + $this->fail('Should have blocked'); + } catch (\InvalidArgumentException $e) { + $this->assertSame('Cannot have true as a setting for testItem', $e->getMessage()); + } catch (\Exception $e) { + $this->fail('Unexpected exception: '.$e->getMessage()); + } + + // now only allow one: doesn't really make a difference for bools + $callable = function () { + return false; + }; + $this->parseItem($callable, '', true); + $callable = function () { + return; + }; + $this->parseItem($callable, '', true); + try { + $callable = function () { + return true; + }; + $this->parseItem($callable, '', true); + $this->fail('Should have blocked'); + } catch (\InvalidArgumentException $e) { + $this->assertSame('Cannot have true as a setting for testItem', $e->getMessage()); + } catch (\Exception $e) { + $this->fail('Unexpected exception: '.$e->getMessage()); + } + }//end testParseItemCallablesAsBools() + + /** + * Test the parseAllowCredentials with callables + * Uses reflection as this is a protected method. + * + * @test + * @covers \Bairwell\Cors\Traits\Parse::parseAllowCredentials + */ + public function testParseAllowCredentialsCallables() + { + $sut = new Cors(); + $reflection = new \ReflectionClass(get_class($sut)); + $settingsProperty = $reflection->getProperty('settings'); + $settingsProperty->setAccessible(true); + $method = $reflection->getMethod('parseAllowCredentials'); + $method->setAccessible(true); + $request = $this->getMockBuilder('Psr\Http\Message\ServerRequestInterface') + ->disableOriginalConstructor() + ->getMock(); + $settingName = 'allowCredentials'; + + $settingValue = function () { + return true; + }; + + $settingsProperty->setValue($sut, [$settingName => $settingValue, 'def' => '567', 'ghi' => '911']); + $result = $method->invokeArgs($sut, [$request]); + $this->assertInternalType('bool', $result); + $this->assertTrue($result); + + $settingValue = function () { + return false; + }; + + $settingsProperty->setValue($sut, [$settingName => $settingValue, 'def' => '567', 'ghi' => '911']); + $result = $method->invokeArgs($sut, [$request]); + $this->assertInternalType('bool', $result); + $this->assertFalse($result); + + // check failures + $values = ['abc', '123', null, '-1', -1]; + foreach ($values as $value) { + try { + $settingValue = function () use ($value) { + return $value; + }; + + $settingsProperty->setValue($sut, [$settingName => $settingValue, 'def' => '567', 'ghi' => '911']); + $result = $method->invokeArgs($sut, [$request]); + $this->fail('Should have rejected value:'.$value); + } catch (\InvalidArgumentException $e) { + $this->assertSame('allowCredentials should be a boolean value', $e->getMessage()); + } catch (\Exception $e) { + $this->fail('Invalid exception: '.$e->getMessage()); + } + } + }//end testParseAllowCredentialsCallables() + + /** + * Test the parseAllowCredentials with values + * Uses reflection as this is a protected method. + * + * @test + * @covers \Bairwell\Cors\Traits\Parse::parseAllowCredentials + */ + public function testParseAllowCredentialValues() + { + $sut = new Cors(); + $reflection = new \ReflectionClass(get_class($sut)); + $settingsProperty = $reflection->getProperty('settings'); + $settingsProperty->setAccessible(true); + $method = $reflection->getMethod('parseAllowCredentials'); + $method->setAccessible(true); + $request = $this->getMockBuilder('Psr\Http\Message\ServerRequestInterface') + ->disableOriginalConstructor() + ->getMock(); + $settingName = 'allowCredentials'; + + // good settings + $settingsProperty->setValue($sut, [$settingName => true, 'def' => '567', 'ghi' => '911']); + $result = $method->invokeArgs($sut, [$request]); + $this->assertInternalType('bool', $result); + $this->assertTrue($result, 'Checking true'); + $settingsProperty->setValue($sut, [$settingName => false, 'def' => '567', 'ghi' => '911']); + $result = $method->invokeArgs($sut, [$request]); + $this->assertInternalType('bool', $result); + $this->assertFalse($result, 'Checking false'); + // check failures + $values = ['abc', '123', '-1', -1]; + foreach ($values as $settingValue) { + try { + $settingsProperty->setValue($sut, [$settingName => $settingValue, 'def' => '567', 'ghi' => '911']); + $result = $method->invokeArgs($sut, [$request]); + $this->fail('Should have rejected value:'.$settingValue); + } catch (\InvalidArgumentException $e) { + $this->assertSame('allowCredentials should be a boolean value', $e->getMessage()); + } catch (\Exception $e) { + $this->fail('Invalid exception: '.$e->getMessage()); + } + } + }//end testParseAllowCredentialValues() + + /** + * Test the parseAllowCredentials with callables + * Uses reflection as this is a protected method. + * + * @test + * @covers \Bairwell\Cors\Traits\Parse::parseMaxAge + */ + public function testParseMaxAgeCallables() + { + $sut = new Cors(); + $reflection = new \ReflectionClass(get_class($sut)); + $settingsProperty = $reflection->getProperty('settings'); + $settingsProperty->setAccessible(true); + $method = $reflection->getMethod('parseMaxAge'); + $method->setAccessible(true); + $request = $this->getMockBuilder('Psr\Http\Message\ServerRequestInterface') + ->disableOriginalConstructor() + ->getMock(); + $settingName = 'maxAge'; + + $settingValue = function () { + return 123; + }; + + $settingsProperty->setValue($sut, [$settingName => $settingValue, 'def' => '567', 'ghi' => '911']); + $result = $method->invokeArgs($sut, [$request]); + $this->assertInternalType('int', $result); + $this->assertSame(123, $result); + + $settingValue = function () { + return 456; + }; + + $settingsProperty->setValue($sut, [$settingName => $settingValue, 'def' => '567', 'ghi' => '911']); + $result = $method->invokeArgs($sut, [$request]); + $this->assertInternalType('int', $result); + $this->assertSame(456, $result); + + // check failures + try { + $settingValue = function () { + return; + }; + $settingsProperty->setValue($sut, [$settingName => $settingValue, 'def' => '567', 'ghi' => '911']); + $result = $method->invokeArgs($sut, [$request]); + $this->fail('Should have rejected null'); + } catch (\InvalidArgumentException $e) { + $this->assertSame('maxAge should be an int value', $e->getMessage()); + } catch (\Exception $e) { + $this->fail('Invalid exception: '.$e->getMessage()); + } + + try { + $settingValue = function () { + return -1; + }; + $settingsProperty->setValue($sut, [$settingName => $settingValue, 'def' => '567', 'ghi' => '911']); + $result = $method->invokeArgs($sut, [$request]); + $this->fail('Should have rejected null'); + } catch (\InvalidArgumentException $e) { + $this->assertSame('maxAge should be 0 or more', $e->getMessage()); + } catch (\Exception $e) { + $this->fail('Invalid exception: '.$e->getMessage()); + } + + $values = ['abc', '123', true, false, '-1']; + foreach ($values as $value) { + try { + $settingValue = function () use ($value) { + return $value; + }; + + $settingsProperty->setValue($sut, [$settingName => $settingValue, 'def' => '567', 'ghi' => '911']); + $result = $method->invokeArgs($sut, [$request]); + $this->fail('Should have rejected value:'.$value); + } catch (\InvalidArgumentException $e) { + $this->assertSame('maxAge should be an int value', $e->getMessage()); + } catch (\Exception $e) { + $this->fail('Invalid exception: '.$e->getMessage()); + } + } + }//end testParseMaxAgeCallables() + + /** + * Test the parseOrigin with values + * Uses reflection as this is a protected method. + * + * @test + * @covers \Bairwell\Cors\Traits\Parse::parseOrigin + */ + public function testParseOriginEmptyString() + { + $sut = new Cors(); + // setup the logger + $this->logger = new Logger('test'); + $this->testLogger = new TestHandler(); + $this->logger->pushHandler($this->testLogger); + $sut->setLogger($this->logger); + // + $reflection = new \ReflectionClass(get_class($sut)); + $settingsProperty = $reflection->getProperty('settings'); + $settingsProperty->setAccessible(true); + $method = $reflection->getMethod('parseOrigin'); + $method->setAccessible(true); + $request = $this->getMockBuilder('Psr\Http\Message\ServerRequestInterface') + ->disableOriginalConstructor() + ->getMock(); + $request->expects($this->once()) + ->method('getHeaderLine') + ->with('origin') + ->willReturn(''); + $result = $method->invokeArgs($sut, [$request]); + $this->assertSame('', $result); + // check the logger + $this->assertTrue($this->testLogger->hasDebugThatContains('Origin is empty or is not a string')); + }//end testParseOriginEmptyString() + + /** + * Test the parseOrigin with values + * Uses reflection as this is a protected method. + * + * @test + * @covers \Bairwell\Cors\Traits\Parse::parseOrigin + */ + public function testParseOriginInvalidString() + { + $sut = new Cors(); + // setup the logger + $this->logger = new Logger('test'); + $this->testLogger = new TestHandler(); + $this->logger->pushHandler($this->testLogger); + $sut->setLogger($this->logger); + // + $reflection = new \ReflectionClass(get_class($sut)); + $settingsProperty = $reflection->getProperty('settings'); + $settingsProperty->setAccessible(true); + $method = $reflection->getMethod('parseOrigin'); + $method->setAccessible(true); + $request = $this->getMockBuilder('Psr\Http\Message\ServerRequestInterface') + ->disableOriginalConstructor() + ->getMock(); + $request->expects($this->once()) + ->method('getHeaderLine') + ->with('origin') + ->willReturn(123); + $result = $method->invokeArgs($sut, [$request]); + $this->assertSame('', $result); + // check the logger + $this->assertTrue($this->testLogger->hasDebugThatContains('Origin is empty or is not a string')); + }//end testParseOriginInvalidString() + + /** + * Test the parseMaxAge with values + * Uses reflection as this is a protected method. + * + * @test + * @covers \Bairwell\Cors\Traits\Parse::parseMaxAge + */ + public function testParseMaxAgeValues() + { + $sut = new Cors(); + $reflection = new \ReflectionClass(get_class($sut)); + $settingsProperty = $reflection->getProperty('settings'); + $settingsProperty->setAccessible(true); + $method = $reflection->getMethod('parseMaxAge'); + $method->setAccessible(true); + $request = $this->getMockBuilder('Psr\Http\Message\ServerRequestInterface') + ->disableOriginalConstructor() + ->getMock(); + $settingName = 'maxAge'; + + // good settings + $settingsProperty->setValue($sut, [$settingName => 234, 'def' => '567', 'ghi' => '911']); + $result = $method->invokeArgs($sut, [$request]); + $this->assertInternalType('int', $result); + $this->assertSame(234, $result); + $settingsProperty->setValue($sut, [$settingName => 456, 'def' => '567', 'ghi' => '911']); + $result = $method->invokeArgs($sut, [$request]); + $this->assertInternalType('int', $result); + $this->assertSame(456, $result); + // check failures + try { + $settingsProperty->setValue($sut, [$settingName => -1, 'def' => '567', 'ghi' => '911']); + $result = $method->invokeArgs($sut, [$request]); + $this->fail('Should have rejected null value'); + } catch (\InvalidArgumentException $e) { + $this->assertSame('maxAge should be 0 or more', $e->getMessage()); + } catch (\Exception $e) { + $this->fail('Invalid exception: '.$e->getMessage()); + } + + $values = ['abc', '123', true, false, '-1']; + foreach ($values as $settingValue) { + try { + $settingsProperty->setValue($sut, [$settingName => $settingValue, 'def' => '567', 'ghi' => '911']); + $result = $method->invokeArgs($sut, [$request]); + $this->fail('Should have rejected value:'.$settingValue); + } catch (\InvalidArgumentException $e) { + $this->assertSame('maxAge should be an int value', $e->getMessage()); + } catch (\Exception $e) { + $this->fail('Invalid exception: '.$e->getMessage()); + } + } + }//end testParseMaxAgeValues() + + /** + * Test the parse item section. + * + * @param mixed $settingValue The callable,int,string,bool or array we are testing against. + * @param string $expectedResult The expected result. + * @param boolean $isSingle Does this "setting" take a single (true) or multiple parameters (false). + */ + protected function parseItem($settingValue, string $expectedResult, bool $isSingle = false) + { + $sut = new Cors(); + $reflection = new \ReflectionClass(get_class($sut)); + $settingsProperty = $reflection->getProperty('settings'); + $settingsProperty->setAccessible(true); + // setup the logger + $this->logger = new Logger('test'); + $this->testLogger = new TestHandler(); + $this->logger->pushHandler($this->testLogger); + $sut->setLogger($this->logger); + + $method = $reflection->getMethod('parseItem'); + $method->setAccessible(true); + $request = $this->getMockBuilder('Psr\Http\Message\ServerRequestInterface') + ->disableOriginalConstructor() + ->getMock(); + $settingName = 'testItem'; + $settingsProperty->setValue($sut, [$settingName => $settingValue, 'def' => '567', 'ghi' => '911']); + $result = $method->invokeArgs($sut, [$settingName, $request, $isSingle]); + $this->assertInternalType('string', $result); + $this->assertSame($expectedResult, $result); + }//end parseItem() +}//end class diff --git a/tests/Cors/Traits/PreflightTest.php b/tests/Cors/Traits/PreflightTest.php new file mode 100644 index 0000000..cf99fcf --- /dev/null +++ b/tests/Cors/Traits/PreflightTest.php @@ -0,0 +1,422 @@ +runInvoke( + [ + 'method' => 'OPTIONS', + 'setHeaders' => ['origin' => 'example.com'], + 'configuration' => ['allowMethods' => ''] + ] + ); + $this->fail('Should not have got here'); + } catch (\Exception $e) { + $this->assertSame('No methods configured to be allowed for request', $e->getMessage()); + } + }//end testInvokerPreflightNoMethods() + + /** + * Runs a test based on this having: + * - Method: OPTIONS (preflight) + * - * allowed origin (default) + * - Origin set to example.com (matching wildcard) + * should get exception (no ACRM). + * + * @test + * @covers \Bairwell\Cors::__construct + * @covers \Bairwell\Cors::__invoke + * @covers \Bairwell\Cors::preflight + * @covers \Bairwell\Cors::preflightAccessControlRequestHeaders + * @covers \Bairwell\Cors::preflightAccessControlAllowMethods + * @uses \Bairwell\Cors\Exceptions\NoMethod + */ + public function testInvokerPreflightNoAcrm() + { + try { + $this->runInvoke( + [ + 'method' => 'OPTIONS', + 'setHeaders' => ['origin' => 'example.com'], + 'configuration' => [] + ] + ); + $this->fail('Should not have got here'); + $this->fail('Expected exception to be raised'); + } catch (NoMethod $e) { + $this->assertSame('No method provided', $e->getMessage()); + $this->assertEmpty($e->getAllowed()); + $this->assertSame('', $e->getSent()); + } + }//end testInvokerPreflightNoAcrm() + + /** + * Runs a test based on this having: + * - Method: OPTIONS (preflight) + * - * allowed origin (default) + * - * allowed methods set to PUT,POST + * - Origin set to example.com (matching wildcard) + * - Access-Control-Request-Method set to "DELETE" + * should get exception. + * + * @test + * @covers \Bairwell\Cors::__construct + * @covers \Bairwell\Cors::__invoke + * @covers \Bairwell\Cors::preflight + * @covers \Bairwell\Cors::preflightAccessControlRequestHeaders + * @covers \Bairwell\Cors::preflightAccessControlAllowMethods + * @uses \Bairwell\Cors\Exceptions\MethodNotAllowed + */ + public function testInvokerPreflightInvalidAcrm() + { + try { + $this->runInvoke( + [ + 'method' => 'OPTIONS', + 'setHeaders' => ['origin' => 'example.com', 'access-control-request-method' => 'delete'], + 'configuration' => ['allowMethods' => ['PUT', 'POST']] + ] + ); + $this->fail('Should have failed'); + } catch (MethodNotAllowed $e) { + $this->assertSame('Method not allowed', $e->getMessage()); + $this->assertSame(['PUT', 'POST'], $e->getAllowed()); + $this->assertSame('DELETE', $e->getSent()); + } + }//end testInvokerPreflightInvalidAcrm() + + /** + * Runs a test based on this having: + * - Method: OPTIONS (preflight) + * - * allowed origin (default) + * - * allowed methods set to PUT,POST + * - Origin set to example.com (matching wildcard) + * - Access-Control-Request-Method set to "PUT" + * should get: + * Access-Control-Allow-Origin + * Access-Control-Allow-Methods + * Access-Control-Allow-Headers. + * + * @test + * @covers \Bairwell\Cors::__construct + * @covers \Bairwell\Cors::__invoke + * @covers \Bairwell\Cors::preflight + * @covers \Bairwell\Cors::preflightAccessControlRequestHeaders + * @covers \Bairwell\Cors::preflightAccessControlAllowMethods + */ + public function testInvokerPreflightValidAcrm() + { + $results = $this->runInvoke( + [ + 'method' => 'OPTIONS', + 'setHeaders' => ['origin' => 'example.com', 'access-control-request-method' => 'pUt'], + 'configuration' => ['allowMethods' => ['PUT', 'POST']] + ] + ); + $expected = [ + 'withHeader:Access-Control-Allow-Origin' => '*', + 'withHeader:Access-Control-Allow-Methods' => 'PUT, POST', + 'withHeader:Access-Control-Allow-Headers' => '', + 'withStatus' => '204:No Content', + 'withoutHeader:Content-Type' => true, + 'withoutHeader:Content-Length' => true + ]; + $this->arraysAreSimilar($expected, $results); + }//end testInvokerPreflightValidAcrm() + + /** + * Runs a test based on this having: + * - Method: OPTIONS (preflight) + * - * allowed origin (default) + * - * allowed methods set to PUT,POST + * - Origin set to example.com (matching wildcard) + * - Access-Control-Request-Method set to "PUT" + * - Access-Control-Request-Headers set to "X-Jeff" + * should get exception. + * + * @test + * @covers \Bairwell\Cors::__construct + * @covers \Bairwell\Cors::__invoke + * @covers \Bairwell\Cors::preflight + * @covers \Bairwell\Cors::preflightAccessControlRequestHeaders + * @covers \Bairwell\Cors::preflightAccessControlAllowMethods + * @uses \Bairwell\Cors\Exceptions\NoHeadersAllowed + */ + public function testInvokerPreflightValidAcrmInvalidAcrh() + { + try { + $this->runInvoke( + [ + 'method' => 'OPTIONS', + 'setHeaders' => [ + 'origin' => 'example.com', + 'access-control-request-method' => 'put', + 'access-control-request-headers' => 'x-jeff' + ], + 'configuration' => ['allowMethods' => ['PUT', 'POST']] + ] + ); + $this->fail('Should not have got here'); + } catch (NoHeadersAllowed $e) { + $this->assertSame('No headers are allowed', $e->getMessage()); + $this->assertEmpty($e->getAllowed()); + $this->assertSame('x-jeff', $e->getSent()); + } + }//end testInvokerPreflightValidAcrmInvalidAcrh() + + /** + * Runs a test based on this having: + * - Method: OPTIONS (preflight) + * - * allowed origin (default) + * - * allowed methods set to PUT,POST + * - * allowed headers to to x-jeff, x-smith + * - Origin set to example.com (matching wildcard) + * - Access-Control-Request-Method set to "PUT" + * - Access-Control-Request-Headers set to "X-Jeff" + * should get exception. + * + * @test + * @covers \Bairwell\Cors::__construct + * @covers \Bairwell\Cors::__invoke + * @covers \Bairwell\Cors::preflight + * @covers \Bairwell\Cors::preflightAccessControlRequestHeaders + * @covers \Bairwell\Cors::preflightAccessControlAllowMethods + * @uses Bairwell\Cors\Exceptions\HeaderNotAllowed + */ + public function testInvokerPreflightValidAcrmDisallowedAcrh() + { + try { + $this->runInvoke( + [ + 'method' => 'OPTIONS', + 'setHeaders' => [ + 'origin' => 'example.com', + 'access-control-request-method' => 'put', + 'access-control-request-headers' => 'x-jeff, x-smith, x-jones' + ], + 'configuration' => ['allowMethods' => ['PUT', 'POST'], 'allowHeaders' => 'x-jeff,x-smith'] + ] + ); + $this->fail('Should not have got here'); + } catch (HeaderNotAllowed $e) { + $this->assertSame('Header not allowed', $e->getMessage()); + $this->assertSame(['x-jeff', 'x-smith'], $e->getAllowed()); + $this->assertSame('x-jeff, x-smith, x-jones', $e->getSent()); + } + }//end testInvokerPreflightValidAcrmDisallowedAcrh() + + /** + * Runs a test based on this having: + * - Method: OPTIONS (preflight) + * - * allowed origin (default) + * - * allowed methods set to PUT,POST + * - * allowed headers to to x-jeff, x-smith + * - Origin set to example.com (matching wildcard) + * - Access-Control-Request-Method set to "PUT" + * - Access-Control-Request-Headers set to "X-Jeff" + * should get: + * Access-Control-Allow-Origin + * Access-Control-Allow-Methods + * Access-Control-Allow-Headers. + * + * @test + * @covers \Bairwell\Cors::__construct + * @covers \Bairwell\Cors::__invoke + * @covers \Bairwell\Cors::preflight + * @covers \Bairwell\Cors::preflightAccessControlRequestHeaders + * @covers \Bairwell\Cors::preflightAccessControlAllowMethods + */ + public function testInvokerPreflightValidAcrmValidAcrh() + { + $results = $this->runInvoke( + [ + 'method' => 'OPTIONS', + 'setHeaders' => [ + 'origin' => 'example.com', + 'access-control-request-method' => 'put', + 'access-control-request-headers' => 'x-jeff, x-smith, x-jones' + ], + 'configuration' => ['allowMethods' => ['PUT', 'POST'], 'allowHeaders' => 'x-jeff,x-smith,x-jones'] + ] + ); + $expected = [ + 'withHeader:Access-Control-Allow-Origin' => '*', + 'withHeader:Access-Control-Allow-Methods' => 'PUT, POST', + 'withHeader:Access-Control-Allow-Headers' => 'x-jeff, x-smith, x-jones', + 'withStatus' => '204:No Content', + 'withoutHeader:Content-Type' => true, + 'withoutHeader:Content-Length' => true + ]; + $this->arraysAreSimilar($expected, $results); + }//end testInvokerPreflightValidAcrmValidAcrh() + + /** + * Runs a test based on this having: + * - Method: OPTIONS (preflight) + * - * allowed origin (default) + * - * allowed methods set to PUT,POST + * - * allowed headers to to x-jeff, x-smith + * - * allow credentials + * - * maxAge 300 + * - * origin example.com + * - Origin set to example.com + * - Access-Control-Request-Method set to "PUT" + * - Access-Control-Request-Headers set to "X-Jeff" + * should get: + * Access-Control-Allow-Origin + * Access-Control-Allow-Methods + * Access-Control-Allow-Headers + * Access-Control-Max-Age + * Access-Control-Allow-Credentials + * Vary: Origin. + * + * @test + * @covers \Bairwell\Cors::__construct + * @covers \Bairwell\Cors::__invoke + * @covers \Bairwell\Cors::preflight + * @covers \Bairwell\Cors::preflightAccessControlRequestHeaders + * @covers \Bairwell\Cors::preflightAccessControlAllowMethods + */ + public function testInvokerPreflightAllTheThings() + { + $results = $this->runInvoke( + [ + 'method' => 'OPTIONS', + 'setHeaders' => [ + 'origin' => 'example.com', + 'access-control-request-method' => 'put', + 'access-control-request-headers' => 'x-jeff, x-smith, x-jones' + ], + 'configuration' => [ + 'allowMethods' => ['PUT', 'POST'], + 'allowHeaders' => 'x-jeff,x-smith,x-jones', + 'maxAge' => 300, + 'allowCredentials' => true, + 'origin' => 'example.com' + ] + ] + ); + $expected = [ + 'withHeader:Access-Control-Allow-Origin' => 'example.com', + 'withHeader:Access-Control-Allow-Credentials' => 'true', + 'withHeader:Access-Control-Allow-Methods' => 'PUT, POST', + 'withHeader:Access-Control-Allow-Headers' => 'x-jeff, x-smith, x-jones', + 'withHeader:Access-Control-Max-Age' => 300, + 'withAddedHeader:Vary' => 'Origin', + 'withStatus' => '204:No Content', + 'withoutHeader:Content-Type' => true, + 'withoutHeader:Content-Length' => true + ]; + $this->arraysAreSimilar($expected, $results); + }//end testInvokerPreflightAllTheThings() + + /** + * Specific test to ensure PreflightAccessControlRequestHeaders returns empty arrays. + * + * @test + * @covers \Bairwell\Cors::preflightAccessControlRequestHeaders + * @uses Bairwell\Cors\Exceptions\NoHeadersAllowed + */ + public function testPreflightAccessControlRequestHeadersNoHeaders() + { + // first with no headers + $config['allowHeaders'] = ['']; + $sut = new Cors($config); + $reflection = new \ReflectionClass(get_class($sut)); + $method = $reflection->getMethod('preflightAccessControlRequestHeaders'); + $method->setAccessible(true); + $request = $this->getMockForAbstractClass('\Psr\Http\Message\ServerRequestInterface'); + $request->expects($this->any()) + ->method('getHeaderLine') + ->with('access-control-request-headers') + ->willReturn('xyz'); + $response = $this->getMockForAbstractClass('\Psr\Http\Message\ResponseInterface'); + + $headers = ['abc', 'def', 'ghi']; + try { + // @codingStandardsIgnoreStart + $response = $method->invokeArgs($sut, [$request, $response, &$headers]); + // @codingStandardsIgnoreEnd + $this->fail('Should have thrown exception'); + } catch (NoHeadersAllowed $e) { + $this->assertSame('No headers are allowed', $e->getMessage()); + $this->assertEmpty($e->getAllowed()); + $this->assertSame('xyz', $e->getSent()); + } + }//end testPreflightAccessControlRequestHeadersNoHeaders() + + /** + * Specific test to ensure PreflightAccessControlRequestHeaders returns empty arrays. + * + * @test + * @covers \Bairwell\Cors::preflightAccessControlRequestHeaders + * @uses Bairwell\Cors\Exceptions\HeaderNotAllowed + */ + public function testPreflightAccessControlRequestHeadersInvalidHeaders() + { + // first with no headers + $config['allowHeaders'] = ['x-smith']; + $sut = new Cors($config); + $reflection = new \ReflectionClass(get_class($sut)); + $method = $reflection->getMethod('preflightAccessControlRequestHeaders'); + $method->setAccessible(true); + $request = $this->getMockForAbstractClass('\Psr\Http\Message\ServerRequestInterface'); + $request->expects($this->any()) + ->method('getHeaderLine') + ->with('access-control-request-headers') + ->willReturn('x-jones'); + $response = $this->getMockForAbstractClass('\Psr\Http\Message\ResponseInterface'); + + $headers = ['abc', 'def', 'ghi']; + try { + // @codingStandardsIgnoreStart + $response = $method->invokeArgs($sut, [$request, $response, &$headers]); + // @codingStandardsIgnoreEnd + $this->fail('Exception expected'); + } catch (HeaderNotAllowed $e) { + $this->assertSame('Header not allowed', $e->getMessage()); + $this->assertSame(['x-smith'], $e->getAllowed()); + $this->assertSame('x-jones', $e->getSent()); + } + }//end testPreflightAccessControlRequestHeadersInvalidHeaders() +}//end class diff --git a/tests/Cors/Traits/RunInvokeArrays.php b/tests/Cors/Traits/RunInvokeArrays.php new file mode 100644 index 0000000..4d400fa --- /dev/null +++ b/tests/Cors/Traits/RunInvokeArrays.php @@ -0,0 +1,242 @@ +testLogger->getRecords(); + $return=[]; + foreach ($records as $record) { + $return[]=$record['message']; + } + return $return; + } + /** + * Setup for PHPUnit. + * + * @throws \Exception In callback if there is a problem. + */ + public function setUp() + { + $this->defaults = [ + 'origin' => '*', + 'exposeHeaders' => '', + 'maxAge' => 0, + 'allowCredentials' => false, + 'allowMethods' => 'GET,HEAD,PUT,POST,DELETE', + 'allowHeaders' => '' + ]; + $this->allowedSettings = [ + 'exposeHeaders' => ['string', 'array', 'callable'], + 'allowMethods' => ['string', 'array', 'callable'], + 'allowHeaders' => ['string', 'array', 'callable'], + 'origin' => ['string', 'array', 'callable'], + 'maxAge' => ['int', 'callable'], + 'allowCredentials' => ['bool', 'callable'] + ]; + }//end setUp() + + /** + * Run the invoke system for testing. + * + * @param array $settings Include: + * method (GET/POST/PUT/OPTIONS) + * setHeaders (array): additional headers sending in (such as origin) + * configuration (array) Configuration data to pass in. + * + * @throws \Exception If configuration settings are missing. + * + * @return array + */ + private function runInvoke(array $settings) + { + if (false === isset($settings['method']) || false === isset($settings['setHeaders']) + || false === isset($settings['configuration']) + ) { + throw new \Exception('Missing settings'); + } + + $sut = new Cors($settings['configuration']); + // sanity check + $this->assertInstanceOf('Bairwell\Cors', $sut); + $sutSettings = array_merge($this->defaults, $settings['configuration']); + $this->arraysAreSimilar($sutSettings, $sut->getSettings(), 'Matching internal settings'); + // setup the logger + $this->logger = new Logger('test'); + $this->testLogger = new TestHandler(); + $this->logger->pushHandler($this->testLogger); + $sut->setLogger($this->logger); + // set up the request + $request = $this->getMockForAbstractClass('\Psr\Http\Message\ServerRequestInterface'); + $request->expects($this->any()) + ->method('getMethod') + ->willReturn($settings['method']); + $request->expects($this->any()) + ->method('getHeaderLine') + ->willReturnCallback( + function ($headerName) use ($settings) { + if (true === isset($settings['setHeaders'][$headerName])) { + return $settings['setHeaders'][$headerName]; + } else { + return ''; + } + } + ); + // now setup the response stack. + $responseCalls = []; + $response = $this->getMockForAbstractClass('\Psr\Http\Message\ResponseInterface'); + $response->expects($this->any()) + ->method('withAddedHeader') + ->will( + $this->returnCallback( + function ($k, $v) use (&$responseCalls, $response) { + $responseCalls['withAddedHeader:'.$k] = $v; + + return $response; + } + ) + ); + $response->expects($this->any()) + ->method('withHeader') + ->will( + $this->returnCallback( + function ($k, $v) use (&$responseCalls, $response) { + $responseCalls['withHeader:'.$k] = $v; + + return $response; + } + ) + ); + $response->expects($this->any()) + ->method('withoutHeader') + ->will( + $this->returnCallback( + function ($k) use (&$responseCalls, $response) { + $responseCalls['withoutHeader:'.$k] = true; + + return $response; + } + ) + ); + $response->expects($this->any()) + ->method('withStatus') + ->will( + $this->returnCallback( + function ($k, $v) use (&$responseCalls, $response) { + $responseCalls['withStatus'] = $k.':'.$v; + + return $response; + } + ) + ); + $next = function ($req, $res) use (&$responseCalls, $response) { + $responseCalls['calledNext'] = 'called'; + + return $response; + }; + $returnedResponse = $sut->__invoke($request, $response, $next); + + return $responseCalls; + }//end runInvoke() + + /** + * Determine if two associative arrays are similar. + * + * Both arrays must have the same indexes with identical values + * without respect to key ordering. + * + * @param \array $a First array to compare against. + * @param \array $b Second array to compare against. + * @param string $message Optional diagnostic message. + */ + private function arraysAreSimilar(array $a, array $b, string $message = '') + { + if (count($a) !== count($b)) { + $this->fail($message.': First array has '.count($a).' variables, second has '.count($b)); + + return; + } + + $aKeys = array_keys($a); + $bKeys = array_keys($b); + $differenceInKeys = array_diff($aKeys, $bKeys); + if (0 !== count($differenceInKeys)) { + $this->fail( + $message.': Difference in keys: first array has keys: ['. + implode(', ', $aKeys). + '] second array has: ['. + implode(', ', $bKeys). + ']' + ); + + return; + } + + // we know that the indexes, but maybe not values, match. + // compare the values between the two arrays + foreach ($a as $k => $v) { + if (($v instanceof \Closure) || ($b[$k] instanceof \Closure)) { + if (false === (($v instanceof \Closure) && ($b[$k] instanceof \Closure))) { + $this->fail($message.': Expected key '.$k.' to be a closure in both instances'); + } + } else { + $msg = $message.': Expected '.gettype($v); + if (true === is_string($v)) { + $msg .= ' "'.$v.'"'; + } + + $msg .= ' got '.gettype($b[$k]); + if (true === is_string($b[$k])) { + $msg .= ' "'.$b[$k].'"'; + } + + $this->assertSame($v, $b[$k], $msg); + } + }//end foreach + }//end arraysAreSimilar() +} diff --git a/tests/Cors/Traits/ValidateTest.php b/tests/Cors/Traits/ValidateTest.php new file mode 100644 index 0000000..257505b --- /dev/null +++ b/tests/Cors/Traits/ValidateTest.php @@ -0,0 +1,122 @@ +allowedSettings = [ + 'exposeHeaders' => ['string', 'array', 'callable'], + 'allowMethods' => ['string', 'array', 'callable'], + 'allowHeaders' => ['string', 'array', 'callable'], + 'origin' => ['string', 'array', 'callable'], + 'maxAge' => ['int', 'callable'], + 'allowCredentials' => ['bool', 'callable'], + 'blockedCallback' => ['callable'] + ]; + }//end setUp() + /** + * Covers checking the validation settings. + * + * @test + * @covers \Bairwell\Cors::validateSetting + * @covers \Bairwell\Cors::validateSettingString + * @covers \Bairwell\Cors::validateSettingArray + * @covers \Bairwell\Cors::validateSettingCallable + * @covers \Bairwell\Cors::validateSettingInt + * @covers \Bairwell\Cors::validateSettingBool + */ + public function testValidateSettings() + { + $sut = new Cors(); + $reflection = new \ReflectionClass(get_class($sut)); + $settingsProperty = $reflection->getProperty('settings'); + $settingsProperty->setAccessible(true); + $method = $reflection->getMethod('validateSetting'); + $method->setAccessible(true); + // general tests + $testData = [ + ['notOn' => 'string', 'value' => 'abc'], + ['notOn' => 'string', 'value' => '123'], + ['notOn' => 'int', 'value' => 123], + ['notOn' => 'bool', 'value' => true], + ['notOn' => 'bool', 'value' => false], + [ + 'notOn' => 'callable', + 'value' => function () { + } + ], + ['notOn' => 'array', 'value' => ['abc', 'def']] + ]; + foreach ($this->allowedSettings as $k => $allowed) { + foreach ($testData as $data) { + try { + $method->invokeArgs($sut, [$k, $data['value'], $allowed]); + } catch (\InvalidArgumentException $e) { + if (true === in_array($data['notOn'], $allowed)) { + $this->fail('Failed to test '.$k.' correctly: rejected when passing value:'.$e->getMessage()); + } else { + $expected = 'Unable to validate settings for '.$k.': allowed types: '.implode(', ', $allowed); + $this->assertSame($expected, $e->getMessage()); + } + } + } + } + + // specific tests + try { + $method->invokeArgs($sut, ['test', [], ['array']]); + } catch (\InvalidArgumentException $e) { + $this->assertSame('Array for test is empty', $e->getMessage()); + } + + // non-stringed array (containing another array) + try { + $method->invokeArgs($sut, ['test', ['abc', '123', []], ['array']]); + } catch (\InvalidArgumentException $e) { + $this->assertSame('Array for test contains a non-string item', $e->getMessage()); + } + + // non-stringed array (containing int) + try { + $method->invokeArgs($sut, ['test', ['abc', 123], ['array']]); + } catch (\InvalidArgumentException $e) { + $this->assertSame('Array for test contains a non-string item', $e->getMessage()); + } + + // int is too low + try { + $method->invokeArgs($sut, ['test', -1, ['int']]); + } catch (\InvalidArgumentException $e) { + $this->assertSame('Int value for test is too low', $e->getMessage()); + } + }//end testValidateSettings() +}//end class diff --git a/tests/CorsTest.php b/tests/CorsTest.php new file mode 100644 index 0000000..b4c040e --- /dev/null +++ b/tests/CorsTest.php @@ -0,0 +1,666 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +declare (strict_types = 1); + +namespace Bairwell; + +use Bairwell\Cors\Exceptions\BadOrigin; + +/** + * Class CorsTest. + * Tests the CORs middleware layer. + * + * @uses \Bairwell\Cors + * @uses \Bairwell\Cors\Traits\Validate + * @uses \Bairwell\Cors\Traits\Parse + * @uses \Bairwell\Cors\Traits\Preflight + * @uses \Bairwell\Cors\Exceptions\ExceptionAbstract + */ +class CorsTest extends \PHPUnit_Framework_TestCase +{ + use \Bairwell\Cors\Traits\RunInvokeArrays; + + /** + * Checks the default settings. + * + * @test + * @covers \Bairwell\Cors::__construct + * @covers \Bairwell\Cors::getDefaults + * @covers \Bairwell\Cors::getSettings + * @covers \Bairwell\Cors::getAllowedSettings + */ + public function testCheckDefaultSettings() + { + $sut = new Cors(); + $defaults = $sut->getDefaults(); + $this->arraysAreSimilar($this->defaults, $defaults); + $settings = $sut->getSettings(); + $this->arraysAreSimilar($this->defaults, $settings); + $allowed = $sut->getAllowedSettings(); + $this->arraysAreSimilar($this->allowedSettings, $allowed); + }//end testCheckDefaultSettings() + + /** + * Test the logger can be configured. + * + * @test + * @covers \Bairwell\Cors::addLog + * @covers \Bairwell\Cors::setLogger + */ + public function testLogger() { + $sut = new Cors(); + $addLog=new \ReflectionMethod($sut,'addLog'); + $addLog->setAccessible(true); + $this->assertFalse($addLog->invoke($sut,'Log entry')); + + $logger=$this->getMockForAbstractClass('\Psr\Log\LoggerInterface'); + $logger->expects($this->once()) + ->method('debug') + ->with('Log entry'); + $sut->setLogger($logger); + $this->assertTrue($addLog->invoke($sut,'Log entry')); + } + /** + * Checks the settings can be changed via the constructor. + * + * @test + * @covers \Bairwell\Cors::__construct + * @covers \Bairwell\Cors::getSettings + */ + public function testCheckChangedSettingsViaConstructor() + { + $sut = new Cors(['origin' => 'test']); + $expected = $this->defaults; + $expected['origin'] = 'test'; + $settings = $sut->getSettings(); + $this->arraysAreSimilar($expected, $settings); + }//end testCheckChangedSettingsViaConstructor() + + /** + * Checks the settings can be changed via the setter. + * + * @test + * @covers \Bairwell\Cors::__construct + * @covers \Bairwell\Cors::getSettings + * @covers \Bairwell\Cors::setSettings + */ + public function testCheckChangedSettingsViaSetter() + { + $sut = new Cors(); + $sut->setSettings(['maxAge' => 123, 'allowCredentials' => true]); + $expected = $this->defaults; + $expected['maxAge'] = 123; + $expected['allowCredentials'] = true; + $settings = $sut->getSettings(); + $this->arraysAreSimilar($expected, $settings); + }//end testCheckChangedSettingsViaSetter() + + /** + * Checks the settings will allow random stuff to be set. + * + * @test + * @covers \Bairwell\Cors::__construct + * @covers \Bairwell\Cors::getSettings + */ + public function testCheckChangedSettingsViaSetterRandomSettings() + { + $sut = new Cors(); + $sut->setSettings(['maxAge' => 123, 'allowCredentials' => true, 'random' => '123']); + $expected = $this->defaults; + $expected['maxAge'] = 123; + $expected['allowCredentials'] = true; + $expected['random'] = '123'; + $settings = $sut->getSettings(); + $this->arraysAreSimilar($expected, $settings); + }//end testCheckChangedSettingsViaSetterRandomSettings() + + /** + * Runs a test based on this having: + * - Method: GET + * - Default configuration. + * Should have no CORS headers back. + * + * @test + * @covers \Bairwell\Cors::__construct + * @covers \Bairwell\Cors::__invoke + */ + public function testInvokerGetDefaults() + { + $results = $this->runInvoke( + [ + 'method' => 'GET', + 'setHeaders' => [], + 'configuration' => [] + ] + ); + $expected = ['calledNext' => 'called']; + $this->arraysAreSimilar($results, $expected); + // check logs + $expectedLogs=[ + 'Request does not have an origin setting' + ]; + $logEntries=$this->getLoggerStrings(); + $this->assertEquals($expectedLogs,$logEntries); + }//end testInvokerGetDefaults() + + /** + * Runs a test based on this having: + * - Method: GET + * - * allowed origin (default) + * - Origin set to example.com (matching wildcard) + * should get + * Access-Control-Allow-Origin + * and next called. + * + * @test + * @covers \Bairwell\Cors::__construct + * @covers \Bairwell\Cors::__invoke + * @covers \Bairwell\Cors\Traits\Parse::parseOriginMatch + * @covers \Bairwell\Cors\Traits\Parse::parseOrigin + */ + public function testInvokerWithOriginHeader() + { + $results = $this->runInvoke( + [ + 'method' => 'GET', + 'setHeaders' => ['origin' => 'example.com'], + 'configuration' => [] + ] + ); + $expected = ['withHeader:Access-Control-Allow-Origin' => '*', 'calledNext' => 'called']; + $this->arraysAreSimilar($results, $expected); + // check logs + $expectedLogs=[ + 'Request has an origin setting and is being treated like a CORs request', + 'Processing origin of "example.com"', + 'Attempting to match origin as string', + 'Checking configuration origin of "*" against user "example.com"', + 'Origin is either an empty string or wildcarded star. Returning *', + 'Processing with origin of "*"', + 'Calling next bit of middleware' + ]; + $logEntries=$this->getLoggerStrings(); + $this->assertEquals($expectedLogs,$logEntries); + + }//end testInvokerWithOriginHeader() + + /** + * Runs a test based on this having: + * - Method: GET + * - example.com allowed origin (default) + * - Origin set to example.com + * should get + * Access-Control-Allow-Origin + * and next called. + * + * @test + * @covers \Bairwell\Cors::__construct + * @covers \Bairwell\Cors::__invoke + * @covers \Bairwell\Cors\Traits\Parse::parseOriginMatch + * @covers \Bairwell\Cors\Traits\Parse::parseOrigin + */ + public function testInvokerWithCustomOriginHeader() + { + $results = $this->runInvoke( + [ + 'method' => 'GET', + 'setHeaders' => ['origin' => 'example.com'], + 'configuration' => ['origin' => 'example.com'] + ] + ); + $expected = ['withHeader:Access-Control-Allow-Origin' => 'example.com', 'calledNext' => 'called']; + $this->arraysAreSimilar($results, $expected); + // check logs + $expectedLogs=[ + 'Request has an origin setting and is being treated like a CORs request', + 'Processing origin of "example.com"', + 'Attempting to match origin as string', + 'Checking configuration origin of "example.com" against user "example.com"', + 'Origin is an exact case insensitive match', + 'Processing with origin of "example.com"', + 'Calling next bit of middleware' + ]; + $logEntries=$this->getLoggerStrings(); + $this->assertEquals($expectedLogs,$logEntries); + }//end testInvokerWithCustomOriginHeader() + + /** + * Runs a test based on this having: + * - Method: GET + * - example.com allowed origin (default) + * - Origin set to dummy.com + * should get access denied. + * + * @test + * @covers \Bairwell\Cors::__construct + * @covers \Bairwell\Cors::__invoke + * @covers \Bairwell\Cors\Traits\Parse::parseOriginMatch + * @covers \Bairwell\Cors\Traits\Parse::parseOrigin + * @uses \Bairwell\Cors\Exceptions\BadOrigin + */ + public function testInvokerWithCustomOriginHeaderInvalid() + { + try { + $results = $this->runInvoke( + [ + 'method' => 'GET', + 'setHeaders' => ['origin' => 'dummy.com'], + 'configuration' => ['origin' => 'example.com'] + ] + ); + $this->fail('An exception should have been raised due to the mismatches'); + } catch (BadOrigin $e) { + $this->assertSame('Bad Origin', $e->getMessage()); + $this->assertEmpty($e->getAllowed()); + $this->assertSame('dummy.com', $e->getsent()); + } + + // check logs + $expectedLogs=[ + 'Request has an origin setting and is being treated like a CORs request', + 'Processing origin of "dummy.com"', + 'Attempting to match origin as string', + 'Checking configuration origin of "example.com" against user "dummy.com"', + 'Unable to match "example.com" against user "dummy.com"' + ]; + $logEntries=$this->getLoggerStrings(); + $this->assertEquals($expectedLogs,$logEntries); + }//end testInvokerWithCustomOriginHeaderInvalid() + + /** + * Runs a test based on this having: + * - Method: GET + * - example.com allowed origin (default) + * - Origin set to '' + * should just get "Next" as (with a blank/unset origin), this is not a cors call. + * + * @test + * @covers \Bairwell\Cors::__construct + * @covers \Bairwell\Cors::__invoke + * @covers \Bairwell\Cors\Traits\Parse::parseOriginMatch + * @covers \Bairwell\Cors\Traits\Parse::parseOrigin + */ + public function testInvokerWithCustomOriginHeaderEmpty() + { + $results = $this->runInvoke( + [ + 'method' => 'GET', + 'setHeaders' => ['origin' => ''], + 'configuration' => ['origin' => 'example.com'] + ] + ); + $expected = ['calledNext' => 'called']; + $this->arraysAreSimilar($results, $expected); + // check logs + $expectedLogs=[ + 'Request does not have an origin setting' + ]; + $logEntries=$this->getLoggerStrings(); + $this->assertEquals($expectedLogs,$logEntries); + }//end testInvokerWithCustomOriginHeaderEmpty() + + /** + * Runs a test based on this having: + * - Method: GET + * - example.com allowed origin (default) + * - Origin set to dummy.com + * should get access denied. + * + * @test + * @covers \Bairwell\Cors::__construct + * @covers \Bairwell\Cors::__invoke + * @covers \Bairwell\Cors\Traits\Parse::parseOriginMatch + * @covers \Bairwell\Cors\Traits\Parse::parseOrigin + * @uses \Bairwell\Cors\Exceptions\BadOrigin + */ + public function testInvokerWithCustomOriginHeaderDummyCallback() + { + try { + $results = $this->runInvoke( + [ + 'method' => 'GET', + 'setHeaders' => ['origin' => 'dummy.com'], + 'configuration' => ['origin' => 'example.com'] + ] + ); + $this->fail('Expected exception to be raised'); + } catch (BadOrigin $e) { + $this->assertSame('Bad Origin', $e->getMessage()); + $this->assertEmpty($e->getAllowed()); + $this->assertSame('dummy.com', $e->getSent()); + } + // check logs + $expectedLogs=[ + 'Request has an origin setting and is being treated like a CORs request', + 'Processing origin of "dummy.com"', + 'Attempting to match origin as string', + 'Checking configuration origin of "example.com" against user "dummy.com"', + 'Unable to match "example.com" against user "dummy.com"' + ]; + $logEntries=$this->getLoggerStrings(); + $this->assertEquals($expectedLogs,$logEntries); + }//end testInvokerWithCustomOriginHeaderDummyCallback() + + /** + * Runs a test based on this having: + * - Method: GET + * - origin set via callback + * - Origin set to dummy.com + * should get access denied. + * + * @test + * @covers \Bairwell\Cors::__construct + * @covers \Bairwell\Cors::__invoke + * @covers \Bairwell\Cors\Traits\Parse::parseOriginMatch + * @covers \Bairwell\Cors\Traits\Parse::parseOrigin + * @uses \Bairwell\Cors\Exceptions\BadOrigin + */ + public function testInvokerWithCustomOriginHeaderCustomCallbacks() + { + $originCallback = function ($request) { + return 'hello'; + }; + try { + $results = $this->runInvoke( + [ + 'method' => 'GET', + 'setHeaders' => ['origin' => 'dummy.com'], + 'configuration' => ['origin' => $originCallback] + ] + ); + $this->fail('Expected exception to be raised'); + } catch (BadOrigin $e) { + $this->assertSame('Bad Origin', $e->getMessage()); + $this->assertEmpty($e->getAllowed()); + $this->assertSame('dummy.com', $e->getSent()); + } + // check logs + $expectedLogs=[ + 'Request has an origin setting and is being treated like a CORs request', + 'Processing origin of "dummy.com"', + 'Origin server request is being passed to callback', + 'Attempting to match origin as string', + 'Checking configuration origin of "hello" against user "dummy.com"', + 'Unable to match "hello" against user "dummy.com"' + ]; + $logEntries=$this->getLoggerStrings(); + $this->assertEquals($expectedLogs,$logEntries); + }//end testInvokerWithCustomOriginHeaderCustomCallbacks() + + /** + * Runs a test based on this having: + * - Method: GET + * - origin set via callback + * - Origin set to dummy.com + * should get + * Access-Control-Allow-Origin + * and next called. + * + * @test + * @covers \Bairwell\Cors::__construct + * @covers \Bairwell\Cors::__invoke + * @covers \Bairwell\Cors\Traits\Parse::parseOriginMatch + * @covers \Bairwell\Cors\Traits\Parse::parseOrigin + */ + public function testInvokerWithCustomOriginHeaderCustomAllowedCallbacks() + { + $originCallback = function ($request) { + return 'dummy.com'; + }; + $results = $this->runInvoke( + [ + 'method' => 'GET', + 'setHeaders' => ['origin' => 'dummy.com'], + 'configuration' => ['origin' => $originCallback] + ] + ); + $expected = ['withHeader:Access-Control-Allow-Origin' => 'dummy.com', 'calledNext' => 'called']; + $this->arraysAreSimilar($results, $expected); + // check logs + $expectedLogs=[ + 'Request has an origin setting and is being treated like a CORs request', + 'Processing origin of "dummy.com"', + 'Origin server request is being passed to callback', + 'Attempting to match origin as string', + 'Checking configuration origin of "dummy.com" against user "dummy.com"', + 'Origin is an exact case insensitive match', + 'Processing with origin of "dummy.com"', + 'Calling next bit of middleware' + ]; + $logEntries=$this->getLoggerStrings(); + $this->assertEquals($expectedLogs,$logEntries); + }//end testInvokerWithCustomOriginHeaderCustomAllowedCallbacks() + + /** + * Runs a test based on this having: + * - Method: GET + * - origin set to array + * - Origin set to dummy.com + * should get + * Access-Control-Allow-Origin + * and next called. + * + * @test + * @covers \Bairwell\Cors::__construct + * @covers \Bairwell\Cors::__invoke + * @covers \Bairwell\Cors\Traits\Parse::parseOriginMatch + * @covers \Bairwell\Cors\Traits\Parse::parseOrigin + */ + public function testInvokerWithOriginArray() + { + $results = $this->runInvoke( + [ + 'method' => 'GET', + 'setHeaders' => ['origin' => 'dummy.com'], + 'configuration' => ['origin' => ['example.com', 'dummy.com']] + ] + ); + $expected = ['withHeader:Access-Control-Allow-Origin' => 'dummy.com', 'calledNext' => 'called']; + $this->arraysAreSimilar($results, $expected); + // check logs + $expectedLogs=[ + 'Request has an origin setting and is being treated like a CORs request', + 'Processing origin of "dummy.com"', + 'Iterating through Origin array', + 'Checking configuration origin of "example.com" against user "dummy.com"', + 'Unable to match "example.com" against user "dummy.com"', + 'Checking configuration origin of "dummy.com" against user "dummy.com"', + 'Origin is an exact case insensitive match', + 'Iterator found a matched origin of dummy.com', + 'Processing with origin of "dummy.com"', + 'Calling next bit of middleware' + ]; + $logEntries=$this->getLoggerStrings(); + $this->assertEquals($expectedLogs,$logEntries); + }//end testInvokerWithOriginArray() + + /** + * Runs a test based on this having: + * - Method: GET + * - origin set to array + * - Origin set to dummy.com + * should get + * Access-Control-Allow-Origin + * and next called. + * + * @test + * @covers \Bairwell\Cors::__construct + * @covers \Bairwell\Cors::__invoke + * @covers \Bairwell\Cors\Traits\Parse::parseOriginMatch + * @covers \Bairwell\Cors\Traits\Parse::parseOrigin + */ + public function testInvokerWithOriginArrayWildcard() + { + $results = $this->runInvoke( + [ + 'method' => 'GET', + 'setHeaders' => ['origin' => 'www.dummy.com'], + 'configuration' => ['origin' => ['example.com', '*.dummy.com']] + ] + ); + $expected = ['withHeader:Access-Control-Allow-Origin' => 'www.dummy.com', 'calledNext' => 'called']; + $this->arraysAreSimilar($results, $expected); + // check logs + $expectedLogs=[ + 'Request has an origin setting and is being treated like a CORs request', + 'Processing origin of "www.dummy.com"', + 'Iterating through Origin array', + 'Checking configuration origin of "example.com" against user "www.dummy.com"', + 'Unable to match "example.com" against user "www.dummy.com"', + 'Checking configuration origin of "*.dummy.com" against user "www.dummy.com"', + 'Wildcarded origin match with www.dummy.com', + 'Iterator found a matched origin of www.dummy.com', + 'Processing with origin of "www.dummy.com"', + 'Calling next bit of middleware' + ]; + $logEntries=$this->getLoggerStrings(); + $this->assertEquals($expectedLogs,$logEntries); + }//end testInvokerWithOriginArrayWildcard() + + /** + * Runs a test based on this having: + * - Method: GET + * - origin set to array + * - Origin set to dummy.com + * should get + * access denied. + * + * @test + * @covers \Bairwell\Cors::__construct + * @covers \Bairwell\Cors::__invoke + * @covers \Bairwell\Cors\Traits\Parse::parseOriginMatch + * @covers \Bairwell\Cors\Traits\Parse::parseOrigin + * @uses \Bairwell\Cors\Exceptions\BadOrigin + */ + public function testInvokerWithOriginArrayInvalid() + { + try { + $results = $this->runInvoke( + [ + 'method' => 'GET', + 'setHeaders' => ['origin' => 'bbc.co.uk'], + 'configuration' => ['origin' => ['example.com', 'dummy.com']] + ] + ); + $this->fail('Expected exception to be raised'); + } catch (BadOrigin $e) { + $this->assertSame('Bad Origin', $e->getMessage()); + $this->assertEmpty($e->getAllowed()); + $this->assertSame('bbc.co.uk', $e->getSent()); + } + // check logs + $expectedLogs=[ + 'Request has an origin setting and is being treated like a CORs request', + 'Processing origin of "bbc.co.uk"', + 'Iterating through Origin array', + 'Checking configuration origin of "example.com" against user "bbc.co.uk"', + 'Unable to match "example.com" against user "bbc.co.uk"', + 'Checking configuration origin of "dummy.com" against user "bbc.co.uk"', + 'Unable to match "dummy.com" against user "bbc.co.uk"' + ]; + $logEntries=$this->getLoggerStrings(); + $this->assertEquals($expectedLogs,$logEntries); + }//end testInvokerWithOriginArrayInvalid() + + /** + * Runs a test based on this having: + * - Method: GET + * - * allowed origin (default) + * - allowCredentials true + * - Origin set to example.com (matching wildcard) + * should get + * Access-Control-Allow-Origin + * Access-Control-Allow-Credentials + * and next called. + * + * @test + * @covers \Bairwell\Cors::__construct + * @covers \Bairwell\Cors::__invoke + */ + public function testInvokerWithOriginHeaderAndCredentials() + { + $results = $this->runInvoke( + [ + 'method' => 'GET', + 'setHeaders' => ['origin' => 'example.com'], + 'configuration' => ['allowCredentials' => true] + ] + ); + $expected = [ + 'withHeader:Access-Control-Allow-Origin' => '*', + 'withHeader:Access-Control-Allow-Credentials' => 'true', + 'calledNext' => 'called' + ]; + $this->arraysAreSimilar($results, $expected); + // check logs + $expectedLogs=[ + 'Request has an origin setting and is being treated like a CORs request', + 'Processing origin of "example.com"', + 'Attempting to match origin as string', + 'Checking configuration origin of "*" against user "example.com"', + 'Origin is either an empty string or wildcarded star. Returning *', + 'Processing with origin of "*"', + 'Adding Access-Control-Allow-Credentials header', + 'Calling next bit of middleware' + ]; + $logEntries=$this->getLoggerStrings(); + $this->assertEquals($expectedLogs,$logEntries); + }//end testInvokerWithOriginHeaderAndCredentials() + + /** + * Runs a test based on this having: + * - Method: GET + * - * allowed origin (default) + * - allowCredentials true + * - Origin set to example.com (matching wildcard) + * should get + * Access-Control-Allow-Origin + * Access-Control-Allow-Credentials + * Access-Control-Expose-Headers + * and next called. + * + * @test + * @covers \Bairwell\Cors::__construct + * @covers \Bairwell\Cors::__invoke + */ + public function testInvokerWithOriginHeaderAndCredentialsWithHeaders() + { + $results = $this->runInvoke( + [ + 'method' => 'GET', + 'setHeaders' => ['origin' => 'example.com'], + 'configuration' => ['allowCredentials' => true, 'exposeHeaders' => 'XY,ZX'] + ] + ); + $expected = [ + 'withHeader:Access-Control-Allow-Origin' => '*', + 'withHeader:Access-Control-Allow-Credentials' => 'true', + 'withHeader:Access-Control-Expose-Headers' => 'XY, ZX', + 'calledNext' => 'called' + ]; + $this->arraysAreSimilar($results, $expected); + // check logs + $expectedLogs=[ + 'Request has an origin setting and is being treated like a CORs request', + 'Processing origin of "example.com"', + 'Attempting to match origin as string', + 'Checking configuration origin of "*" against user "example.com"', + 'Origin is either an empty string or wildcarded star. Returning *', + 'Processing with origin of "*"', + 'Adding Access-Control-Allow-Credentials header', + 'Adding Access-Control-Expose-Header header', + 'Calling next bit of middleware' + ]; + $logEntries=$this->getLoggerStrings(); + $this->assertEquals($expectedLogs,$logEntries); + }//end testInvokerWithOriginHeaderAndCredentialsWithHeaders() +}//end class