diff --git a/CHANGELOG.md b/CHANGELOG.md index 259dbe4a..a303f0dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ Changelog ========= +1.2.0 +----- + +* **2014-12-05** Added support for the symfony/http-kernel component reverse proxy HttpCache. + 1.1.2 ----- diff --git a/README.md b/README.md index 12f1e23f..81fc7b19 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,8 @@ Features * Send [cache invalidation requests](http://foshttpcache.readthedocs.org/en/latest/cache-invalidator.html) with minimal impact on performance. * Use the built-in support for [Varnish](http://foshttpcache.readthedocs.org/en/latest/varnish-configuration.html) - 3 and 4, [Nginx](http://foshttpcache.readthedocs.org/en/latest/nginx-configuration.html) + 3 and 4, [Nginx](http://foshttpcache.readthedocs.org/en/latest/nginx-configuration.html), the + [Symfony reverse proxy from the http-kernel component](http://foshttpcache.readthedocs.org/en/latest/symfony-cache-configuration.html) or easily implement your own caching proxy client. * [Test your application](http://foshttpcache.readthedocs.org/en/latest/testing-your-application.html) against your Varnish or Nginx setup. diff --git a/composer.json b/composer.json index fc9e7dfd..c65311a6 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,8 @@ "guzzle/plugin-mock": "*", "mockery/mockery": "*", "monolog/monolog": "*", - "symfony/process": "~2.3" + "symfony/process": "~2.3", + "symfony/http-kernel": "~2.3" }, "suggest": { "monolog/monolog": "For logging issues while invalidating" @@ -42,7 +43,7 @@ }, "extra": { "branch-alias": { - "dev-master": "1.1.x-dev" + "dev-master": "1.2.x-dev" } } } diff --git a/doc/index.rst b/doc/index.rst index 695fb902..87784999 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -4,12 +4,13 @@ FOSHttpCache This is the documentation for the `FOSHttpCache library `_. This library integrates your PHP applications with HTTP caching proxies such as -Varnish. Use this library to send invalidation requests from your application -to the caching proxy and to test your caching and invalidation setup. +Varnish, Nginx or the Symfony HttpCache class. Use this library to send +invalidation requests from your application to the caching proxy and to test +your caching and invalidation setup. -If you use Symfony2, have a look at the FOSHttpCacheBundle_. The bundle -provides the Invalidator as a service, support for the built-in cache kernel of -Symfony and a number of Symfony2-specific features to help with caching and +If you use the Symfony2 full stack framework, have a look at the FOSHttpCacheBundle_. +The bundle provides the Invalidator as a service, support for the built-in cache +kernel of Symfony and a number of Symfony2-specific features to help with caching and caching proxies. Contents: diff --git a/doc/proxy-clients.rst b/doc/proxy-clients.rst index f05ee39c..6bf416e1 100644 --- a/doc/proxy-clients.rst +++ b/doc/proxy-clients.rst @@ -175,7 +175,7 @@ Varnish client:: )); Make sure to add any headers that you want to ban on to your -:doc:`Varnish configuration `. +:doc:`proxy configuration `. .. _custom guzzle client: diff --git a/doc/proxy-configuration.rst b/doc/proxy-configuration.rst index 459a45ec..6c8352db 100644 --- a/doc/proxy-configuration.rst +++ b/doc/proxy-configuration.rst @@ -11,3 +11,4 @@ know about the other features of the caching proxy to get everything right. varnish-configuration nginx-configuration + symfony-cache-configuration diff --git a/doc/symfony-cache-configuration.rst b/doc/symfony-cache-configuration.rst new file mode 100644 index 00000000..320129fb --- /dev/null +++ b/doc/symfony-cache-configuration.rst @@ -0,0 +1,116 @@ +Symfony HttpCache Configuration +------------------------------- + +The ``symfony/http-kernel`` component provides a reverse proxy implemented +completely in PHP, called `HttpCache`_. While it is certainly less efficient +than using Varnish or Nginx, it can still provide considerable performance +gains over an installation that is not cached at all. It can be useful for +running an application on shared hosting for instance. + +You can use features of this library with the Symfony ``HttpCache``. The basic +concept is to use event subscribers on the HttpCache class. + +.. note:: + + If you are using the full stack Symfony framework, have a look at the + HttpCache provided by the FOSHttpCacheBundle_ instead. + +.. warning:: + + Symfony HttpCache support is currently limited to following features: + + * User context + +Extending the right HttpCache +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Instead of extending ``Symfony\Component\HttpKernel\HttpCache\HttpCache``, your +``AppCache`` should extend ``FOS\HttpCache\SymfonyCache\EventDispatchingHttpCache``. + +.. tip:: + + If your class already needs to extend a different class, simply copy the + event handling code from the EventDispatchingHttpCache into your + ``AppCache`` class. The drawback is that you need to manually check whether + you need to adjust your ``AppCache`` each time you update the FOSHttpCache + library. + +Now that you have an event dispatching kernel, you can make it register the +subscribers you need. While you could do that from your bootstrap code, this is +not the recommended way. You would need to adjust every place you instantiate +the cache. Instead, overwrite the constructor of AppCache and register the +subscribers there. A simple cache will look like this:: + + require_once __DIR__.'/AppKernel.php'; + + use FOS\HttpCache\SymfonyCache\EventDispatchingHttpCache; + use FOS\HttpCache\SymfonyCache\UserContextSubscriber; + + class AppCache extends EventDispatchingHttpCache + { + /** + * Overwrite constructor to register event subscribers for FOSHttpCache. + */ + public function __construct(HttpKernelInterface $kernel, $cacheDir = null) + { + parent::__construct($kernel, $cacheDir); + + $this->addSubscriber(new UserContextSubscriber()); + } + } + +User Context +~~~~~~~~~~~~ + +To support :doc:`user context hashing ` you need to register the +``UserContextSubscriber``. If the default settings are right for you, you don't +need to do anything more. You can customize a number of options through the +constructor: + +* **anonymous_hash**: Hash used for anonymous user. This is a performance + optimization to not do a backend request for users that are not logged in. + +* **user_hash_accept_header**: Accept header value to be used to request the + user hash to the backend application. Must match the setup of the backend + application. + + **default**: ``application/vnd.fos.user-context-hash`` + +* **user_hash_header**: Name of the header the user context hash will be stored + into. Must match the setup for the Vary header in the backend application. + + **default**: ``X-User-Context-Hash`` + +* **user_hash_uri**: Target URI used in the request for user context hash + generation. + + **default**: ``/_fos_user_context_hash`` + +* **user_hash_method**: HTTP Method used with the hash lookup request for user + context hash generation. + + **default**: ``GET`` + +* **session_name_prefix**: Prefix for session cookies. Must match your PHP session configuration. + + **default**: ``PHPSESSID`` + +.. warning:: + + If you have a customized session name, it is **very important** that this + constant matches it. + Session IDs are indeed used as keys to cache the generated use context hash. + + Wrong session name will lead to unexpected results such as having the same + user context hash for every users, + or not having it cached at all (painful for performance. + +Cleaning the Cookie Header +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +By default, the UserContextSubscriber only sets the session cookie (according to +the ``session_name_prefix`` option) in the requests to the backend. If you need +a different behaviour, overwrite ``UserContextSubscriber::cleanupHashLookupRequest`` +with your own logic. + +.. _HttpCache: http://symfony.com/doc/current/book/http_cache.html#symfony-reverse-proxy diff --git a/doc/user-context.rst b/doc/user-context.rst index 9e55daa7..e27b5fff 100644 --- a/doc/user-context.rst +++ b/doc/user-context.rst @@ -43,8 +43,9 @@ Caching on user context works as follows: Proxy Client Configuration -------------------------- -Currently, user context caching is only supported by Varnish. See the -:ref:`Varnish Configuration ` on how to prepare Varnish properly. +Currently, user context caching is only supported by Varnish and by the Symfony +HttpCache. See the :ref:`Varnish Configuration ` or +:doc:`Symfony HttpCache Configuration `. Calculating the User Context Hash --------------------------------- diff --git a/src/SymfonyCache/CacheEvent.php b/src/SymfonyCache/CacheEvent.php new file mode 100644 index 00000000..ece60f96 --- /dev/null +++ b/src/SymfonyCache/CacheEvent.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\HttpCache\SymfonyCache; + +use Symfony\Component\EventDispatcher\Event; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\HttpCache\HttpCache; + +/** + * Event raised by the HttpCache kernel. + * + * @author David Buchmann + */ +class CacheEvent extends Event +{ + /** + * @var HttpCache + */ + private $kernel; + + /** + * @var Request + */ + private $request; + + /** + * @var Response + */ + private $response; + + /** + * @param HttpCache $kernel The kernel raising with this event. + * @param Request $request The request being processed. + */ + public function __construct(HttpCache $kernel, Request $request) + { + $this->kernel = $kernel; + $this->request = $request; + } + + /** + * Get the cache kernel that raised this event. + * + * @return HttpCache + */ + public function getKernel() + { + return $this->kernel; + } + + /** + * Get the request that is being processed. + * + * @return Request + */ + public function getRequest() + { + return $this->request; + } + + /** + * @return Response|null The response if one was set. + */ + public function getResponse() + { + return $this->response; + } + + /** + * Sets a response to use instead of continuing to handle this request. + * + * Setting a response stops propagation of the event to further event handlers. + * + * @param Response $response + */ + public function setResponse(Response $response) + { + $this->response = $response; + + $this->stopPropagation(); + } +} diff --git a/src/SymfonyCache/EventDispatchingHttpCache.php b/src/SymfonyCache/EventDispatchingHttpCache.php new file mode 100644 index 00000000..44b4d97d --- /dev/null +++ b/src/SymfonyCache/EventDispatchingHttpCache.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\HttpCache\SymfonyCache; + +use Symfony\Component\HttpKernel\HttpCache\HttpCache; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\HttpKernelInterface; + +/** + * Base class for enhanced Symfony reverse proxy based on the symfony component. + * + * When using FOSHttpCacheBundle, look at FOS\HttpCacheBundle\HttpCache instead. + * + * This kernel supports event subscribers that can act on the events defined in + * FOS\HttpCache\SymfonyCache\Events and may alter the request flow. + * + * @author Jérôme Vieilledent (courtesy of eZ Systems AS) + * + * {@inheritdoc} + */ +abstract class EventDispatchingHttpCache extends HttpCache +{ + /** + * @var EventDispatcherInterface + */ + private $eventDispatcher; + + /** + * Get event dispatcher + * + * @return EventDispatcherInterface + */ + public function getEventDispatcher() + { + if (null === $this->eventDispatcher) { + $this->eventDispatcher = new EventDispatcher(); + } + + return $this->eventDispatcher; + } + + /** + * Add subscriber + * + * @param EventSubscriberInterface $subscriber + */ + public function addSubscriber(EventSubscriberInterface $subscriber) + { + $this->getEventDispatcher()->addSubscriber($subscriber); + } + + /** + * {@inheritDoc} + * + * Adding the Events::PRE_HANDLE event. + */ + public function handle(Request $request, $type = HttpKernelInterface::MASTER_REQUEST, $catch = true) + { + if ($this->getEventDispatcher()->hasListeners(Events::PRE_HANDLE)) { + $event = new CacheEvent($this, $request); + $this->getEventDispatcher()->dispatch(Events::PRE_HANDLE, $event); + if ($event->getResponse()) { + return $event->getResponse(); + } + } + + return parent::handle($request, $type, $catch); + } +} diff --git a/src/SymfonyCache/Events.php b/src/SymfonyCache/Events.php new file mode 100644 index 00000000..99a7f51a --- /dev/null +++ b/src/SymfonyCache/Events.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\HttpCache\SymfonyCache; + +/** + * Events used in the customized Symfony built-in reverse proxy HttpCache. + */ +final class Events +{ + const PRE_HANDLE = 'fos_http_cache.pre_handle'; +} diff --git a/src/SymfonyCache/UserContextSubscriber.php b/src/SymfonyCache/UserContextSubscriber.php new file mode 100644 index 00000000..265219d2 --- /dev/null +++ b/src/SymfonyCache/UserContextSubscriber.php @@ -0,0 +1,214 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\HttpCache\SymfonyCache; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\HttpKernelInterface; + +/** + * User context handler for the symfony built-in HttpCache. + * + * @author Jérôme Vieilledent (courtesy of eZ Systems AS) + * + * {@inheritdoc} + */ +class UserContextSubscriber implements EventSubscriberInterface +{ + /** + * The options configured in the constructor argument or default values. + * + * @var array + */ + private $options; + + /** + * Generated user hash. + * + * @var string + */ + private $userHash; + + /** + * When creating this subscriber, you can configure a number of options. + * + * - anonymous_hash: Hash used for anonymous user. + * - user_hash_accept_header: Accept header value to be used to request the user hash to the + * backend application. Must match the setup of the backend application. + * - user_hash_header: Name of the header the user context hash will be stored into. Must + * match the setup for the Vary header in the backend application. + * - user_hash_uri: Target URI used in the request for user context hash generation. + * - user_hash_method: HTTP Method used with the hash lookup request for user context hash generation. + * - session_name_prefix: Prefix for session cookies. Must match your PHP session configuration. + * + * @param array $options Options to overwrite the default options + */ + public function __construct(array $options = array()) + { + $this->options = $options + array( + 'anonymous_hash' => '38015b703d82206ebc01d17a39c727e5', + 'user_hash_accept_header' => 'application/vnd.fos.user-context-hash', + 'user_hash_header' => 'X-User-Context-Hash', + 'user_hash_uri' => '/_fos_user_context_hash', + 'user_hash_method' => 'GET', + 'session_name_prefix' => 'PHPSESSID', + ); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() + { + return array( + Events::PRE_HANDLE => 'preHandle', + ); + } + + /** + * Look at the request before it is handled by the kernel. + * + * Adds the user hash header to the request. + * + * Checks if an external request tries tampering with the use context hash mechanism + * to prevent attacks. + * + * @param CacheEvent $event + */ + public function preHandle(CacheEvent $event) + { + $request = $event->getRequest(); + if (!$this->isInternalRequest($request)) { + // Prevent tampering attacks on the hash mechanism + if ($request->headers->get('accept') === $this->options['user_hash_accept_header'] + || $request->headers->get($this->options['user_hash_header']) !== null + ) { + $event->setResponse(new Response('', 400)); + + return; + } + + if ($request->isMethodSafe()) { + $request->headers->set($this->options['user_hash_header'], $this->getUserHash($event->getKernel(), $request)); + } + } + + // let the kernel handle this request. + } + + /** + * Remove unneeded things from the request for user hash generation. + * + * Cleans cookies header to only keep the session identifier cookie, so the hash lookup request + * can be cached per session. + * + * @param Request $hashLookupRequest + * @param Request $originalRequest + */ + protected function cleanupHashLookupRequest(Request $hashLookupRequest, Request $originalRequest) + { + $sessionIds = array(); + foreach ($originalRequest->cookies as $name => $value) { + if ($this->isSessionName($name)) { + $sessionIds[$name] = $value; + $hashLookupRequest->cookies->set($name, $value); + } + } + $hashLookupRequest->headers->set('Cookie', http_build_query($sessionIds, '', '; ')); + } + + /** + * Checks if passed request object is to be considered internal (e.g. for user hash lookup). + * + * @param Request $request + * + * @return bool + */ + private function isInternalRequest(Request $request) + { + return $request->attributes->get('internalRequest', false) === true; + } + + /** + * Returns the user context hash for $request. + * + * @param Request $request + * + * @return string + */ + private function getUserHash(HttpKernelInterface $kernel, Request $request) + { + if (isset($this->userHash)) { + return $this->userHash; + } + + if ($this->isAnonymous($request)) { + return $this->userHash = $this->options['anonymous_hash']; + } + + // Hash lookup request to let the backend generate the user hash + $hashLookupRequest = $this->generateHashLookupRequest($request); + $resp = $kernel->handle($hashLookupRequest); + // Store the user hash in memory for sub-requests (processed in the same thread). + $this->userHash = $resp->headers->get($this->options['user_hash_header']); + + return $this->userHash; + } + + /** + * Checks if current request is considered anonymous. + * + * @param Request $request + * + * @return bool + */ + private function isAnonymous(Request $request) + { + foreach ($request->cookies as $name => $value) { + if ($this->isSessionName($name)) { + return false; + } + } + + return true; + } + + /** + * Checks if passed string can be considered as a session name, such as would be used in cookies. + * + * @param string $name + * + * @return bool + */ + private function isSessionName($name) + { + return strpos($name, $this->options['session_name_prefix']) === 0; + } + + /** + * Generates the request object that will be forwarded to get the user context hash. + * + * @param Request $request + * + * @return Request The request that will return the user context hash value. + */ + private function generateHashLookupRequest(Request $request) + { + $hashLookupRequest = Request::create($this->options['user_hash_uri'], $this->options['user_hash_method'], array(), array(), array(), $request->server->all()); + $hashLookupRequest->attributes->set('internalRequest', true); + $hashLookupRequest->headers->set('Accept', $this->options['user_hash_accept_header']); + $this->cleanupHashLookupRequest($hashLookupRequest, $request); + + return $hashLookupRequest; + } +} diff --git a/tests/Unit/SymfonyCache/EventDispatchingHttpCacheTest.php b/tests/Unit/SymfonyCache/EventDispatchingHttpCacheTest.php new file mode 100644 index 00000000..df08cc1d --- /dev/null +++ b/tests/Unit/SymfonyCache/EventDispatchingHttpCacheTest.php @@ -0,0 +1,131 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\HttpCache\Tests\Unit\SymfonyCache; + +use FOS\HttpCache\SymfonyCache\EventDispatchingHttpCache; +use FOS\HttpCache\SymfonyCache\CacheEvent; +use FOS\HttpCache\SymfonyCache\Events; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\HttpKernelInterface; + +class EventDispatchingHttpCacheTest extends \PHPUnit_Framework_TestCase +{ + /** + * @return EventDispatchingHttpCache|\PHPUnit_Framework_MockObject_MockObject + */ + protected function getHttpCachePartialMock(array $mockedMethods = null) + { + $mock = $this + ->getMockBuilder('\FOS\HttpCache\SymfonyCache\EventDispatchingHttpCache') + ->setMethods( $mockedMethods ) + ->disableOriginalConstructor() + ->getMock() + ; + + // Force setting options property since we can't use original constructor. + $options = array( + 'debug' => false, + 'default_ttl' => 0, + 'private_headers' => array( 'Authorization', 'Cookie' ), + 'allow_reload' => false, + 'allow_revalidate' => false, + 'stale_while_revalidate' => 2, + 'stale_if_error' => 60, + ); + + $refHttpCache = new \ReflectionClass('Symfony\Component\HttpKernel\HttpCache\HttpCache'); + // Workaround for Symfony 2.3 where $options property is not defined. + if (!$refHttpCache->hasProperty('options')) { + $mock->options = $options; + } else { + $refOptions = $refHttpCache->getProperty('options'); + $refOptions->setAccessible(true); + $refOptions->setValue($mock, $options ); + } + + return $mock; + } + + public function testCalled() + { + $catch = true; + $request = Request::create('/foo', 'GET'); + $response = new Response(); + + $httpCache = $this->getHttpCachePartialMock(array('lookup')); + $subscriber = new TestSubscriber($this, $httpCache, $request); + $httpCache->addSubscriber($subscriber); + $httpCache + ->expects($this->any()) + ->method('lookup') + ->with($request) + ->will($this->returnValue($response)) + ; + $httpCache->handle($request); + + $this->assertEquals(1, $subscriber->hits); + $this->assertSame($response, $httpCache->handle($request, HttpKernelInterface::MASTER_REQUEST, $catch)); + } + + public function testAbort() + { + $catch = true; + $request = Request::create('/foo', 'GET'); + $response = new Response(); + + $httpCache = $this->getHttpCachePartialMock(array('lookup')); + $subscriber = new TestSubscriber($this, $httpCache, $request, $response); + $httpCache->addSubscriber($subscriber); + $httpCache + ->expects($this->never()) + ->method('lookup') + ; + $httpCache->handle($request); + + $this->assertEquals(1, $subscriber->hits); + $this->assertSame($response, $httpCache->handle($request, HttpKernelInterface::MASTER_REQUEST, $catch)); + } +} + +class TestSubscriber implements EventSubscriberInterface +{ + public $hits = 0; + private $test; + private $kernel; + private $request; + private $response; + + public function __construct($test, $kernel, $request, $response = null) + { + $this->test = $test; + $this->kernel = $kernel; + $this->request = $request; + $this->response = $response; + } + + public static function getSubscribedEvents() + { + return array(Events::PRE_HANDLE => 'preHandle'); + } + + public function preHandle(CacheEvent $event) + { + $this->test->assertSame($this->kernel, $event->getKernel()); + $this->test->assertSame($this->request, $event->getRequest()); + if ($this->response) { + $event->setResponse($this->response); + } + $this->hits++; + } +} diff --git a/tests/Unit/SymfonyCache/UserContextSubscriberTest.php b/tests/Unit/SymfonyCache/UserContextSubscriberTest.php new file mode 100644 index 00000000..0940f5a5 --- /dev/null +++ b/tests/Unit/SymfonyCache/UserContextSubscriberTest.php @@ -0,0 +1,179 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\HttpCache\Tests\Unit\SymfonyCache; + +use FOS\HttpCache\SymfonyCache\CacheEvent; +use FOS\HttpCache\SymfonyCache\UserContextSubscriber; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\HttpCache\HttpCache; + +class UserContextSubscriberTest extends \PHPUnit_Framework_TestCase +{ + /** + * @var HttpCache|\PHPUnit_Framework_MockObject_MockObject + */ + private $kernel; + + public function setUp() + { + $this->kernel = $this + ->getMockBuilder('Symfony\Component\HttpKernel\HttpCache\HttpCache') + ->disableOriginalConstructor() + ->getMock() + ; + } + + /** + * UserContextSubscriber default options to simulate the correct headers. + * + * @return array + */ + public function provideConfigOptions() + { + $subscriber = new UserContextSubscriber(); + $ref = new \ReflectionObject($subscriber); + $prop = $ref->getProperty('options'); + $prop->setAccessible(true); + $options = $prop->getValue($subscriber); + + $custom = array( + 'user_hash_uri' => '/test-uri', + 'user_hash_header' => 'test/header', + 'user_hash_accept_header' => 'test accept', + 'anonymous_hash' => 'test hash', + );; + + return array( + array(array(), $options), + array($custom, $custom + $options) + ); + } + + /** + * @dataProvider provideConfigOptions + */ + public function testGenerateUserHashNotAllowed($arg, $options) + { + $userContextSubscriber = new UserContextSubscriber($arg); + + $request = new Request(); + $request->headers->set('accept', $options['user_hash_accept_header']); + $event = new CacheEvent($this->kernel, $request); + + $userContextSubscriber->preHandle($event); + $response = $event->getResponse(); + + $this->assertInstanceOf('Symfony\\Component\\HttpFoundation\\Response', $response); + $this->assertSame(400, $response->getStatusCode()); + } + + /** + * @dataProvider provideConfigOptions + */ + public function testPassingUserHashNotAllowed($arg, $options) + { + $userContextSubscriber = new UserContextSubscriber($arg); + + $request = new Request(); + $request->headers->set($options['user_hash_header'], 'foo'); + $event = new CacheEvent($this->kernel, $request); + + $userContextSubscriber->preHandle($event); + $response = $event->getResponse(); + + $this->assertInstanceOf('Symfony\\Component\\HttpFoundation\\Response', $response); + $this->assertSame(400, $response->getStatusCode()); + } + + /** + * @dataProvider provideConfigOptions + */ + public function testUserHashAnonymous($arg, $options) + { + $userContextSubscriber = new UserContextSubscriber($arg); + + $request = new Request(); + + $event = new CacheEvent($this->kernel, $request); + + $userContextSubscriber->preHandle($event); + $response = $event->getResponse(); + + $this->assertNull($response); + $this->assertTrue($request->headers->has($options['user_hash_header'])); + $this->assertSame($options['anonymous_hash'], $request->headers->get($options['user_hash_header'])); + } + + /** + * @dataProvider provideConfigOptions + */ + public function testUserHashUserWithSession($arg, $options) + { + $userContextSubscriber = new UserContextSubscriber($arg); + + $catch = true; + $sessionId1 = 'my_session_id'; + $sessionId2 = 'another_session_id'; + $cookies = array( + 'PHPSESSID' => $sessionId1, + 'PHPSESSIDsdiuhsdf4535d4f' => $sessionId2, + 'foo' => 'bar' + ); + $cookieString = "PHPSESSID=$sessionId1; foo=bar; PHPSESSIDsdiuhsdf4535d4f=$sessionId2"; + $request = Request::create('/foo', 'GET', array(), $cookies, array(), array('Cookie' => $cookieString)); + + $hashRequest = Request::create($options['user_hash_uri'], $options['user_hash_method'], array(), array(), array(), $request->server->all()); + $hashRequest->attributes->set('internalRequest', true); + $hashRequest->headers->set('Accept', $options['user_hash_accept_header']); + $hashRequest->headers->set('Cookie', "PHPSESSID=$sessionId1; PHPSESSIDsdiuhsdf4535d4f=$sessionId2"); + $hashRequest->cookies->set('PHPSESSID', $sessionId1); + $hashRequest->cookies->set('PHPSESSIDsdiuhsdf4535d4f', $sessionId2); + // Ensure request properties have been filled up. + $hashRequest->getPathInfo(); + $hashRequest->getMethod(); + + $expectedContextHash = 'my_generated_hash'; + // Just avoid the response to modify the request object, otherwise it's impossible to test objects equality. + /** @var \Symfony\Component\HttpFoundation\Response|\PHPUnit_Framework_MockObject_MockObject $hashResponse */ + $hashResponse = $this->getMockBuilder('\Symfony\Component\HttpFoundation\Response') + ->setMethods(array('prepare')) + ->getMock(); + $hashResponse->headers->set($options['user_hash_header'], $expectedContextHash ); + + $that = $this; + $this->kernel + ->expects($this->once()) + ->method('handle') + ->with( + $this->callback(function (Request $request) use ($that, $hashRequest) { + // we need to call some methods to get the internal fields initialized + $request->getMethod(); + $request->getPathInfo(); + $that->assertEquals($hashRequest, $request); + + return true; + }), + $catch + ) + ->will($this->returnValue($hashResponse)); + + $event = new CacheEvent($this->kernel, $request); + + $userContextSubscriber->preHandle($event); + $response = $event->getResponse(); + + $this->assertNull($response); + $this->assertTrue($request->headers->has($options['user_hash_header'])); + $this->assertSame($expectedContextHash, $request->headers->get($options['user_hash_header'])); + } +}