set('admin_access.site_hosts', array_values(array_filter((array) $siteHosts))); config()->set('admin_access.app_host', $appHost ?? ''); $router = new Router(app('events'), app()); Route::swap($router); require base_path('routes/web.php'); $router->getRoutes()->refreshNameLookups(); return $router; } /** * Dispatch through the fresh router, with the request bound where the framework * would bind it. * * Router::dispatch() on a standalone router does not rebind the container's * request, so a route that reads one — through the helper OR through an * injected parameter, both of which resolve from the container — would be * handed the test's own request instead of the one being answered. In a real * request the two are the same object; here they are not, and a redirect would * silently lose its query string in the test while working in production. */ function dispatchOn(Router $router, Request $request) { app()->instance('request', $request); return $router->dispatch($request); } /** Does this host answer this path at all? */ function answers(Router $router, string $host, string $path): bool { try { $router->getRoutes()->match(Request::create("http://{$host}{$path}", 'GET')); return true; } catch (Symfony\Component\HttpKernel\Exception\NotFoundHttpException) { return false; } } it('does not serve the portal on the website’s hostname', function () { // The half that was missing, and the one that was reported back. $router = routerWithHosts('www.example.test', 'app.example.test'); foreach (['/dashboard', '/cloud', '/invoices', '/settings', '/login', '/register'] as $path) { expect(answers($router, 'app.example.test', $path))->toBeTrue("app should answer {$path}") ->and(answers($router, 'www.example.test', $path))->toBeFalse("www must not answer {$path}"); } }); it('does not serve the website on the portal’s hostname', function () { $router = routerWithHosts('www.example.test', 'app.example.test'); foreach (['/robots.txt', '/legal/impressum', '/legal/agb'] as $path) { expect(answers($router, 'www.example.test', $path))->toBeTrue("www should answer {$path}") ->and(answers($router, 'app.example.test', $path))->toBeFalse("app must not answer {$path}"); } expect($router->getRoutes()->match(Request::create('http://www.example.test/', 'GET'))->getActionName()) ->toContain('LandingController'); }); it('answers "/" on the portal host with the product, never a 404', function () { // "/" is what somebody types from memory. Turning that into an error page // to make a point about hostnames helps nobody. $router = routerWithHosts('www.example.test', 'app.example.test'); $response = dispatchOn($router, Request::create('http://app.example.test/', 'GET')); expect($response->getStatusCode())->toBe(302) ->and($response->headers->get('Location'))->toContain('/login'); }); it('sends a signed-in customer on the portal host to their dashboard', function () { $this->actingAs(User::factory()->create()); $router = routerWithHosts('www.example.test', 'app.example.test'); $response = dispatchOn($router, Request::create('http://app.example.test/', 'GET')); expect($response->getStatusCode())->toBe(302) ->and($response->headers->get('Location'))->toContain('/dashboard'); }); it('keeps the Stripe webhook reachable on any host', function () { // Stripe posts to whichever URL it was configured with, and a hostname // mismatch there loses payments rather than showing a 404 to a person. $router = routerWithHosts('www.example.test', 'app.example.test'); $matches = function (string $host) use ($router) { try { $router->getRoutes()->match(Request::create("http://{$host}/webhooks/stripe", 'POST')); return true; } catch (Symfony\Component\HttpKernel\Exception\NotFoundHttpException) { return false; } }; expect($matches('www.example.test'))->toBeTrue() ->and($matches('app.example.test'))->toBeTrue() ->and($matches('anything.example.test'))->toBeTrue(); }); it('binds nothing at all while no hostnames are configured', function () { // The installed base, and every development machine reached by a bare IP. // Switching this on by default would lock those out of everything at once. $router = routerWithHosts(null, null); foreach (['/', '/dashboard', '/legal/impressum'] as $path) { expect(answers($router, 'anything.example.test', $path))->toBeTrue("everything should answer {$path}"); } }); it('sends every other name for the website to the canonical one', function () { // An apex domain and its www are two names for one site. Answering on both // without picking one splits search rankings and cookies between them. $router = routerWithHosts(['www.example.test', 'example.test'], 'app.example.test'); $response = dispatchOn($router, Request::create('http://example.test/legal/impressum', 'GET')); expect($response->getStatusCode())->toBe(301) ->and($response->headers->get('Location'))->toBe('https://www.example.test/legal/impressum'); }); it('keeps the path and the query when it redirects to the canonical name', function () { // A bookmark or an ad link carries both, and dropping them turns a // redirect into a dead end at the front page. $router = routerWithHosts(['www.example.test', 'example.test'], 'app.example.test'); $response = dispatchOn($router, Request::create('http://example.test/legal/agb?ref=mail', 'GET')); expect($response->headers->get('Location'))->toBe('https://www.example.test/legal/agb?ref=mail'); }); it('does not let the alias redirect swallow any other host', function () { // It is a catch-all, and a catch-all registered without a host would eat // every path in the application. $router = routerWithHosts(['www.example.test', 'example.test'], 'app.example.test'); expect(answers($router, 'app.example.test', '/dashboard'))->toBeTrue() ->and(answers($router, 'www.example.test', '/legal/impressum'))->toBeTrue(); });