171 lines
7.2 KiB
PHP
171 lines
7.2 KiB
PHP
<?php
|
||
|
||
use App\Models\User;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Routing\Router;
|
||
use Illuminate\Support\Facades\Route;
|
||
|
||
/**
|
||
* The website and the portal each answer on their own hostname, and nowhere
|
||
* else.
|
||
*
|
||
* A route belongs to a host. One that is not registered for the host being
|
||
* asked cannot answer, and the honest reply is 404 — that is the whole
|
||
* mechanism, and Route::domain() is all of it.
|
||
*
|
||
* The first attempt bound the landing page alone and left everything else
|
||
* host-agnostic. The website duly vanished from the portal, and the portal
|
||
* stayed reachable from the website: www.clupilot.com/dashboard kept working.
|
||
* Half a separation is no separation, which is why these tests check BOTH
|
||
* directions for every route rather than only the one that was reported.
|
||
*
|
||
* Routes are built once at boot, so changing config alone changes nothing.
|
||
* These re-register them against a fresh router — the idiom WelcomeTest already
|
||
* uses for the status host.
|
||
*/
|
||
function routerWithHosts(string|array|null $siteHosts, ?string $appHost = null): Router
|
||
{
|
||
config()->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();
|
||
});
|