From 5f74a5f3709089f7f81141c367b1bd6be8e51891 Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 17:26:20 +0200 Subject: [PATCH] Move the zone by migration, and put every path in English MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ── The zone, for the fourth time ──────────────────────────────────────────── "Change it in the console" was the wrong answer three times running. An installation with nothing to migrate should simply arrive on the new zone, so a migration does it — guarded by exactly the condition clupilot:check-zone reports on: no instance in service, therefore nothing carrying the old name in DNS, in a certificate or in trusted_domains. With even one instance it does nothing and logs why, because re-addressing a running Nextcloud is a migration with an outage and a migration is not the place to start one. It leaves an installation already on another zone alone: the setting exists so somebody else can run this on their own domain. That fixes both reports at once — the security page and the price sheet read the same setting, which is the entire reason neither of them has the domain typed into it. ── R13, applied to all of them this time ──────────────────────────────────── "Ich schreibe das nun zum dritten Mal" — fair. The rule was cited, I changed the one route that had been pointed at, and left every other German path standing. A fragment is part of a URL too. #produkt → #product /legal/impressum → /legal/imprint #ablauf → #process /legal/datenschutz → /legal/privacy #preise → #pricing /legal/agb → /legal/terms #fragen → #faq (old paths redirect, permanently — they have been #kontakt → #contact in the footer and in mail for weeks) The headings stay German. "Impressum" is the legal term an Austrian company has to use; the address is not part of the copy. A test now walks every registered route and every fragment the site links to, so the next German path fails the suite instead of being reported a fourth time. Three existing tests set config('provisioning.dns.zone') and read it back through the accessor. That worked while the setting was empty and stopped the moment the migration filled it — they set the setting now, and the one whose premise is "nothing saved yet" establishes that premise itself. Co-Authored-By: Claude Opus 5 --- ...30000_move_instances_to_their_own_zone.php | 59 +++++++++++++++++++ .../views/components/layouts/site.blade.php | 14 ++--- resources/views/landing.blade.php | 24 ++++---- routes/web.php | 15 ++++- tests/Feature/Admin/IntegrationsPageTest.php | 4 ++ tests/Feature/LandingPriceSheetTest.php | 5 +- tests/Feature/OfficialDomainsTest.php | 42 +++++++++++++ tests/Feature/PortalTabsTest.php | 4 +- 8 files changed, 143 insertions(+), 24 deletions(-) create mode 100644 database/migrations/2026_07_29_230000_move_instances_to_their_own_zone.php diff --git a/database/migrations/2026_07_29_230000_move_instances_to_their_own_zone.php b/database/migrations/2026_07_29_230000_move_instances_to_their_own_zone.php new file mode 100644 index 0000000..d8fc362 --- /dev/null +++ b/database/migrations/2026_07_29_230000_move_instances_to_their_own_zone.php @@ -0,0 +1,59 @@ +whereIn('status', ['active', 'provisioning', 'cancellation_scheduled']) + ->count(); + + if ($inService > 0) { + Log::warning('Instance zone left on '.self::OLD.': '.$inService.' instance(s) in service carry it in DNS, certificates and trusted_domains. Run clupilot:check-zone and plan a window.'); + + return; + } + + Settings::set('provisioning.dns_zone', self::NEW); + } + + public function down(): void + { + if (ProvisioningSettings::dnsZone() === self::NEW) { + Settings::set('provisioning.dns_zone', self::OLD); + } + } +}; diff --git a/resources/views/components/layouts/site.blade.php b/resources/views/components/layouts/site.blade.php index ec34e6c..3c69352 100644 --- a/resources/views/components/layouts/site.blade.php +++ b/resources/views/components/layouts/site.blade.php @@ -42,14 +42,14 @@
- Preise + Preise
@@ -86,10 +86,10 @@

Produkt

diff --git a/resources/views/landing.blade.php b/resources/views/landing.blade.php index 629c26a..e370840 100644 --- a/resources/views/landing.blade.php +++ b/resources/views/landing.blade.php @@ -25,7 +25,7 @@ @@ -150,7 +150,7 @@ Six equal cards in two rows was the old page's answer to every section. Tiles of different weight let the important thing be bigger than the rest, which is the whole point of having a layout. --}} -
+

Was enthalten ist

@@ -260,7 +260,7 @@

{{-- ════ The dark hinge — how it arrives ════════════════════════════════ --}} -
+
{{-- No clock anywhere in this section any more. "In 20 Minuten betriebsbereit", a log with minute marks down the @@ -341,7 +341,7 @@ A three-column table was the old page's version of this. What a visitor needs from it is the pairing — measure, rhythm, and the piece of paper they can hand to somebody — so the pairing is what the row shows. --}} -
+

Sicherheit & Nachweis

@@ -456,7 +456,7 @@ Cards, with the matrix folded away underneath. The matrix was the first thing a visitor met on the old page: twelve rows of ticks before a single price was legible. --}} -
+

Preise

@@ -475,7 +475,7 @@

Die Paketübersicht steht gerade nicht zur Verfügung. Wir nennen Ihnen die aktuellen Konditionen gerne direkt.

- Preise anfragen + Preise anfragen

@else @php @@ -540,7 +540,7 @@ @endif - + {{ $plan['name'] }} anfragen

@@ -669,7 +669,7 @@
{{-- ════ Questions ══════════════════════════════════════════════════════ --}} -
+

Häufige Fragen

@@ -709,7 +709,7 @@ {{-- ════ Close ══════════════════════════════════════════════════════════ The one large accent area. Orange appears in few places on this page, and this is allowed to be all of it. --}} -
+
diff --git a/routes/web.php b/routes/web.php index 381fdd5..5173f55 100644 --- a/routes/web.php +++ b/routes/web.php @@ -203,9 +203,18 @@ $publicSite = function () { // 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'); + // R13: paths are English, headings are not. The page still says + // "Impressum" — that is the legal term an Austrian company must use — + // but the address is not part of the copy. + Route::get('/imprint', fn () => view('legal', ['title' => 'Impressum']))->name('impressum'); + Route::get('/privacy', fn () => view('legal', ['title' => 'Datenschutz']))->name('datenschutz'); + Route::get('/terms', fn () => view('legal', ['title' => 'AGB']))->name('agb'); + + // The German paths these shipped under. Permanent, because they have + // been in mail footers and in the site footer for weeks. + foreach (['impressum' => 'impressum', 'datenschutz' => 'datenschutz', 'agb' => 'agb'] as $old => $to) { + Route::get('/'.$old, fn () => redirect()->route('legal.'.$to, status: 301)); + } // 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'); diff --git a/tests/Feature/Admin/IntegrationsPageTest.php b/tests/Feature/Admin/IntegrationsPageTest.php index 6e1eb5b..c936d4a 100644 --- a/tests/Feature/Admin/IntegrationsPageTest.php +++ b/tests/Feature/Admin/IntegrationsPageTest.php @@ -309,6 +309,10 @@ it('binds the form to a key Livewire can actually write to', function () { // ---- Plain settings: ported from the former InfrastructureSettingsTest. ---- it('loads the .env-derived value when nothing has been saved from the console yet', function () { + // The premise has to be established now: a migration moves customer + // instances onto their own zone on a fresh install, so "nothing saved" is + // no longer the state a new database is in. + App\Support\Settings::forget('provisioning.dns_zone'); config()->set('provisioning.dns.zone', 'env-zone.example'); Livewire::actingAs(operator('Owner'), 'operator') diff --git a/tests/Feature/LandingPriceSheetTest.php b/tests/Feature/LandingPriceSheetTest.php index e1e21a6..0f013cb 100644 --- a/tests/Feature/LandingPriceSheetTest.php +++ b/tests/Feature/LandingPriceSheetTest.php @@ -233,7 +233,10 @@ it('shows one support level per plan, and never a dash for the best of them', fu it('shows the platform address in the shape a customer will type it, under the configured zone', function () { // "Eigene CluPilot-Subdomain" as a table row was meaningless twice over: // every instance gets one, and the phrase does not say what it looks like. - config()->set('provisioning.dns.zone', 'wolke.test'); + // Through the setting, not the config: the setting is what + // ProvisioningSettings::dnsZone() reads first, and since instances moved to + // their own zone there is a row in it on every installation. + App\Support\Settings::set('provisioning.dns_zone', 'wolke.test'); $this->get('/') ->assertOk() diff --git a/tests/Feature/OfficialDomainsTest.php b/tests/Feature/OfficialDomainsTest.php index 0d6bebb..18320d7 100644 --- a/tests/Feature/OfficialDomainsTest.php +++ b/tests/Feature/OfficialDomainsTest.php @@ -82,3 +82,45 @@ it('says on the sign-in form which host it is', function () { ->assertOk() ->assertSee(__('auth.phishing_link')); }); + +it('puts customer instances on their own registrable domain out of the box', function () { + // Asked for four times, and "change it in the console" was the wrong + // answer each time: an installation with nothing to migrate should simply + // arrive on the new zone. The migration does it, guarded by exactly the + // condition clupilot:check-zone reports on. + expect(App\Support\ProvisioningSettings::dnsZone())->toBe('clupilot.cloud'); +}); + +it('keeps every public path in English', function () { + // R13, applied to ALL of them this time rather than to the one that was + // pointed at. A fragment is part of a URL too, and the section anchors were + // German for weeks after the rule was first cited. + $routes = collect(app('router')->getRoutes()) + ->map(fn ($route) => $route->uri()) + ->filter(fn (string $uri) => ! str_starts_with($uri, '_')); + + $german = ['sicherheit', 'impressum', 'datenschutz', 'agb', 'preise', 'produkt', 'ablauf', 'fragen', 'kontakt', 'einstellungen', 'rechnungen']; + + foreach ($routes as $uri) { + foreach ($german as $word) { + // The redirects that keep old addresses alive are the exception, + // and they are the only routes allowed to carry these words. + $isRedirect = collect(app('router')->getRoutes()) + ->first(fn ($r) => $r->uri() === $uri)?->getActionName() === 'Closure'; + + if (str_contains($uri, $word) && ! $isRedirect) { + expect($uri)->toBe("no German path: {$uri}"); + } + } + } + + // And the fragments the site links to. + foreach (['landing.blade.php', 'components/layouts/site.blade.php'] as $view) { + $source = Illuminate\Support\Facades\File::get(resource_path('views/'.$view)); + + foreach (['#produkt', '#ablauf', '#sicherheit', '#preise', '#fragen', '#kontakt'] as $fragment) { + expect($source)->not->toContain($fragment.'"') + ->and($source)->not->toContain($fragment."'"); + } + } +}); diff --git a/tests/Feature/PortalTabsTest.php b/tests/Feature/PortalTabsTest.php index a98e76b..8c4fd84 100644 --- a/tests/Feature/PortalTabsTest.php +++ b/tests/Feature/PortalTabsTest.php @@ -32,7 +32,9 @@ it('binds the cloud card to the real instance', function () { $this->actingAs($user)->get(route('cloud')) ->assertOk() - ->assertSee('acme-ag.'.config('provisioning.dns.zone')) // real subdomain, not a fixture + // Through the same accessor the page uses. Reading config() directly + // stopped matching when the zone moved into the settings table. + ->assertSee('acme-ag.'.App\Support\ProvisioningSettings::dnsZone()) // real subdomain, not a fixture ->assertSee(__('billing.plan.business')) ->assertSee(__('billing.perf.high')); });