diff --git a/.env.example b/.env.example index d438c2a..c24214c 100644 --- a/.env.example +++ b/.env.example @@ -215,6 +215,9 @@ 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, +# Mehrere Namen kommagetrennt, der ERSTE ist der kanonische — er liefert aus, +# alle weiteren leiten dauerhaft dorthin um (Pfad und Query bleiben erhalten): +# SITE_HOST=www.clupilot.com,clupilot.com # 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. diff --git a/VERSION b/VERSION index 3a3cd8c..1892b92 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.1 +1.3.2 diff --git a/app/Livewire/Admin/Infrastructure.php b/app/Livewire/Admin/Infrastructure.php deleted file mode 100644 index e8ec0f1..0000000 --- a/app/Livewire/Admin/Infrastructure.php +++ /dev/null @@ -1,81 +0,0 @@ -authorize('hosts.manage'); - - $this->dnsZone = ProvisioningSettings::dnsZone(); - $this->wgEndpoint = ProvisioningSettings::wgEndpoint(); - $this->wgHubPubkey = ProvisioningSettings::wgHubPublicKey(); - $this->traefikDynamicPath = ProvisioningSettings::traefikDynamicPath(); - $this->sshPublicKey = ProvisioningSettings::sshPublicKey(); - $this->monitoringUrl = ProvisioningSettings::monitoringUrl(); - } - - public function save(): void - { - $this->authorize('hosts.manage'); - - $data = $this->validate([ - 'dnsZone' => ['nullable', 'string', 'max:255'], - 'wgEndpoint' => ['nullable', 'string', 'max:255'], - 'wgHubPubkey' => ['nullable', 'string', 'max:255'], - 'traefikDynamicPath' => ['nullable', 'string', 'max:255'], - 'sshPublicKey' => ['nullable', 'string', 'max:1000'], - 'monitoringUrl' => ['nullable', 'url', 'max:255'], - ]); - - Settings::set('provisioning.dns_zone', trim((string) $data['dnsZone'])); - Settings::set('provisioning.wg_endpoint', trim((string) $data['wgEndpoint'])); - Settings::set('provisioning.wg_hub_pubkey', trim((string) $data['wgHubPubkey'])); - Settings::set('provisioning.traefik_dynamic_path', trim((string) $data['traefikDynamicPath'])); - Settings::set('provisioning.ssh_public_key', trim((string) $data['sshPublicKey'])); - Settings::set('monitoring.api_url', trim((string) $data['monitoringUrl'])); - - $this->dispatch('notify', message: __('infrastructure.saved')); - } - - public function render() - { - return view('livewire.admin.infrastructure'); - } -} diff --git a/app/Livewire/Admin/Secrets.php b/app/Livewire/Admin/Secrets.php deleted file mode 100644 index eeddca0..0000000 --- a/app/Livewire/Admin/Secrets.php +++ /dev/null @@ -1,175 +0,0 @@ -authorize('secrets.manage'); - } - - public function save(string $key): void - { - $this->guard(); - - $field = self::field($key); - $value = trim((string) ($this->entered[$field] ?? '')); - - if ($value === '') { - $this->addError('entered.'.$field, __('secrets.empty')); - - return; - } - - try { - app(SecretVault::class)->put($key, $value, Auth::guard('operator')->user()); - } catch (Throwable $e) { - $this->addError('entered.'.$field, $e->getMessage()); - - return; - } - - // Out of the component state as soon as it is stored. - $this->entered[$field] = ''; - $this->check = null; - $this->dispatch('notify', message: __('secrets.saved')); - } - - /** - * The save button opens ConfirmSaveSecret instead of calling save() - * directly (R23) — that modal cannot see `entered` itself, so its confirm - * button dispatches back here rather than mutating anything on its own. - */ - #[On('secret-save-confirmed')] - public function onSaveConfirmed(string $key): void - { - $this->save($key); - } - - public function forget(string $key): void - { - $this->guard(); - - app(SecretVault::class)->forget($key); - $this->check = null; - $this->dispatch('notify', message: __('secrets.removed')); - } - - /** See onSaveConfirmed() — same reasoning, for ConfirmForgetSecret. */ - #[On('secret-forget-confirmed')] - public function onForgetConfirmed(string $key): void - { - $this->forget($key); - } - - /** - * Try the key that is in force — or the one being typed, before storing it. - * - * Checking the candidate first is the point: a key that is saved and wrong - * fails later, somewhere else, usually in front of a customer. - */ - public function test(string $key): void - { - $this->guard(); - - // The checker named by THIS entry, not a fixed one. When the area held a - // single Stripe key a hard-coded StripeCheck was the same thing; with - // four entries it would have reported on Stripe while the operator was - // looking at the DNS token. - $checker = SecretVault::REGISTRY[$key]['check'] ?? null; - abort_if($checker === null, 404); - - $candidate = trim((string) ($this->entered[self::field($key)] ?? '')) ?: null; - $this->check = app($checker)->run($candidate); - } - - /** The dotless form key for a registry key. */ - public static function field(string $key): string - { - return str_replace('.', '_', $key); - } - - /** Capability AND a recent password, on every single action. */ - private function guard(): void - { - $this->authorize('secrets.manage'); - abort_unless($this->passwordRecentlyConfirmed(), 403); - } - - public function render() - { - $vault = app(SecretVault::class); - $unlocked = $this->passwordRecentlyConfirmed(); - - return view('livewire.admin.secrets', [ - 'unlocked' => $unlocked, - 'usable' => $vault->isUsable(), - 'entries' => collect(SecretVault::REGISTRY) - ->map(fn (array $meta, string $key) => [ - 'key' => $key, - 'field' => self::field($key), - 'label' => __($meta['label']), - // Only where a checker exists. A test button that cannot - // actually test is a promise the page does not keep. - 'testable' => isset($meta['check']), - 'source' => $vault->source($key), - // Only ever an outline, and only once unlocked. - 'outline' => $unlocked ? $vault->outline($key) : null, - 'updated_at' => $unlocked ? $vault->updatedAt($key) : null, - // The SSH identity is a multi-line PEM key: a single-line - // password field would mangle it on paste. Everything else - // here is a one-line token. - 'multiline' => $key === 'ssh.private_key', - ]) - ->values() - ->all(), - ]); - } -} diff --git a/config/admin_access.php b/config/admin_access.php index e00769a..15b8d52 100644 --- a/config/admin_access.php +++ b/config/admin_access.php @@ -62,24 +62,32 @@ return [ 'app_host' => strtolower(trim((string) env('APP_HOST', ''))), /* - | Hostname the public website lives on, e.g. www.clupilot.com. + | Hostnames the public website lives on, e.g. www.clupilot.com,clupilot.com. + | + | Comma-separated, and the FIRST is canonical: it serves the site, and every + | other name in the list redirects to it permanently, path and all. An apex + | domain and its www are two names for one site, and answering on both + | without picking one splits the search rankings and the cookies between + | them. | | Empty means the landing page answers on every host, which is what it did | — including on the portal's own hostname. Somebody who typed | 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 — 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. + | Set, the website — landing page, robots.txt and the legal pages — exists + | on these names and nowhere else. Every other host answers "/" with the + | product instead. The console keeps its own "/": its routes are registered + | first and win. | | 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. + | produce a canonical URL from inside a queued mail, where there is no + | request to take a hostname from. */ - 'site_host' => strtolower(trim((string) env('SITE_HOST', ''))), + 'site_hosts' => array_values(array_filter(array_map( + fn ($host) => strtolower(trim($host)), + explode(',', (string) env('SITE_HOST', '')) + ))), /* | Key for VPN configs stored at rest (32 bytes, base64). Separate from diff --git a/lang/de/infrastructure.php b/lang/de/infrastructure.php deleted file mode 100644 index 6548194..0000000 --- a/lang/de/infrastructure.php +++ /dev/null @@ -1,33 +0,0 @@ - 'Infrastruktur', - 'subtitle' => 'Bereitstellungs-Einstellungen — sichtbar und hier änderbar, ohne Zugriff auf den Server. Die zugehörigen Zugangsdaten liegen unter Zugangsdaten.', - - 'dns_title' => 'DNS & Traefik', - 'dns_body' => 'Die Zone, unter der Kundeninstanzen und Hosts erreichbar sind, und wo Traefik seine dynamische Konfiguration erwartet.', - 'dns_zone' => 'DNS-Zone', - 'dns_zone_hint' => 'Zum Beispiel clupilot.com — Kundeninstanzen erhalten ..', - 'traefik_path' => 'Traefik: Pfad für dynamische Konfiguration', - 'traefik_path_hint' => 'Verzeichnis auf dem Traffic-Host, in das Routen geschrieben werden.', - - 'wg_title' => 'WireGuard-Hub', - 'wg_body' => 'Wie ein neuer Host CluPilot als VPN-Hub erreicht. Der private Hub-Schlüssel bleibt in der Serverdatei — nur der öffentliche Teil und die Adresse stehen hier.', - 'wg_endpoint' => 'Hub-Endpunkt', - 'wg_endpoint_hint' => 'Öffentlich erreichbare Adresse und Port, z. B. vpn.clupilot.com:51820.', - 'wg_hub_pubkey' => 'Hub-Public-Key', - 'wg_hub_pubkey_hint' => 'Ausgabe von wg pubkey < /etc/wireguard/privatekey auf diesem Server.', - - 'ssh_title' => 'SSH-Identität', - 'ssh_body' => 'Der öffentliche Teil des Schlüssels, der auf jedem neuen Host hinterlegt wird. Der private Teil ist geheim und liegt unter Zugangsdaten.', - 'ssh_public_key' => 'SSH-Schlüssel (öffentlich)', - 'ssh_public_key_hint' => 'Wird bei der Host-Aufnahme in authorized_keys eingetragen.', - - 'monitoring_title' => 'Monitoring', - 'monitoring_body' => 'Wo die Kuma-Bridge erreichbar ist. Kuma selbst (KUMA_URL/KUMA_USERNAME/KUMA_PASSWORD/KUMA_TOTP) bleibt in der Serverdatei — das ist die Bridge, ein eigener Container, der sie beim Start liest, nicht diese Anwendung.', - 'monitoring_url' => 'Monitoring-Bridge-URL', - 'monitoring_url_hint' => 'Zum Beispiel http://kuma-bridge:8080. Leer lässt Monitoring aus.', - - 'save' => 'Speichern', - 'saved' => 'Gespeichert. Der neue Wert gilt ab sofort.', -]; diff --git a/lang/en/infrastructure.php b/lang/en/infrastructure.php deleted file mode 100644 index e5eb5c2..0000000 --- a/lang/en/infrastructure.php +++ /dev/null @@ -1,33 +0,0 @@ - 'Infrastructure', - 'subtitle' => 'Deployment settings — visible and changeable here, without server access. The matching credentials live under Credentials.', - - 'dns_title' => 'DNS & Traefik', - 'dns_body' => 'The zone customer instances and hosts live under, and where Traefik expects its dynamic configuration.', - 'dns_zone' => 'DNS zone', - 'dns_zone_hint' => 'For example clupilot.com — customer instances get ..', - 'traefik_path' => 'Traefik dynamic config path', - 'traefik_path_hint' => 'Directory on the traffic host that routes get written into.', - - 'wg_title' => 'WireGuard hub', - 'wg_body' => 'How a new host reaches CluPilot as the VPN hub. The private hub key stays in the server file — only the public half and the address live here.', - 'wg_endpoint' => 'Hub endpoint', - 'wg_endpoint_hint' => 'Publicly reachable address and port, e.g. vpn.clupilot.com:51820.', - 'wg_hub_pubkey' => 'Hub public key', - 'wg_hub_pubkey_hint' => 'Output of wg pubkey < /etc/wireguard/privatekey on this server.', - - 'ssh_title' => 'SSH identity', - 'ssh_body' => 'The public half of the key deployed to every fresh host. The private half is secret and lives under Credentials.', - 'ssh_public_key' => 'SSH key (public)', - 'ssh_public_key_hint' => 'Written into authorized_keys during host onboarding.', - - 'monitoring_title' => 'Monitoring', - 'monitoring_body' => 'Where the Kuma bridge is reachable. Kuma itself (KUMA_URL/KUMA_USERNAME/KUMA_PASSWORD/KUMA_TOTP) stays in the server file — that is the bridge, a separate container that reads them at startup, not this application.', - 'monitoring_url' => 'Monitoring bridge URL', - 'monitoring_url_hint' => 'For example http://kuma-bridge:8080. Leave blank to leave monitoring off.', - - 'save' => 'Save', - 'saved' => 'Saved. The new value applies immediately.', -]; diff --git a/resources/views/livewire/admin/infrastructure.blade.php b/resources/views/livewire/admin/infrastructure.blade.php deleted file mode 100644 index b8277b8..0000000 --- a/resources/views/livewire/admin/infrastructure.blade.php +++ /dev/null @@ -1,63 +0,0 @@ -
-
-

{{ __('infrastructure.title') }}

-

{{ __('infrastructure.subtitle') }}

-
- - {{-- One page, one save: every field here is a plain deployment setting — - no password re-confirmation, no per-field confirm modal (R23 governs - destructive actions; saving a URL is not one), unlike Zugangsdaten. --}} -
-
-
-

{{ __('infrastructure.dns_title') }}

-

{{ __('infrastructure.dns_body') }}

-
-
- - -
-
- -
-
-

{{ __('infrastructure.wg_title') }}

-

{{ __('infrastructure.wg_body') }}

-
-
- - -
-
- -
-
-

{{ __('infrastructure.ssh_title') }}

-

{{ __('infrastructure.ssh_body') }}

-
-
- - -

{{ __('infrastructure.ssh_public_key_hint') }}

- @error('sshPublicKey')

{{ $message }}

@enderror -
-
- -
-
-

{{ __('infrastructure.monitoring_title') }}

-

{{ __('infrastructure.monitoring_body') }}

-
- -
- -
- - {{ __('infrastructure.save') }} - -
-
-
diff --git a/resources/views/livewire/admin/secrets.blade.php b/resources/views/livewire/admin/secrets.blade.php deleted file mode 100644 index e413059..0000000 --- a/resources/views/livewire/admin/secrets.blade.php +++ /dev/null @@ -1,163 +0,0 @@ -
-
-

{{ __('secrets.title') }}

-

{{ __('secrets.subtitle') }}

-
- - @if (! $usable) - {{ __('secrets.no_key') }} - @endif - - @if (! $unlocked) - {{-- The second gate. Being signed in is not enough to read a payment - key: the realistic threat is an unlocked machine, and a session is - exactly what that hands over. --}} -
-

{{ __('secrets.locked_title') }}

-

{{ __('secrets.locked_body') }}

- -
-
- -
- - {{ __('secrets.unlock') }} - -
-
- @else -
-

{{ __('secrets.unlocked_note') }}

- -
- - @foreach ($entries as $entry) -
-
-
-

{{ $entry['label'] }}

-

{{ $entry['key'] }}

-
- - - {{ __('secrets.source_'.$entry['source']) }} - -
- - @if ($entry['outline']) -
-
-
{{ __('secrets.stored_value') }}
-
{{ $entry['outline'] }}
-
- @if ($entry['updated_at']) -
-
{{ __('secrets.changed') }}
-
{{ \Illuminate\Support\Carbon::parse($entry['updated_at'])->diffForHumans() }}
-
- @endif -
- @endif - -
- @if ($entry['multiline']) - {{-- A multi-line PEM key: a single-line password field - would mangle it on paste. Not masked — a textarea - cannot mask its content in any browser, and every - other tool that accepts an SSH key (GitHub - included) shows it in the clear while typing for - the same reason. --}} - - -

{{ __('secrets.ssh_private_key_hint') }}

- @error('entered.'.$entry['field'])

{{ $message }}

@enderror - @else - - @endif -
- -
- {{-- Test first: a key that is saved and wrong fails later, - somewhere else, usually in front of a customer. Only - where this entry actually has a checker. --}} - @if ($entry['testable']) - - {{ __('secrets.test') }} - - @endif - - - {{ __('secrets.save') }} - - - @if ($entry['source'] === 'stored') - - {{ __('secrets.forget') }} - - @endif -
-
- @endforeach - - @if ($check !== null) -
-

{{ __('secrets.check_title') }}

- - @if (! $check['ok']) - {{ __('secrets.check_'.$check['reason']) }} - @else -
-
{{ __('secrets.check_account') }}:
-
{{ $check['account'] }}{{ $check['business'] ? ' · '.$check['business'] : '' }}
-
{{ __('secrets.check_mode') }}:
-
- {{ $check['live'] ? __('secrets.mode_live') : __('secrets.mode_test') }}{{ $check['restricted'] ? ' · '.__('secrets.mode_restricted') : '' }} -
-
- - {{-- The part nobody can see from outside: a key can be - perfectly valid while the endpoint listens for the - wrong events, and nothing fails until a payment goes - unrecorded. --}} -

{{ __('secrets.check_webhooks') }}

- @if ($check['webhooks'] === null) -

{{ __('secrets.check_webhooks_unknown') }}

- @elseif ($check['webhooks'] === []) - {{ __('secrets.check_webhooks_none') }} - @else -
    - @foreach ($check['webhooks'] as $hook) -
  • -
    - {{ $hook['url'] }} - {{ $hook['status'] }} -
    -
    - @foreach ($hook['events'] as $event) - {{ $event }} - @endforeach -
    -
  • - @endforeach -
- @endif - @endif -
- @endif - - {{ __('secrets.webhook_secret_note') }} - @endif -
diff --git a/routes/web.php b/routes/web.php index fe8d268..31d608c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -170,7 +170,9 @@ if ($statusHost !== '') { | 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'); +$siteHosts = (array) config('admin_access.site_hosts', []); +$siteHost = $siteHosts[0] ?? ''; // canonical: the one that serves +$siteAliases = array_slice($siteHosts, 1); // every other name redirects to it $appHost = (string) config('admin_access.app_host'); $publicSite = function () { @@ -263,6 +265,31 @@ if ($appHost !== '') { if ($siteHost !== '') { Route::domain($siteHost)->group($publicSite); + // Every other name for the same site — an apex beside its www — answers + // with a permanent redirect to the canonical one, path and query intact. + // Serving both without picking one splits search rankings and cookies + // between two names for one thing. + // + // Registered as a catch-all on those hosts alone, so it cannot swallow a + // path on any other host. It is deliberately not a middleware: a redirect + // that only some routes get is the half-measure this file already made + // once. + foreach ($siteAliases as $index => $alias) { + Route::domain($alias) + // The injected request, not the request() helper: this closure also + // runs in tests that dispatch through a router of their own, where + // the helper resolves whatever happens to be bound in the container + // rather than the request being answered. + ->get('/{path?}', function (Illuminate\Http\Request $request, ?string $path = null) use ($siteHost) { + $target = 'https://'.$siteHost.'/'.ltrim((string) $path, '/'); + $query = $request->getQueryString(); + + return redirect()->away($query ? $target.'?'.$query : $target, 301); + }) + ->where('path', '.*') + ->name("site.alias{$index}"); + } + // "/" 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. diff --git a/tests/Feature/Admin/InfrastructureSettingsTest.php b/tests/Feature/Admin/InfrastructureSettingsTest.php deleted file mode 100644 index 2950ae5..0000000 --- a/tests/Feature/Admin/InfrastructureSettingsTest.php +++ /dev/null @@ -1,55 +0,0 @@ -test(Infrastructure::class) - ->assertForbidden(); -}); - -it('loads the .env-derived value when nothing has been saved from the console yet', function () { - config()->set('provisioning.dns.zone', 'env-zone.example'); - - Livewire::actingAs(operator('Owner'), 'operator') - ->test(Infrastructure::class) - ->assertSet('dnsZone', 'env-zone.example'); -}); - -it('saves every field, and the consumer reads the saved value back', function () { - Livewire::actingAs(operator('Owner'), 'operator') - ->test(Infrastructure::class) - ->set('dnsZone', 'console-zone.example') - ->set('wgEndpoint', 'vpn.example:51820') - ->set('wgHubPubkey', 'hubpubkey==') - ->set('traefikDynamicPath', '/etc/traefik/dynamic') - ->set('sshPublicKey', 'ssh-ed25519 AAAAC3 clupilot') - ->set('monitoringUrl', 'http://kuma-bridge:8080') - ->call('save') - ->assertHasNoErrors(); - - // Not just that Settings holds the value — that the class every real - // consumer (HttpHetznerDnsClient, LocalWireguardHub, SshTraefikWriter, - // EstablishSshTrust, HttpMonitoringClient) actually calls returns it. - expect(ProvisioningSettings::dnsZone())->toBe('console-zone.example') - ->and(ProvisioningSettings::wgEndpoint())->toBe('vpn.example:51820') - ->and(ProvisioningSettings::wgHubPublicKey())->toBe('hubpubkey==') - ->and(ProvisioningSettings::traefikDynamicPath())->toBe('/etc/traefik/dynamic') - ->and(ProvisioningSettings::sshPublicKey())->toBe('ssh-ed25519 AAAAC3 clupilot') - ->and(ProvisioningSettings::monitoringUrl())->toBe('http://kuma-bridge:8080'); -}); - -it('refuses a monitoring URL that is not a URL', function () { - Livewire::actingAs(operator('Owner'), 'operator') - ->test(Infrastructure::class) - ->set('monitoringUrl', 'not a url') - ->call('save') - ->assertHasErrors('monitoringUrl'); -}); diff --git a/tests/Feature/Admin/SecretsPageTest.php b/tests/Feature/Admin/SecretsPageTest.php deleted file mode 100644 index 913908f..0000000 --- a/tests/Feature/Admin/SecretsPageTest.php +++ /dev/null @@ -1,198 +0,0 @@ -set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); -}); - -it('shows the no-key banner for a malformed SECRETS_KEY, not only a missing one', function () { - // Codex R15#3, P2: render() feeds 'usable' => $vault->isUsable() to the - // view, and the banner is the one place an operator actually sees that - // verdict. Before the fix, a malformed (nonempty, wrong-length) key made - // isUsable() answer true, so this banner stayed hidden — the page said - // credentials could be stored here when they could not. Asserted before - // the lock gate specifically because @if (! $usable) sits above @if (! - // $unlocked) in the view; a signed-in-but-locked session must still see - // it. - config()->set('admin_access.secrets_key', 'zu-kurz'); - - Livewire::actingAs(operator('Owner'), 'operator') - ->test(SecretsPage::class) - ->assertSee(__('secrets.no_key')); -}); - -it('is not reachable without the capability', function () { - // Every operator has console.view. That must not mean "can read the - // payment key". - Livewire::actingAs(operator('Admin'), 'operator') - ->test(SecretsPage::class) - ->assertForbidden(); -}); - -it('shows nothing until the password is confirmed', function () { - $owner = operator('Owner'); - app(SecretVault::class)->put('stripe.secret', 'sk_live_ABCDEFGH9999', $owner); - - $page = Livewire::actingAs($owner, 'operator')->test(SecretsPage::class); - - $page->assertSee(__('secrets.locked_title')) - // Not even the outline before unlocking. - ->assertDontSee('9999') - ->assertViewHas('unlocked', false); -}); - -it('refuses every action while locked, not merely hiding the buttons', function () { - // A Livewire action is reachable by anyone who can post to /livewire/update. - $owner = operator('Owner'); - - foreach ([['save', 'stripe.secret'], ['forget', 'stripe.secret'], ['test', 'stripe.secret']] as [$action, $arg]) { - Livewire::actingAs($owner, 'operator') - ->test(SecretsPage::class) - ->call($action, $arg) - ->assertForbidden(); - } -}); - -it('unlocks with the password, then stores and clears the entered value', function () { - $owner = operator('Owner'); - - $page = Livewire::actingAs($owner, 'operator') - ->test(SecretsPage::class) - ->set('confirmablePassword', 'password') - ->call('confirmPassword') - ->assertHasNoErrors() - ->assertViewHas('unlocked', true); - - $page->set('entered.stripe_secret', 'sk_live_NEWKEY1234') - ->call('save', 'stripe.secret') - ->assertHasNoErrors(); - - expect(app(SecretVault::class)->get('stripe.secret'))->toBe('sk_live_NEWKEY1234') - // Out of the component state as soon as it is stored: a Livewire - // property travels to the browser and back in the snapshot. - ->and($page->get('entered')['stripe_secret'])->toBe(''); -}); - -it('never puts a stored secret into the component snapshot', function () { - $owner = operator('Owner'); - app(SecretVault::class)->put('stripe.secret', 'sk_live_TOPSECRET42', $owner); - - $page = Livewire::actingAs($owner, 'operator') - ->test(SecretsPage::class) - ->set('confirmablePassword', 'password') - ->call('confirmPassword'); - - expect(json_encode($page->snapshot))->not->toContain('sk_live_TOPSECRET42'); -}); - -it('can be locked again without signing out', function () { - $owner = operator('Owner'); - - Livewire::actingAs($owner, 'operator') - ->test(SecretsPage::class) - ->set('confirmablePassword', 'password') - ->call('confirmPassword') - ->assertViewHas('unlocked', true) - ->call('forgetPasswordConfirmation') - ->assertViewHas('unlocked', false); -}); - -// R23: confirmation moved from wire:confirm to ConfirmSaveSecret / -// ConfirmForgetSecret. Neither modal can see Secrets' own (deferred) -// `entered` state, so they dispatch back to the page instead of mutating -// anything themselves — save()/forget() keep their existing guard(). - -it('does not open the save/forget confirmation without the capability', function () { - Livewire::actingAs(operator('Admin'), 'operator') - ->test(ConfirmSaveSecret::class, ['key' => 'stripe.secret']) - ->assertForbidden(); - - Livewire::actingAs(operator('Admin'), 'operator') - ->test(ConfirmForgetSecret::class, ['key' => 'stripe.secret']) - ->assertForbidden(); -}); - -it('confirms a save through the modal without storing anything itself', function () { - $owner = operator('Owner'); - - Livewire::actingAs($owner, 'operator') - ->test(ConfirmSaveSecret::class, ['key' => 'stripe.secret']) - ->assertSee(__('secrets.item.stripe_secret')) - ->call('confirm') - ->assertDispatched('secret-save-confirmed', key: 'stripe.secret'); - - // The modal only asked; nothing is stored until the page itself saves. - expect(app(SecretVault::class)->has('stripe.secret'))->toBeFalse(); -}); - -it('saves the typed value once the page receives the confirmed event', function () { - // Exercises the forwarding listener directly — the same round trip a - // browser produces by dispatching openModal then confirm (checked - // manually against admin.dev.clupilot.com, since a global browser event - // reaching a sibling component's #[On] listener is not something a - // single-component Livewire::test() can simulate). - $owner = operator('Owner'); - - Livewire::actingAs($owner, 'operator') - ->test(SecretsPage::class) - ->set('confirmablePassword', 'password') - ->call('confirmPassword') - ->set('entered.stripe_secret', 'sk_live_VIACONFIRM1') - ->call('onSaveConfirmed', 'stripe.secret') - ->assertHasNoErrors(); - - expect(app(SecretVault::class)->get('stripe.secret'))->toBe('sk_live_VIACONFIRM1'); -}); - -it('forgets the stored value once the page receives the confirmed event', function () { - $owner = operator('Owner'); - app(SecretVault::class)->put('stripe.secret', 'sk_live_TOFORGET999', $owner); - - Livewire::actingAs($owner, 'operator') - ->test(SecretsPage::class) - ->set('confirmablePassword', 'password') - ->call('confirmPassword') - ->call('onForgetConfirmed', 'stripe.secret'); - - expect(app(SecretVault::class)->has('stripe.secret'))->toBeFalse(); -}); - -it('stores the SSH identity with its internal newlines intact', function () { - // The one multi-line entry on the page (a PEM key) — save() only trims - // the ENDS, so an internal newline must survive the round trip exactly. - config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); - $owner = operator('Owner'); - $pem = "-----BEGIN OPENSSH PRIVATE KEY-----\nabcdef\nghijkl\n-----END OPENSSH PRIVATE KEY-----"; - - Livewire::actingAs($owner, 'operator') - ->test(SecretsPage::class) - ->set('confirmablePassword', 'password') - ->call('confirmPassword') - ->set('entered.ssh_private_key', $pem) - ->call('save', 'ssh.private_key') - ->assertHasNoErrors(); - - expect(app(SecretVault::class)->get('ssh.private_key'))->toBe($pem); -}); - -it('binds the form to a key Livewire can actually write to', function () { - // A dot in a Livewire property path means nesting, so a registry key with a - // dot in it would be written to entered['stripe']['secret'] and the value - // would never reach the save. - expect(Secrets::field('stripe.secret'))->toBe('stripe_secret') - ->and(str_contains(Secrets::field('stripe.secret'), '.'))->toBeFalse(); -}); diff --git a/tests/Feature/PortalHostTest.php b/tests/Feature/PortalHostTest.php index f52bfaf..d80415e 100644 --- a/tests/Feature/PortalHostTest.php +++ b/tests/Feature/PortalHostTest.php @@ -23,9 +23,9 @@ use Illuminate\Support\Facades\Route; * These re-register them against a fresh router — the idiom WelcomeTest already * uses for the status host. */ -function routerWithHosts(?string $siteHost, ?string $appHost = null): Router +function routerWithHosts(string|array|null $siteHosts, ?string $appHost = null): Router { - config()->set('admin_access.site_host', $siteHost ?? ''); + 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()); @@ -36,6 +36,24 @@ function routerWithHosts(?string $siteHost, ?string $appHost = null): Router 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 { @@ -75,7 +93,7 @@ it('answers "/" on the portal host with the product, never a 404', function () { // 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')); + $response = dispatchOn($router, Request::create('http://app.example.test/', 'GET')); expect($response->getStatusCode())->toBe(302) ->and($response->headers->get('Location'))->toContain('/login'); @@ -85,7 +103,7 @@ 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 = $router->dispatch(Request::create('http://app.example.test/', 'GET')); + $response = dispatchOn($router, Request::create('http://app.example.test/', 'GET')); expect($response->getStatusCode())->toBe(302) ->and($response->headers->get('Location'))->toContain('/dashboard'); @@ -120,3 +138,33 @@ it('binds nothing at all while no hostnames are configured', function () { 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(); +});