diff --git a/src/Symfony/Bridge/PhpUnit/bin/simple-phpunit.php b/src/Symfony/Bridge/PhpUnit/bin/simple-phpunit.php index 3c98499fb4..452af47fd3 100644 --- a/src/Symfony/Bridge/PhpUnit/bin/simple-phpunit.php +++ b/src/Symfony/Bridge/PhpUnit/bin/simple-phpunit.php @@ -24,15 +24,47 @@ $getEnvVar = function ($name, $default = false) use ($argv) { static $phpunitConfig = null; if (null === $phpunitConfig) { - $opt = min(array_search('-c', $opts = array_reverse($argv), true) ?: INF, array_search('--configuration', $opts, true) ?: INF); $phpunitConfigFilename = null; - if (INF !== $opt && isset($opts[$opt - 1])) { - $phpunitConfigFilename = $opts[$opt - 1]; - } elseif (file_exists('phpunit.xml')) { - $phpunitConfigFilename = 'phpunit.xml'; - } elseif (file_exists('phpunit.xml.dist')) { - $phpunitConfigFilename = 'phpunit.xml.dist'; + $getPhpUnitConfig = function ($probableConfig) use (&$getPhpUnitConfig) { + if (!$probableConfig) { + return null; + } + if (is_dir($probableConfig)) { + return $getPhpUnitConfig($probableConfig.DIRECTORY_SEPARATOR.'phpunit.xml'); + } + + if (file_exists($probableConfig)) { + return $probableConfig; + } + if (file_exists($probableConfig.'.dist')) { + return $probableConfig.'.dist'; + } + + return null; + }; + + foreach ($argv as $cliArgumentIndex => $cliArgument) { + if ('--' === $cliArgument) { + break; + } + // long option + if ('--configuration' === $cliArgument && array_key_exists($cliArgumentIndex + 1, $argv)) { + $phpunitConfigFilename = $getPhpUnitConfig($argv[$cliArgumentIndex + 1]); + break; + } + // short option + if (0 === strpos($cliArgument, '-c')) { + if ('-c' === $cliArgument && array_key_exists($cliArgumentIndex + 1, $argv)) { + $phpunitConfigFilename = $getPhpUnitConfig($argv[$cliArgumentIndex + 1]); + } else { + $phpunitConfigFilename = $getPhpUnitConfig(substr($cliArgument, 2)); + } + break; + } } + + $phpunitConfigFilename = $phpunitConfigFilename ?: $getPhpUnitConfig('phpunit.xml'); + if ($phpunitConfigFilename) { $phpunitConfig = new DomDocument(); $phpunitConfig->load($phpunitConfigFilename); diff --git a/src/Symfony/Component/HttpClient/HttpClientTrait.php b/src/Symfony/Component/HttpClient/HttpClientTrait.php index 7cb82c73e5..2486f04bc8 100644 --- a/src/Symfony/Component/HttpClient/HttpClientTrait.php +++ b/src/Symfony/Component/HttpClient/HttpClientTrait.php @@ -110,7 +110,7 @@ trait HttpClientTrait throw new InvalidArgumentException(sprintf('Option "auth_basic" must be string or an array, %s given.', \gettype($options['auth_basic']))); } - if (isset($options['auth_bearer']) && (!\is_string($options['auth_bearer']) || !preg_match('{^[-._~+/0-9a-zA-Z]++=*+$}', $options['auth_bearer']))) { + if (isset($options['auth_bearer']) && (!\is_string($options['auth_bearer']) || !preg_match('{^[-._=~+/0-9a-zA-Z]++$}', $options['auth_bearer']))) { throw new InvalidArgumentException(sprintf('Option "auth_bearer" must be a string containing only characters from the base 64 alphabet, %s given.', \is_string($options['auth_bearer']) ? 'invalid string' : \gettype($options['auth_bearer']))); } diff --git a/src/Symfony/Component/HttpFoundation/Response.php b/src/Symfony/Component/HttpFoundation/Response.php index c1a2e14887..05f740b6ad 100644 --- a/src/Symfony/Component/HttpFoundation/Response.php +++ b/src/Symfony/Component/HttpFoundation/Response.php @@ -628,7 +628,7 @@ class Response } /** - * Returns true if the response must be revalidated by caches. + * Returns true if the response must be revalidated by shared caches once it has become stale. * * This method indicates that the response must not be served stale by a * cache in any circumstance without first revalidating with the origin. diff --git a/src/Symfony/Component/HttpKernel/HttpCache/HttpCache.php b/src/Symfony/Component/HttpKernel/HttpCache/HttpCache.php index 21e6c3df2d..6c4715802e 100644 --- a/src/Symfony/Component/HttpKernel/HttpCache/HttpCache.php +++ b/src/Symfony/Component/HttpKernel/HttpCache/HttpCache.php @@ -476,13 +476,37 @@ class HttpCache implements HttpKernelInterface, TerminableInterface // always a "master" request (as the real master request can be in cache) $response = SubRequestHandler::handle($this->kernel, $request, HttpKernelInterface::MASTER_REQUEST, $catch); - // we don't implement the stale-if-error on Requests, which is nonetheless part of the RFC - if (null !== $entry && \in_array($response->getStatusCode(), [500, 502, 503, 504])) { + /* + * Support stale-if-error given on Responses or as a config option. + * RFC 7234 summarizes in Section 4.2.4 (but also mentions with the individual + * Cache-Control directives) that + * + * A cache MUST NOT generate a stale response if it is prohibited by an + * explicit in-protocol directive (e.g., by a "no-store" or "no-cache" + * cache directive, a "must-revalidate" cache-response-directive, or an + * applicable "s-maxage" or "proxy-revalidate" cache-response-directive; + * see Section 5.2.2). + * + * https://tools.ietf.org/html/rfc7234#section-4.2.4 + * + * We deviate from this in one detail, namely that we *do* serve entries in the + * stale-if-error case even if they have a `s-maxage` Cache-Control directive. + */ + if (null !== $entry + && \in_array($response->getStatusCode(), [500, 502, 503, 504]) + && !$entry->headers->hasCacheControlDirective('no-cache') + && !$entry->mustRevalidate() + ) { if (null === $age = $entry->headers->getCacheControlDirective('stale-if-error')) { $age = $this->options['stale_if_error']; } - if (abs($entry->getTtl()) < $age) { + /* + * stale-if-error gives the (extra) time that the Response may be used *after* it has become stale. + * So we compare the time the $entry has been sitting in the cache already with the + * time it was fresh plus the allowed grace period. + */ + if ($entry->getAge() <= $entry->getMaxAge() + $age) { $this->record($request, 'stale-if-error'); return $entry; diff --git a/src/Symfony/Component/HttpKernel/Tests/HttpCache/HttpCacheTest.php b/src/Symfony/Component/HttpKernel/Tests/HttpCache/HttpCacheTest.php index 34f1dc7f3b..9c64b98fee 100644 --- a/src/Symfony/Component/HttpKernel/Tests/HttpCache/HttpCacheTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/HttpCache/HttpCacheTest.php @@ -1522,6 +1522,168 @@ class HttpCacheTest extends HttpCacheTestCase $cache->handle($request, HttpKernelInterface::SUB_REQUEST); } + public function testStaleIfErrorMustNotResetLifetime() + { + // Make sure we don't accidentally treat the response as fresh (revalidated) again + // when stale-if-error handling kicks in. + + $responses = [ + [ + 'status' => 200, + 'body' => 'OK', + // This is cacheable and can be used in stale-if-error cases: + 'headers' => ['Cache-Control' => 'public, max-age=10', 'ETag' => 'some-etag'], + ], + [ + 'status' => 500, + 'body' => 'FAIL', + 'headers' => [], + ], + [ + 'status' => 500, + 'body' => 'FAIL', + 'headers' => [], + ], + ]; + + $this->setNextResponses($responses); + $this->cacheConfig['stale_if_error'] = 10; + + $this->request('GET', '/'); // warm cache + + sleep(15); // now the entry is stale, but still within the grace period (10s max-age + 10s stale-if-error) + + $this->request('GET', '/'); // hit backend error + $this->assertEquals(200, $this->response->getStatusCode()); // stale-if-error saved the day + $this->assertEquals(15, $this->response->getAge()); + + sleep(10); // now we're outside the grace period + + $this->request('GET', '/'); // hit backend error + $this->assertEquals(500, $this->response->getStatusCode()); // fail + } + + /** + * @dataProvider getResponseDataThatMayBeServedStaleIfError + */ + public function testResponsesThatMayBeUsedStaleIfError($responseHeaders, $sleepBetweenRequests = null) + { + $responses = [ + [ + 'status' => 200, + 'body' => 'OK', + 'headers' => $responseHeaders, + ], + [ + 'status' => 500, + 'body' => 'FAIL', + 'headers' => [], + ], + ]; + + $this->setNextResponses($responses); + $this->cacheConfig['stale_if_error'] = 10; // after stale, may be served for 10s + + $this->request('GET', '/'); // warm cache + + if ($sleepBetweenRequests) { + sleep($sleepBetweenRequests); + } + + $this->request('GET', '/'); // hit backend error + + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('OK', $this->response->getContent()); + $this->assertTraceContains('stale-if-error'); + } + + public function getResponseDataThatMayBeServedStaleIfError() + { + // All data sets assume that a 10s stale-if-error grace period has been configured + yield 'public, max-age expired' => [['Cache-Control' => 'public, max-age=60'], 65]; + yield 'public, validateable with ETag, no TTL' => [['Cache-Control' => 'public', 'ETag' => 'some-etag'], 5]; + yield 'public, validateable with Last-Modified, no TTL' => [['Cache-Control' => 'public', 'Last-Modified' => 'yesterday'], 5]; + yield 'public, s-maxage will be served stale-if-error, even if the RFC mandates otherwise' => [['Cache-Control' => 'public, s-maxage=20'], 25]; + } + + /** + * @dataProvider getResponseDataThatMustNotBeServedStaleIfError + */ + public function testResponsesThatMustNotBeUsedStaleIfError($responseHeaders, $sleepBetweenRequests = null) + { + $responses = [ + [ + 'status' => 200, + 'body' => 'OK', + 'headers' => $responseHeaders, + ], + [ + 'status' => 500, + 'body' => 'FAIL', + 'headers' => [], + ], + ]; + + $this->setNextResponses($responses); + $this->cacheConfig['stale_if_error'] = 10; // after stale, may be served for 10s + $this->cacheConfig['strict_smaxage'] = true; // full RFC compliance for this feature + + $this->request('GET', '/'); // warm cache + + if ($sleepBetweenRequests) { + sleep($sleepBetweenRequests); + } + + $this->request('GET', '/'); // hit backend error + + $this->assertEquals(500, $this->response->getStatusCode()); + } + + public function getResponseDataThatMustNotBeServedStaleIfError() + { + // All data sets assume that a 10s stale-if-error grace period has been configured + yield 'public, no TTL but beyond grace period' => [['Cache-Control' => 'public'], 15]; + yield 'public, validateable with ETag, no TTL but beyond grace period' => [['Cache-Control' => 'public', 'ETag' => 'some-etag'], 15]; + yield 'public, validateable with Last-Modified, no TTL but beyond grace period' => [['Cache-Control' => 'public', 'Last-Modified' => 'yesterday'], 15]; + yield 'public, stale beyond grace period' => [['Cache-Control' => 'public, max-age=10'], 30]; + + // Cache-control values that prohibit serving stale responses or responses without positive validation - + // see https://tools.ietf.org/html/rfc7234#section-4.2.4 and + // https://tools.ietf.org/html/rfc7234#section-5.2.2 + yield 'no-cache requires positive validation' => [['Cache-Control' => 'public, no-cache', 'ETag' => 'some-etag']]; + yield 'no-cache requires positive validation, even if fresh' => [['Cache-Control' => 'public, no-cache, max-age=10']]; + yield 'must-revalidate requires positive validation once stale' => [['Cache-Control' => 'public, max-age=10, must-revalidate'], 15]; + yield 'proxy-revalidate requires positive validation once stale' => [['Cache-Control' => 'public, max-age=10, proxy-revalidate'], 15]; + } + + public function testStaleIfErrorWhenStrictSmaxageDisabled() + { + $responses = [ + [ + 'status' => 200, + 'body' => 'OK', + 'headers' => ['Cache-Control' => 'public, s-maxage=20'], + ], + [ + 'status' => 500, + 'body' => 'FAIL', + 'headers' => [], + ], + ]; + + $this->setNextResponses($responses); + $this->cacheConfig['stale_if_error'] = 10; + $this->cacheConfig['strict_smaxage'] = false; + + $this->request('GET', '/'); // warm cache + sleep(25); + $this->request('GET', '/'); // hit backend error + + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('OK', $this->response->getContent()); + $this->assertTraceContains('stale-if-error'); + } + public function testTraceHeaderNameCanBeChanged() { $this->cacheConfig['trace_header'] = 'X-My-Header'; diff --git a/src/Symfony/Component/Security/Core/User/ChainUserProvider.php b/src/Symfony/Component/Security/Core/User/ChainUserProvider.php index 16bdcc5a63..cdce7a9bf6 100644 --- a/src/Symfony/Component/Security/Core/User/ChainUserProvider.php +++ b/src/Symfony/Component/Security/Core/User/ChainUserProvider.php @@ -91,7 +91,7 @@ class ChainUserProvider implements UserProviderInterface, PasswordUpgraderInterf $e->setUsername($user->getUsername()); throw $e; } else { - throw new UnsupportedUserException(sprintf('The account "%s" is not supported.', \get_class($user))); + throw new UnsupportedUserException(sprintf('There is no user provider for user "%s". Shouldn\'t the "supportsClass()" method of your user provider return true for this classname?', \get_class($user))); } } diff --git a/src/Symfony/Component/Security/Http/Firewall/ContextListener.php b/src/Symfony/Component/Security/Http/Firewall/ContextListener.php index 9193a5c5c4..9243119acb 100644 --- a/src/Symfony/Component/Security/Http/Firewall/ContextListener.php +++ b/src/Symfony/Component/Security/Http/Firewall/ContextListener.php @@ -292,7 +292,7 @@ class ContextListener extends AbstractListener implements ListenerInterface return null; } - throw new \RuntimeException(sprintf('There is no user provider for user "%s".', $userClass)); + throw new \RuntimeException(sprintf('There is no user provider for user "%s". Shouldn\'t the "supportsClass()" method of your user provider return true for this classname?', $userClass)); } private function safelyUnserialize(string $serializedToken) diff --git a/src/Symfony/Component/Security/Http/RememberMe/AbstractRememberMeServices.php b/src/Symfony/Component/Security/Http/RememberMe/AbstractRememberMeServices.php index 9c9e1c07cc..869195995b 100644 --- a/src/Symfony/Component/Security/Http/RememberMe/AbstractRememberMeServices.php +++ b/src/Symfony/Component/Security/Http/RememberMe/AbstractRememberMeServices.php @@ -234,7 +234,7 @@ abstract class AbstractRememberMeServices implements RememberMeServicesInterface } } - throw new UnsupportedUserException(sprintf('There is no user provider that supports class "%s".', $class)); + throw new UnsupportedUserException(sprintf('There is no user provider for user "%s". Shouldn\'t the "supportsClass()" method of your user provider return true for this classname?', $class)); } /** diff --git a/src/Symfony/Component/Validator/Mapping/PropertyMetadata.php b/src/Symfony/Component/Validator/Mapping/PropertyMetadata.php index 74ecf02cf3..2a8d3f4f56 100644 --- a/src/Symfony/Component/Validator/Mapping/PropertyMetadata.php +++ b/src/Symfony/Component/Validator/Mapping/PropertyMetadata.php @@ -48,7 +48,13 @@ class PropertyMetadata extends MemberMetadata */ public function getPropertyValue($object) { - return $this->getReflectionMember($object)->getValue($object); + $reflProperty = $this->getReflectionMember($object); + + if (\PHP_VERSION_ID >= 70400 && !$reflProperty->isInitialized($object)) { + return null; + } + + return $reflProperty->getValue($object); } /** diff --git a/src/Symfony/Component/Validator/Tests/Fixtures/Entity_74.php b/src/Symfony/Component/Validator/Tests/Fixtures/Entity_74.php new file mode 100644 index 0000000000..cb22fb7f72 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Fixtures/Entity_74.php @@ -0,0 +1,8 @@ +expectException('Symfony\Component\Validator\Exception\ValidatorException'); $metadata->getPropertyValue($entity); } + + /** + * @requires PHP 7.4 + */ + public function testGetPropertyValueFromUninitializedProperty() + { + $entity = new Entity_74(); + $metadata = new PropertyMetadata(self::CLASSNAME_74, 'uninitialized'); + + $this->assertNull($metadata->getPropertyValue($entity)); + } }