From 5563537cc8c2383ba5fbe039886b10c4444f6434 Mon Sep 17 00:00:00 2001 From: Loz Calver Date: Fri, 24 Aug 2018 15:36:51 +0100 Subject: [PATCH] Implement ConfirmationTokenChain to handle multiple tokens at once --- ...oken.php => AbstractConfirmationToken.php} | 12 +- src/Core/Startup/ConfirmationTokenChain.php | 178 +++++++++++++++++ .../Startup/ErrorControlChainMiddleware.php | 62 +++--- src/Core/Startup/ErrorDirector.php | 13 +- .../Startup/ParameterConfirmationToken.php | 28 +-- src/Core/Startup/URLConfirmationToken.php | 29 +-- .../Startup/ConfirmationTokenChainTest.php | 185 ++++++++++++++++++ .../ErrorControlChainMiddlewareTest.php | 52 +++++ 8 files changed, 500 insertions(+), 59 deletions(-) rename src/Core/Startup/{ConfirmationToken.php => AbstractConfirmationToken.php} (95%) create mode 100644 src/Core/Startup/ConfirmationTokenChain.php create mode 100644 tests/php/Core/Startup/ConfirmationTokenChainTest.php diff --git a/src/Core/Startup/ConfirmationToken.php b/src/Core/Startup/AbstractConfirmationToken.php similarity index 95% rename from src/Core/Startup/ConfirmationToken.php rename to src/Core/Startup/AbstractConfirmationToken.php index d23563f46..11f78b48a 100644 --- a/src/Core/Startup/ConfirmationToken.php +++ b/src/Core/Startup/AbstractConfirmationToken.php @@ -15,7 +15,7 @@ use SilverStripe\Security\RandomGenerator; * * @internal This class is designed specifically for use pre-startup and may change without warning */ -abstract class ConfirmationToken +abstract class AbstractConfirmationToken { /** * @var HTTPRequest @@ -173,6 +173,16 @@ HTML; */ abstract public function params($includeToken = true); + /** + * @return string + */ + abstract public function getRedirectUrlBase(); + + /** + * @return array + */ + abstract public function getRedirectUrlParams(); + /** * Get redirection URL * diff --git a/src/Core/Startup/ConfirmationTokenChain.php b/src/Core/Startup/ConfirmationTokenChain.php new file mode 100644 index 000000000..a47f2c4c0 --- /dev/null +++ b/src/Core/Startup/ConfirmationTokenChain.php @@ -0,0 +1,178 @@ +tokens[] = $token; + } + + /** + * Collect all tokens that require a redirect + * + * @return \Generator + */ + protected function filteredTokens() + { + foreach ($this->tokens as $token) { + if ($token->reloadRequired() || $token->reloadRequiredIfError()) { + yield $token; + } + } + } + + /** + * @return bool + */ + public function suppressionRequired() + { + foreach ($this->tokens as $token) { + if ($token->reloadRequired()) { + return true; + } + } + + return false; + } + + /** + * Suppress URLs & GET vars from tokens that require a redirect + */ + public function suppressTokens() + { + foreach ($this->filteredTokens() as $token) { + $token->suppress(); + } + } + + /** + * @return bool + */ + public function reloadRequired() + { + foreach ($this->tokens as $token) { + if ($token->reloadRequired()) { + return true; + } + } + + return false; + } + + /** + * @return bool + */ + public function reloadRequiredIfError() + { + foreach ($this->tokens as $token) { + if ($token->reloadRequiredIfError()) { + return true; + } + } + + return false; + } + + /** + * @param bool $includeToken + * @return array + */ + public function params($includeToken = true) + { + $params = []; + foreach ($this->tokens as $token) { + $params = array_merge($params, $token->params($includeToken)); + } + + return $params; + } + + /** + * Fetch the URL we want to redirect to, excluding query string parameters. This may + * be the same URL (with a token to be added outside this method), or to a different + * URL if the current one has been suppressed + * + * @return string + */ + public function getRedirectUrlBase() + { + // URLConfirmationTokens may alter the URL to suppress the URL they're protecting, + // so we need to ensure they're inspected last and therefore take priority + $tokens = iterator_to_array($this->filteredTokens(), false); + usort($tokens, function ($a, $b) { + return ($a instanceof URLConfirmationToken) ? 1 : 0; + }); + + $urlBase = Director::baseURL(); + foreach ($tokens as $token) { + $urlBase = $token->getRedirectUrlBase(); + } + + return $urlBase; + } + + /** + * Collate GET vars from all token providers that need to apply a token + * + * @return array + */ + public function getRedirectUrlParams() + { + $params = []; + foreach ($this->filteredTokens() as $token) { + $params = array_merge($params, $token->getRedirectUrlParams()); + } + + return $params; + } + + /** + * @return string + */ + protected function redirectURL() + { + $params = http_build_query($this->getRedirectUrlParams()); + return Controller::join_links($this->getRedirectUrlBase(), '?' . $params); + } + + /** + * @return HTTPResponse + */ + public function reloadWithTokens() + { + $location = $this->redirectURL(); + $locationJS = Convert::raw2js($location); + $locationATT = Convert::raw2att($location); + $body = <<location.href='$locationJS'; + +You are being redirected. If you are not redirected soon, click here to continue +HTML; + + // Build response + $result = new HTTPResponse($body); + $result->redirect($location); + return $result; + } +} diff --git a/src/Core/Startup/ErrorControlChainMiddleware.php b/src/Core/Startup/ErrorControlChainMiddleware.php index e81444629..c29878e15 100644 --- a/src/Core/Startup/ErrorControlChainMiddleware.php +++ b/src/Core/Startup/ErrorControlChainMiddleware.php @@ -34,20 +34,18 @@ class ErrorControlChainMiddleware implements HTTPMiddleware /** * @param HTTPRequest $request - * @return ConfirmationToken|null + * @return ConfirmationTokenChain */ - protected function prepareConfirmationTokenIfRequired(HTTPRequest $request) + protected function prepareConfirmationTokenChain(HTTPRequest $request) { - $token = URLConfirmationToken::prepare_tokens(['dev/build'], $request); + $chain = new ConfirmationTokenChain(); + $chain->pushToken(new URLConfirmationToken('dev/build', $request)); - if (!$token) { - $token = ParameterConfirmationToken::prepare_tokens( - ['isTest', 'isDev', 'flush'], - $request - ); + foreach (['isTest', 'isDev', 'flush'] as $parameter) { + $chain->pushToken(new ParameterConfirmationToken($parameter, $request)); } - return $token; + return $chain; } public function process(HTTPRequest $request, callable $next) @@ -55,19 +53,21 @@ class ErrorControlChainMiddleware implements HTTPMiddleware $result = null; // Prepare tokens and execute chain - $confirmationToken = $this->prepareConfirmationTokenIfRequired($request); - $chain = new ErrorControlChain(); - $chain - ->then(function () use ($request, $chain, $confirmationToken, $next, &$result) { - // If no redirection is necessary then we can disable error supression - if (!$confirmationToken) { - $chain->setSuppression(false); + $confirmationTokenChain = $this->prepareConfirmationTokenChain($request); + $errorControlChain = new ErrorControlChain(); + $errorControlChain + ->then(function () use ($request, $errorControlChain, $confirmationTokenChain, $next, &$result) { + if ($confirmationTokenChain->suppressionRequired()) { + $confirmationTokenChain->suppressTokens(); + } else { + // If no redirection is necessary then we can disable error supression + $errorControlChain->setSuppression(false); } try { // Check if a token is requesting a redirect - if ($confirmationToken && $confirmationToken->reloadRequired()) { - $result = $this->safeReloadWithToken($request, $confirmationToken); + if ($confirmationTokenChain && $confirmationTokenChain->reloadRequired()) { + $result = $this->safeReloadWithTokens($request, $confirmationTokenChain); } else { // If no reload necessary, process application $result = call_user_func($next, $request); @@ -77,10 +77,16 @@ class ErrorControlChainMiddleware implements HTTPMiddleware } }) // Finally if a token was requested but there was an error while figuring out if it's allowed, do it anyway - ->thenIfErrored(function () use ($confirmationToken) { - if ($confirmationToken && $confirmationToken->reloadRequiredIfError()) { - $result = $confirmationToken->reloadWithToken(); - $result->output(); + ->thenIfErrored(function () use ($confirmationTokenChain) { + if ($confirmationTokenChain && $confirmationTokenChain->reloadRequiredIfError()) { + try { + // Reload requires manual boot + $this->getApplication()->getKernel()->boot(false); + } finally { + // Given we're in an error state here, try to continue even if the kernel boot fails + $result = $confirmationTokenChain->reloadWithTokens(); + $result->output(); + } } }) ->execute(); @@ -92,10 +98,10 @@ class ErrorControlChainMiddleware implements HTTPMiddleware * or authentication is impossible. * * @param HTTPRequest $request - * @param ParameterConfirmationToken $reloadToken + * @param ConfirmationTokenChain $confirmationTokenChain * @return HTTPResponse */ - protected function safeReloadWithToken(HTTPRequest $request, $reloadToken) + protected function safeReloadWithTokens(HTTPRequest $request, ConfirmationTokenChain $confirmationTokenChain) { // Safe reload requires manual boot $this->getApplication()->getKernel()->boot(false); @@ -104,9 +110,9 @@ class ErrorControlChainMiddleware implements HTTPMiddleware $request->getSession()->init($request); // Request with ErrorDirector - $result = ErrorDirector::singleton()->handleRequestWithToken( + $result = ErrorDirector::singleton()->handleRequestWithTokenChain( $request, - $reloadToken, + $confirmationTokenChain, $this->getApplication()->getKernel() ); if ($result) { @@ -114,8 +120,8 @@ class ErrorControlChainMiddleware implements HTTPMiddleware } // Fail and redirect the user to the login page - $params = array_merge($request->getVars(), $reloadToken->params(false)); - $backURL = $reloadToken->currentURL() . '?' . http_build_query($params); + $params = array_merge($request->getVars(), $confirmationTokenChain->params(false)); + $backURL = $confirmationTokenChain->getRedirectUrlBase() . '?' . http_build_query($params); $loginPage = Director::absoluteURL(Security::config()->get('login_url')); $loginPage .= "?BackURL=" . urlencode($backURL); $result = new HTTPResponse(); diff --git a/src/Core/Startup/ErrorDirector.php b/src/Core/Startup/ErrorDirector.php index 54001fd05..575df7183 100644 --- a/src/Core/Startup/ErrorDirector.php +++ b/src/Core/Startup/ErrorDirector.php @@ -21,18 +21,21 @@ class ErrorDirector extends Director * Redirect with token if allowed, or null if not allowed * * @param HTTPRequest $request - * @param ConfirmationToken $token + * @param ConfirmationTokenChain $confirmationTokenChain * @param Kernel $kernel * @return null|HTTPResponse Redirection response, or null if not able to redirect */ - public function handleRequestWithToken(HTTPRequest $request, ConfirmationToken $token, Kernel $kernel) - { + public function handleRequestWithTokenChain( + HTTPRequest $request, + ConfirmationTokenChain $confirmationTokenChain, + Kernel $kernel + ) { Injector::inst()->registerService($request, HTTPRequest::class); // Next, check if we're in dev mode, or the database doesn't have any security data, or we are admin - $reload = function (HTTPRequest $request) use ($token, $kernel) { + $reload = function (HTTPRequest $request) use ($confirmationTokenChain, $kernel) { if ($kernel->getEnvironment() === Kernel::DEV || !Security::database_is_ready() || Permission::check('ADMIN')) { - return $token->reloadWithToken(); + return $confirmationTokenChain->reloadWithTokens(); } return null; }; diff --git a/src/Core/Startup/ParameterConfirmationToken.php b/src/Core/Startup/ParameterConfirmationToken.php index 4e90f1ef7..bc751a2c3 100644 --- a/src/Core/Startup/ParameterConfirmationToken.php +++ b/src/Core/Startup/ParameterConfirmationToken.php @@ -3,6 +3,7 @@ namespace SilverStripe\Core\Startup; use SilverStripe\Control\Controller; +use SilverStripe\Control\Director; use SilverStripe\Control\HTTPRequest; use SilverStripe\Control\HTTPResponse; use SilverStripe\Core\Convert; @@ -15,7 +16,7 @@ use SilverStripe\Security\RandomGenerator; * * @internal This class is designed specifically for use pre-startup and may change without warning */ -class ParameterConfirmationToken extends ConfirmationToken +class ParameterConfirmationToken extends AbstractConfirmationToken { /** * The name of the parameter @@ -140,19 +141,22 @@ class ParameterConfirmationToken extends ConfirmationToken } return $params; } + + public function getRedirectUrlBase() + { + return ($this->existsInReferer() && !$this->parameterProvided()) ? Director::baseURL() : $this->currentURL(); + } + + public function getRedirectUrlParams() + { + return ($this->existsInReferer() && !$this->parameterProvided()) + ? $this->params() + : array_merge($this->request->getVars(), $this->params()); + } protected function redirectURL() { - // If url is encoded via BackURL, defer to home page (prevent redirect to form action) - if ($this->existsInReferer() && !$this->parameterProvided()) { - $url = BASE_URL ?: '/'; - $params = $this->params(); - } else { - $url = $this->currentURL(); - $params = array_merge($this->request->getVars(), $this->params()); - } - - // Merge get params with current url - return Controller::join_links($url, '?' . http_build_query($params)); + $query = http_build_query($this->getRedirectUrlParams()); + return Controller::join_links($this->getRedirectUrlBase(), '?' . $query); } } diff --git a/src/Core/Startup/URLConfirmationToken.php b/src/Core/Startup/URLConfirmationToken.php index 8176a41b8..bb509931e 100644 --- a/src/Core/Startup/URLConfirmationToken.php +++ b/src/Core/Startup/URLConfirmationToken.php @@ -12,7 +12,7 @@ use SilverStripe\Control\HTTPRequest; * * @internal This class is designed specifically for use pre-startup and may change without warning */ -class URLConfirmationToken extends ConfirmationToken +class URLConfirmationToken extends AbstractConfirmationToken { /** * @var string @@ -60,7 +60,7 @@ class URLConfirmationToken extends ConfirmationToken */ protected function getURLExistsInBackURL(HTTPRequest $request) { - $backURL = $request->getVar('BackURL'); + $backURL = ltrim($request->getVar('BackURL'), '/'); return (strpos($backURL, $this->urlToCheck) === 0); } @@ -119,18 +119,21 @@ class URLConfirmationToken extends ConfirmationToken return Controller::join_links(Director::baseURL(), $this->currentURL); } + public function getRedirectUrlBase() + { + return ($this->urlExistsInBackURL && !$this->urlMatches()) ? Director::baseURL() : $this->currentURL(); + } + + public function getRedirectUrlParams() + { + return ($this->urlExistsInBackURL && !$this->urlMatches()) + ? $this->params() + : array_merge($this->request->getVars(), $this->params()); + } + protected function redirectURL() { - // If url is encoded via BackURL, defer to home page (prevent redirect to form action) - if ($this->urlExistsInBackURL && !$this->urlMatches()) { - $url = BASE_URL ?: '/'; - $params = $this->params(); - } else { - $url = $this->currentURL(); - $params = array_merge($this->request->getVars(), $this->params()); - } - - // Merge get params with current url - return Controller::join_links($url, '?' . http_build_query($params)); + $query = http_build_query($this->getRedirectUrlParams()); + return Controller::join_links($this->getRedirectUrlBase(), '?' . $query); } } diff --git a/tests/php/Core/Startup/ConfirmationTokenChainTest.php b/tests/php/Core/Startup/ConfirmationTokenChainTest.php new file mode 100644 index 000000000..adb8fba36 --- /dev/null +++ b/tests/php/Core/Startup/ConfirmationTokenChainTest.php @@ -0,0 +1,185 @@ +createPartialMock(ParameterConfirmationToken::class, $methods); + $mock->expects($this->any()) + ->method('reloadRequired') + ->will($this->returnValue($requiresReload)); + return $mock; + } + + protected function getTokenRequiringReloadIfError($requiresReload = true, $extraMethods = []) + { + $methods = array_merge(['reloadRequired', 'reloadRequiredIfError'], $extraMethods); + $mock = $this->createPartialMock(ParameterConfirmationToken::class, $methods); + $mock->expects($this->any()) + ->method('reloadRequired') + ->will($this->returnValue(false)); + $mock->expects($this->any()) + ->method('reloadRequiredIfError') + ->will($this->returnValue($requiresReload)); + return $mock; + } + + public function testFilteredTokens() + { + $chain = new ConfirmationTokenChain(); + $chain->pushToken($tokenRequiringReload = $this->getTokenRequiringReload()); + $chain->pushToken($tokenNotRequiringReload = $this->getTokenRequiringReload(false)); + $chain->pushToken($tokenRequiringReloadIfError = $this->getTokenRequiringReloadIfError()); + $chain->pushToken($tokenNotRequiringReloadIfError = $this->getTokenRequiringReloadIfError(false)); + + $reflectionMethod = new \ReflectionMethod(ConfirmationTokenChain::class, 'filteredTokens'); + $reflectionMethod->setAccessible(true); + $tokens = iterator_to_array($reflectionMethod->invoke($chain)); + + $this->assertContains($tokenRequiringReload, $tokens, 'Token requiring a reload was not returned'); + $this->assertNotContains($tokenNotRequiringReload, $tokens, 'Token not requiring a reload was returned'); + $this->assertContains($tokenRequiringReloadIfError, $tokens, 'Token requiring a reload on error was not returned'); + $this->assertNotContains($tokenNotRequiringReloadIfError, $tokens, 'Token not requiring a reload on error was returned'); + } + + public function testSuppressionRequired() + { + $chain = new ConfirmationTokenChain(); + $chain->pushToken($this->getTokenRequiringReload(false)); + $this->assertFalse($chain->suppressionRequired(), 'Suppression incorrectly marked as required'); + + $chain = new ConfirmationTokenChain(); + $chain->pushToken($this->getTokenRequiringReloadIfError(false)); + $this->assertFalse($chain->suppressionRequired(), 'Suppression incorrectly marked as required'); + + $chain = new ConfirmationTokenChain(); + $chain->pushToken($this->getTokenRequiringReload()); + $this->assertTrue($chain->suppressionRequired(), 'Suppression not marked as required'); + + $chain = new ConfirmationTokenChain(); + $chain->pushToken($this->getTokenRequiringReloadIfError()); + $this->assertFalse($chain->suppressionRequired(), 'Suppression incorrectly marked as required'); + } + + public function testSuppressTokens() + { + $mockToken = $this->getTokenRequiringReload(true, ['suppress']); + $mockToken->expects($this->once()) + ->method('suppress'); + $secondMockToken = $this->getTokenRequiringReloadIfError(true, ['suppress']); + $secondMockToken->expects($this->once()) + ->method('suppress'); + + $chain = new ConfirmationTokenChain(); + $chain->pushToken($mockToken); + $chain->pushToken($secondMockToken); + $chain->suppressTokens(); + } + + public function testReloadRequired() + { + $mockToken = $this->getTokenRequiringReload(true); + $secondMockToken = $this->getTokenRequiringReload(false); + + $chain = new ConfirmationTokenChain(); + $chain->pushToken($mockToken); + $chain->pushToken($secondMockToken); + $this->assertTrue($chain->reloadRequired()); + } + + public function testReloadRequiredIfError() + { + $mockToken = $this->getTokenRequiringReloadIfError(true); + $secondMockToken = $this->getTokenRequiringReloadIfError(false); + + $chain = new ConfirmationTokenChain(); + $chain->pushToken($mockToken); + $chain->pushToken($secondMockToken); + $this->assertTrue($chain->reloadRequiredIfError()); + } + + public function testParams() + { + $mockToken = $this->getTokenRequiringReload(true, ['params']); + $mockToken->expects($this->once()) + ->method('params') + ->with($this->isTrue()) + ->will($this->returnValue(['mockTokenParam' => '1'])); + $secondMockToken = $this->getTokenRequiringReload(true, ['params']); + $secondMockToken->expects($this->once()) + ->method('params') + ->with($this->isTrue()) + ->will($this->returnValue(['secondMockTokenParam' => '2'])); + + $chain = new ConfirmationTokenChain(); + $chain->pushToken($mockToken); + $chain->pushToken($secondMockToken); + $this->assertEquals(['mockTokenParam' => '1', 'secondMockTokenParam' => '2'], $chain->params(true)); + + $mockToken = $this->getTokenRequiringReload(true, ['params']); + $mockToken->expects($this->once()) + ->method('params') + ->with($this->isFalse()) + ->will($this->returnValue(['mockTokenParam' => '1'])); + + $chain = new ConfirmationTokenChain(); + $chain->pushToken($mockToken); + $this->assertEquals(['mockTokenParam' => '1'], $chain->params(false)); + } + + public function testGetRedirectUrlBase() + { + $mockUrlToken = $this->createPartialMock(URLConfirmationToken::class, ['reloadRequired', 'getRedirectUrlBase']); + $mockUrlToken->expects($this->any()) + ->method('reloadRequired') + ->will($this->returnValue(true)); + $mockUrlToken->expects($this->any()) + ->method('getRedirectUrlBase') + ->will($this->returnValue('url-base')); + + $mockParameterToken = $this->createPartialMock(ParameterConfirmationToken::class, ['reloadRequired', 'getRedirectUrlBase']); + $mockParameterToken->expects($this->any()) + ->method('reloadRequired') + ->will($this->returnValue(true)); + $mockParameterToken->expects($this->any()) + ->method('getRedirectUrlBase') + ->will($this->returnValue('parameter-base')); + + $chain = new ConfirmationTokenChain(); + $chain->pushToken($mockParameterToken); + $chain->pushToken($mockUrlToken); + $this->assertEquals('url-base', $chain->getRedirectUrlBase(), 'URLConfirmationToken url base should take priority'); + + // Push them in reverse order to check priority still correct + $chain = new ConfirmationTokenChain(); + $chain->pushToken($mockUrlToken); + $chain->pushToken($mockParameterToken); + $this->assertEquals('url-base', $chain->getRedirectUrlBase(), 'URLConfirmationToken url base should take priority'); + } + + public function testGetRedirectUrlParams() + { + $mockToken = $this->getTokenRequiringReload(true, ['getRedirectUrlParams']); + $mockToken->expects($this->once()) + ->method('getRedirectUrlParams') + ->will($this->returnValue(['mockTokenParam' => '1'])); + + $secondMockToken = $this->getTokenRequiringReload(true, ['getRedirectUrlParams']); + $secondMockToken->expects($this->once()) + ->method('getRedirectUrlParams') + ->will($this->returnValue(['secondMockTokenParam' => '2'])); + + $chain = new ConfirmationTokenChain(); + $chain->pushToken($mockToken); + $chain->pushToken($secondMockToken); + $this->assertEquals(['mockTokenParam' => '1', 'secondMockTokenParam' => '2'], $chain->getRedirectUrlParams()); + } +} diff --git a/tests/php/Core/Startup/ErrorControlChainMiddlewareTest.php b/tests/php/Core/Startup/ErrorControlChainMiddlewareTest.php index 7df6f6ebf..a90799026 100644 --- a/tests/php/Core/Startup/ErrorControlChainMiddlewareTest.php +++ b/tests/php/Core/Startup/ErrorControlChainMiddlewareTest.php @@ -121,4 +121,56 @@ class ErrorControlChainMiddlewareTest extends SapphireTest $this->assertNotContains('?devbuildtoken=', $location); $this->assertContains('Security/login', $location); } + + public function testLiveBuildAndFlushAdmin() + { + // Mock admin + $adminID = $this->logInWithPermission('ADMIN'); + $this->logOut(); + + // Mock app + $app = new HTTPApplication(new BlankKernel(BASE_PATH)); + $app->getKernel()->setEnvironment(Kernel::LIVE); + + // Test being logged in as admin + $chain = new ErrorControlChainMiddleware($app); + $request = new HTTPRequest('GET', '/dev/build/', ['flush' => '1']); + $request->setSession(new Session(['loggedInAs' => $adminID])); + $result = $chain->process($request, function () { + return null; + }); + + $this->assertInstanceOf(HTTPResponse::class, $result); + $location = $result->getHeader('Location'); + $this->assertContains('/dev/build', $location); + $this->assertContains('flush=1', $location); + $this->assertContains('devbuildtoken=', $location); + $this->assertContains('flushtoken=', $location); + $this->assertNotContains('Security/login', $location); + } + + public function testLiveBuildAndFlushUnauthenticated() + { + // Mock app + $app = new HTTPApplication(new BlankKernel(BASE_PATH)); + $app->getKernel()->setEnvironment(Kernel::LIVE); + + // Test being logged in as no one + Security::setCurrentUser(null); + $chain = new ErrorControlChainMiddleware($app); + $request = new HTTPRequest('GET', '/dev/build', ['flush' => '1']); + $request->setSession(new Session(['loggedInAs' => 0])); + $result = $chain->process($request, function () { + return null; + }); + + // Should be directed to login, not to flush + $this->assertInstanceOf(HTTPResponse::class, $result); + $location = $result->getHeader('Location'); + $this->assertNotContains('/dev/build', $location); + $this->assertNotContains('flush=1', $location); + $this->assertNotContains('devbuildtoken=', $location); + $this->assertNotContains('flushtoken=', $location); + $this->assertContains('Security/login', $location); + } }