Bind the portal to its own hostname too, so the website cannot serve it
The previous release bound the landing page and stopped there. The website duly
vanished from the portal — and the portal stayed reachable from the website:
www.clupilot.com/dashboard, /login, /settings all kept working. Half a
separation is no separation, and it was reported back within the hour.
APP_HOST binds every portal route, Fortify's own POST actions included through
fortify.domain — a sign-in form on the website's hostname would otherwise post
to a route that answers there. SITE_HOST now takes the legal pages and
robots.txt with the landing page rather than leaving them everywhere; binding
them is also what makes route('legal.impressum') produce a www URL from inside
a queued mail, where there is no request to take a hostname from.
There is no clever middleware here and there should not be. Route::domain() is
the mechanism: a route registered for one host does not exist on another, and a
request for it gets the 404 it deserves. The only deliberate exception is "/" on
the portal host, which redirects to the dashboard or the sign-in page — that is
what people type from memory, and turning it into an error page to make a point
about hostnames helps nobody. The Stripe webhook and /up stay host-agnostic:
Stripe posts to whichever URL it was given, and a mismatch there loses payments.
Both are opt-in and empty by default, which every development machine reached
by a bare IP depends on.
The tests now check BOTH directions for every path rather than only the one
that was reported. That is the mistake this commit exists to correct.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/granted-plans
v1.3.1
parent
735daf46a4
commit
21762284a7
12
.env.example
12
.env.example
|
|
@ -205,11 +205,19 @@ CLUPILOT_TAX_PERCENT=20
|
|||
# Ohne Eintrag gilt false — dann zeigt https://admin.…/ das PORTAL, nicht die
|
||||
# Konsole, und das sieht wie ein Fehler aus, obwohl es die Vorgabe ist.
|
||||
#
|
||||
# APP_HOST: Hostname des Kundenportals, z. B. app.clupilot.com. Gesetzt, ist
|
||||
# JEDE Portal-Route an diesen Host gebunden und existiert nirgends sonst —
|
||||
# www.clupilot.com/dashboard ist dann ein 404, kein funktionierender Aufruf.
|
||||
# Leer = Portal antwortet ueberall (Vorgabe, und was jede dev-Maschine ueber
|
||||
# eine blanke IP braucht).
|
||||
APP_HOST=
|
||||
|
||||
# SITE_HOST: Hostname der oeffentlichen Website. Gesetzt, antwortet die
|
||||
# Startseite NUR dort — jeder andere Host (das Portal, eine blanke IP) zeigt
|
||||
# unter "/" die Anmeldung bzw. das Dashboard. Leer heisst: Startseite ueberall,
|
||||
# und dann liefert app.clupilot.com die Website aus.
|
||||
# Das Impressum bleibt absichtlich ueberall erreichbar.
|
||||
# und dann liefert app.clupilot.com die Website aus. Gebunden sind Startseite,
|
||||
# robots.txt und die Legal-Seiten — dadurch erzeugt route('legal.impressum')
|
||||
# auch aus einer Mail heraus eine www-Adresse.
|
||||
SITE_HOST=
|
||||
|
||||
ADMIN_HOSTS=admin.dev.clupilot.com
|
||||
|
|
|
|||
|
|
@ -43,6 +43,24 @@ return [
|
|||
*/
|
||||
'status_host' => strtolower(trim((string) env('STATUS_HOST', ''))),
|
||||
|
||||
/*
|
||||
| Hostname the customer portal lives on, e.g. app.clupilot.com.
|
||||
|
|
||||
| Empty means the portal answers on every host, which is the default and
|
||||
| what every development machine reached by a bare IP depends on.
|
||||
|
|
||||
| Set, every portal route — sign-in, registration, the dashboard, Fortify's
|
||||
| own POST actions through fortify.domain — is bound to this host and does
|
||||
| not exist anywhere else. www.clupilot.com/dashboard is then a 404 rather
|
||||
| than a working page, which is the point: a route belongs to a hostname,
|
||||
| and a route that is not registered for the host being asked cannot answer.
|
||||
|
|
||||
| The first attempt at this bound only the landing page and left the portal
|
||||
| host-agnostic — so the website vanished from the portal, and the portal
|
||||
| stayed reachable from the website. Half a separation is no separation.
|
||||
*/
|
||||
'app_host' => strtolower(trim((string) env('APP_HOST', ''))),
|
||||
|
||||
/*
|
||||
| Hostname the public website lives on, e.g. www.clupilot.com.
|
||||
|
|
||||
|
|
@ -51,14 +69,15 @@ return [
|
|||
| app.clupilot.com got the shop window: marketing copy, a pricing table and
|
||||
| a "sign up" call to action, at the address where their servers are.
|
||||
|
|
||||
| Set, the website is bound to this host alone, and every OTHER host answers
|
||||
| "/" with the product: the dashboard for somebody signed in, the sign-in
|
||||
| Set, the website — landing page, robots.txt and the legal pages — is bound
|
||||
| to this host and exists nowhere else. Every other host answers "/" with
|
||||
| the product instead: the dashboard for somebody signed in, the sign-in
|
||||
| page for everybody else. The console keeps its own "/" — its routes are
|
||||
| registered first and win.
|
||||
|
|
||||
| The legal pages are deliberately NOT bound. An imprint has to be reachable
|
||||
| from wherever the reader is standing, and a mail footer links to it from
|
||||
| whichever host sent the mail.
|
||||
| Binding the legal pages is also what makes route('legal.impressum')
|
||||
| produce a www URL from inside a queued mail, where there is no request to
|
||||
| take a hostname from.
|
||||
*/
|
||||
'site_host' => strtolower(trim((string) env('SITE_HOST', ''))),
|
||||
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ return [
|
|||
|
||||
'prefix' => '',
|
||||
|
||||
'domain' => null,
|
||||
'domain' => strtolower(trim((string) env('APP_HOST', ''))) ?: null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
|
|||
201
routes/web.php
201
routes/web.php
|
|
@ -152,99 +152,134 @@ if ($statusHost !== '') {
|
|||
Route::get('/status', StatusController::class)->name('status');
|
||||
}
|
||||
|
||||
// The website, and what stands at "/" everywhere else.
|
||||
//
|
||||
// The landing page was host-agnostic, so app.clupilot.com served the shop
|
||||
// window: marketing copy and a "sign up" button, at the address where a
|
||||
// customer's servers live. Reported exactly that way.
|
||||
//
|
||||
// Registered in this order for the reason the status page above already
|
||||
// documents: a host-agnostic route registered earlier beats a domain-bound one
|
||||
// registered later, so the bound one has to come first.
|
||||
/*
|
||||
| The public website and the customer portal, each on its own hostname.
|
||||
|
|
||||
| Bound with Route::domain(), which is the whole mechanism: a route registered
|
||||
| for one host does not exist on another, so www.clupilot.com/dashboard is a
|
||||
| 404 rather than a working page. The first attempt at this bound only the
|
||||
| landing page and left everything else host-agnostic — the website vanished
|
||||
| from the portal, and the portal stayed reachable from the website. Half a
|
||||
| separation is no separation.
|
||||
|
|
||||
| Both are opt-in. Empty means nothing is bound and everything answers
|
||||
| everywhere, which is what every development machine reached by a bare IP
|
||||
| depends on.
|
||||
|
|
||||
| Deliberately NOT bound: the Stripe webhook (Stripe posts to whichever URL it
|
||||
| was given), /up, and Livewire's own /livewire/update, which both the console
|
||||
| and the portal post component actions to.
|
||||
*/
|
||||
$siteHost = (string) config('admin_access.site_host');
|
||||
$appHost = (string) config('admin_access.app_host');
|
||||
|
||||
$publicSite = function () {
|
||||
Route::get('/', LandingController::class)->name('home');
|
||||
|
||||
// Generated, not a static file: while the site is hidden this has to say
|
||||
// so, and a crawler that gets a 404 here simply crawls anyway.
|
||||
Route::get('/robots.txt', function () {
|
||||
$body = App\Support\Settings::bool('site.public', true)
|
||||
? "User-agent: *\nAllow: /\n"
|
||||
: "User-agent: *\nDisallow: /\n";
|
||||
|
||||
return response($body, 200, ['Content-Type' => 'text/plain']);
|
||||
})->name('robots');
|
||||
|
||||
// Legal pages. Bound with the rest of the website rather than left
|
||||
// everywhere: an imprint belongs to the company's site, and binding it is
|
||||
// what makes route('legal.impressum') generate a www URL from a queued
|
||||
// mail, where there is no request to take a hostname from.
|
||||
Route::prefix('legal')->name('legal.')->group(function () {
|
||||
Route::get('/impressum', fn () => view('legal', ['title' => 'Impressum']))->name('impressum');
|
||||
Route::get('/datenschutz', fn () => view('legal', ['title' => 'Datenschutz']))->name('datenschutz');
|
||||
Route::get('/agb', fn () => view('legal', ['title' => 'AGB']))->name('agb');
|
||||
// Kept so existing links and bookmarks do not 404 — permanently,
|
||||
// because the page has genuinely moved.
|
||||
Route::get('/status', fn () => redirect()->to(route('status'), 301))->name('status');
|
||||
});
|
||||
};
|
||||
|
||||
$portal = function () {
|
||||
// Guest auth pages — full-page class-based Livewire components (R1/R2).
|
||||
// Fortify handles the POST actions with views off; those are bound to the
|
||||
// same host through fortify.domain, or a sign-in form on the website's
|
||||
// hostname would post to a route that answers there.
|
||||
Route::middleware('guest')->group(function () {
|
||||
Route::get('/login', Login::class)->name('login');
|
||||
Route::get('/register', \App\Livewire\Auth\Register::class)->name('register');
|
||||
// Registration POST goes through Fortify's controller but with our own
|
||||
// registration-scoped throttle (Fortify's built-in route has none).
|
||||
Route::post('/register', [\Laravel\Fortify\Http\Controllers\RegisteredUserController::class, 'store'])
|
||||
->middleware('throttle:registration')
|
||||
->name('register.store');
|
||||
Route::get('/two-factor-challenge', TwoFactorChallenge::class)->name('two-factor.login');
|
||||
});
|
||||
|
||||
// Signed and single-use; see ImpersonationController. Deliberately not
|
||||
// behind `auth`: following the link is what creates the session.
|
||||
Route::get('/impersonate/enter/{customer}/{operator}', [ImpersonationController::class, 'enter'])
|
||||
->name('impersonate.enter');
|
||||
|
||||
// Signed in, but the address has not been confirmed yet. Fortify would own
|
||||
// this page, but 'views' => false — it keeps the verify and resend actions
|
||||
// and the app renders its own screens (R1/R2).
|
||||
Route::get('/email/verify', \App\Livewire\Auth\VerifyEmail::class)
|
||||
->middleware('auth')
|
||||
->name('verification.notice');
|
||||
|
||||
// Customer portal — each sidebar tab is a full-page class-based Livewire
|
||||
// component (R1/R2); paths are English (R13).
|
||||
//
|
||||
// `verified` sits on the whole group rather than on the pages that spend
|
||||
// money. An unconfirmed address is not a billing problem to be caught at
|
||||
// checkout: it is an account that may not belong to the person holding it,
|
||||
// and everything behind here — the servers, the users, the backups — is
|
||||
// worth as much to somebody who typed a stranger's address as to its owner.
|
||||
Route::middleware(['auth', 'verified', 'customer.active'])->group(function () {
|
||||
Route::get('/dashboard', Dashboard::class)->name('dashboard');
|
||||
Route::get('/cloud', Cloud::class)->name('cloud');
|
||||
Route::get('/users', Users::class)->name('users');
|
||||
Route::get('/backups', Backups::class)->name('backups');
|
||||
Route::get('/invoices', Invoices::class)->name('invoices');
|
||||
Route::get('/billing', Billing::class)->name('billing');
|
||||
Route::get('/settings', \App\Livewire\Settings::class)->name('settings');
|
||||
Route::get('/support', Support::class)->name('support');
|
||||
|
||||
// Return from an admin impersonation session (accessible as the
|
||||
// customer user). POST so Laravel's CSRF middleware protects the
|
||||
// identity change.
|
||||
Route::post('/impersonate/leave', [ImpersonationController::class, 'leave'])->name('impersonate.leave');
|
||||
});
|
||||
};
|
||||
|
||||
// Domain-bound FIRST, host-agnostic last: a host-agnostic route registered
|
||||
// earlier beats a bound one registered later, which is the same ordering the
|
||||
// status page above already documents.
|
||||
if ($appHost !== '') {
|
||||
Route::domain($appHost)->group($portal);
|
||||
}
|
||||
|
||||
if ($siteHost !== '') {
|
||||
Route::domain($siteHost)->get('/', LandingController::class)->name('home');
|
||||
Route::domain($siteHost)->group($publicSite);
|
||||
|
||||
// Every other host — the portal, a bare IP — answers with the product.
|
||||
// Never a 404: "/" is what somebody types from memory, and turning that
|
||||
// "/" on a host that is neither: the portal's own hostname, or a bare IP.
|
||||
// Never a 404 — "/" is what somebody types from memory, and turning that
|
||||
// into an error page to make a point about hostnames helps nobody.
|
||||
Route::get('/', function () {
|
||||
return redirect()->route(auth()->check() ? 'dashboard' : 'login');
|
||||
})->name('portal.home');
|
||||
} else {
|
||||
Route::get('/', LandingController::class)->name('home');
|
||||
}
|
||||
|
||||
// Generated, not a static file: while the site is hidden this has to say so,
|
||||
// and a crawler that gets a 404 here simply crawls anyway.
|
||||
Route::get('/robots.txt', function () {
|
||||
$body = App\Support\Settings::bool('site.public', true)
|
||||
? "User-agent: *\nAllow: /\n"
|
||||
: "User-agent: *\nDisallow: /\n";
|
||||
if ($appHost === '') {
|
||||
$portal();
|
||||
}
|
||||
|
||||
return response($body, 200, ['Content-Type' => 'text/plain']);
|
||||
})->name('robots');
|
||||
if ($siteHost === '') {
|
||||
$publicSite();
|
||||
}
|
||||
|
||||
// Stripe webhook — paid order → customer provisioning run (CSRF-exempt, signed).
|
||||
// Stripe webhook — paid order → customer provisioning run (CSRF-exempt,
|
||||
// signed). Host-agnostic on purpose: Stripe posts to whichever URL it was
|
||||
// configured with, and a hostname mismatch there loses payments.
|
||||
Route::post('/webhooks/stripe', StripeWebhookController::class)->name('webhooks.stripe');
|
||||
|
||||
// Public legal pages (placeholders — replace with real content before launch).
|
||||
Route::prefix('legal')->name('legal.')->group(function () {
|
||||
Route::get('/impressum', fn () => view('legal', ['title' => 'Impressum']))->name('impressum');
|
||||
Route::get('/datenschutz', fn () => view('legal', ['title' => 'Datenschutz']))->name('datenschutz');
|
||||
Route::get('/agb', fn () => view('legal', ['title' => 'AGB']))->name('agb');
|
||||
// Kept so existing links and bookmarks do not 404 — permanently, because
|
||||
// the page has genuinely moved. Through route() rather than a literal
|
||||
// path: once the page has its own hostname, a relative redirect would land
|
||||
// on the host the visitor is already on, where it no longer answers.
|
||||
Route::get('/status', fn () => redirect()->to(route('status'), 301))->name('status');
|
||||
});
|
||||
|
||||
// Guest auth pages — full-page class-based Livewire components (R1/R2). Fortify
|
||||
// handles the POST actions (login.store, two-factor.login.store) with views off.
|
||||
Route::middleware('guest')->group(function () {
|
||||
Route::get('/login', Login::class)->name('login');
|
||||
Route::get('/register', \App\Livewire\Auth\Register::class)->name('register');
|
||||
// Registration POST goes through Fortify's controller but with our own
|
||||
// registration-scoped throttle (Fortify's built-in route has no limiter).
|
||||
Route::post('/register', [\Laravel\Fortify\Http\Controllers\RegisteredUserController::class, 'store'])
|
||||
->middleware('throttle:registration')
|
||||
->name('register.store');
|
||||
Route::get('/two-factor-challenge', TwoFactorChallenge::class)->name('two-factor.login');
|
||||
});
|
||||
|
||||
// Signed and single-use; see ImpersonationController. Deliberately not behind
|
||||
// `auth`: following the link is what creates the session.
|
||||
Route::get('/impersonate/enter/{customer}/{operator}', [ImpersonationController::class, 'enter'])
|
||||
->name('impersonate.enter');
|
||||
|
||||
// Signed in, but the address has not been confirmed yet. Fortify would own
|
||||
// this page, but 'views' => false — it keeps the verify and resend actions and
|
||||
// the app renders its own screens (R1/R2).
|
||||
Route::get('/email/verify', \App\Livewire\Auth\VerifyEmail::class)
|
||||
->middleware('auth')
|
||||
->name('verification.notice');
|
||||
|
||||
// Customer portal — each sidebar tab is a full-page class-based Livewire
|
||||
// component (R1/R2); paths are English (R13).
|
||||
//
|
||||
// `verified` sits on the whole group rather than on the pages that spend money.
|
||||
// An unconfirmed address is not a billing problem to be caught at checkout: it
|
||||
// is an account that may not belong to the person holding it, and everything
|
||||
// behind here — the servers, the users, the backups — is worth as much to
|
||||
// somebody who typed a stranger's address as it is to its owner.
|
||||
Route::middleware(['auth', 'verified', 'customer.active'])->group(function () {
|
||||
Route::get('/dashboard', Dashboard::class)->name('dashboard');
|
||||
Route::get('/cloud', Cloud::class)->name('cloud');
|
||||
Route::get('/users', Users::class)->name('users');
|
||||
Route::get('/backups', Backups::class)->name('backups');
|
||||
Route::get('/invoices', Invoices::class)->name('invoices');
|
||||
Route::get('/billing', Billing::class)->name('billing');
|
||||
Route::get('/settings', \App\Livewire\Settings::class)->name('settings');
|
||||
Route::get('/support', Support::class)->name('support');
|
||||
|
||||
// Return from an admin impersonation session (accessible as the customer user).
|
||||
// POST so Laravel's CSRF middleware protects the identity change.
|
||||
Route::post('/impersonate/leave', [ImpersonationController::class, 'leave'])->name('impersonate.leave');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,19 +6,27 @@ use Illuminate\Routing\Router;
|
|||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/**
|
||||
* The portal's hostname shows the product, never the shop window.
|
||||
* The website and the portal each answer on their own hostname, and nowhere
|
||||
* else.
|
||||
*
|
||||
* The landing page was registered host-agnostically, so app.clupilot.com served
|
||||
* marketing copy, a pricing table and a "sign up" call to action — at the
|
||||
* address where a customer's servers live. Reported exactly that way.
|
||||
* 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, which is the idiom
|
||||
* WelcomeTest already uses for the status host.
|
||||
* These re-register them against a fresh router — the idiom WelcomeTest already
|
||||
* uses for the status host.
|
||||
*/
|
||||
function routerWithSiteHost(?string $host): Router
|
||||
function routerWithHosts(?string $siteHost, ?string $appHost = null): Router
|
||||
{
|
||||
config()->set('admin_access.site_host', $host ?? '');
|
||||
config()->set('admin_access.site_host', $siteHost ?? '');
|
||||
config()->set('admin_access.app_host', $appHost ?? '');
|
||||
|
||||
$router = new Router(app('events'), app());
|
||||
Route::swap($router);
|
||||
|
|
@ -28,19 +36,44 @@ function routerWithSiteHost(?string $host): Router
|
|||
return $router;
|
||||
}
|
||||
|
||||
it('does not serve the website on the portal’s hostname', function () {
|
||||
$router = routerWithSiteHost('www.example.test');
|
||||
/** 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'));
|
||||
|
||||
$action = fn (string $host) => $router->getRoutes()
|
||||
->match(Request::create("http://{$host}/", 'GET'))
|
||||
->getActionName();
|
||||
return true;
|
||||
} catch (Symfony\Component\HttpKernel\Exception\NotFoundHttpException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
expect($action('www.example.test'))->toContain('LandingController')
|
||||
->and($action('app.example.test'))->not->toContain('LandingController');
|
||||
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('sends a signed-out visitor on the portal host to the sign-in page', function () {
|
||||
$router = routerWithSiteHost('www.example.test');
|
||||
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 = $router->dispatch(Request::create('http://app.example.test/', 'GET'));
|
||||
|
||||
|
|
@ -51,35 +84,39 @@ it('sends a signed-out visitor on the portal host to the sign-in page', function
|
|||
it('sends a signed-in customer on the portal host to their dashboard', function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$router = routerWithSiteHost('www.example.test');
|
||||
|
||||
$router = routerWithHosts('www.example.test', 'app.example.test');
|
||||
$response = $router->dispatch(Request::create('http://app.example.test/', 'GET'));
|
||||
|
||||
expect($response->getStatusCode())->toBe(302)
|
||||
->and($response->headers->get('Location'))->toContain('/dashboard');
|
||||
});
|
||||
|
||||
it('leaves the imprint reachable from wherever the reader is standing', function () {
|
||||
// Deliberately not bound to the website's host: an imprint is a legal
|
||||
// requirement, and a mail footer links to it from whichever host sent the
|
||||
// mail.
|
||||
$router = routerWithSiteHost('www.example.test');
|
||||
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');
|
||||
|
||||
$action = fn (string $host) => $router->getRoutes()
|
||||
->match(Request::create("http://{$host}/legal/impressum", 'GET'))
|
||||
->getActionName();
|
||||
$matches = function (string $host) use ($router) {
|
||||
try {
|
||||
$router->getRoutes()->match(Request::create("http://{$host}/webhooks/stripe", 'POST'));
|
||||
|
||||
expect($action('app.example.test'))->toBe($action('www.example.test'));
|
||||
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('keeps the landing page on every host while no website host is configured', function () {
|
||||
// The installed base, and every development machine reached by IP.
|
||||
$router = routerWithSiteHost(null);
|
||||
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);
|
||||
|
||||
$action = fn (string $host) => $router->getRoutes()
|
||||
->match(Request::create("http://{$host}/", 'GET'))
|
||||
->getActionName();
|
||||
|
||||
expect($action('app.example.test'))->toContain('LandingController')
|
||||
->and($action('anything.example.test'))->toContain('LandingController');
|
||||
foreach (['/', '/dashboard', '/legal/impressum'] as $path) {
|
||||
expect(answers($router, 'anything.example.test', $path))->toBeTrue("everything should answer {$path}");
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue