CluPilotCloud/tests/Feature/PortalHostTest.php

171 lines
7.2 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?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 websites 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 portals 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();
});