diff --git a/app/Livewire/Admin/Integrations.php b/app/Livewire/Admin/Integrations.php index 814a0da..115b558 100644 --- a/app/Livewire/Admin/Integrations.php +++ b/app/Livewire/Admin/Integrations.php @@ -106,6 +106,15 @@ class Integrations extends Component public ?array $check = null; + /** + * Zu welchem Eintrag `$check` gehört. + * + * Ohne das erschien das Ergebnis eines DNS-Tests unter der Stripe-Karte — + * beide Prüfungen teilen sich eine Eigenschaft, und die Anzeige braucht + * den Bezug. + */ + public ?string $checkedKey = null; + // Plain settings — App\Support\ProvisioningSettings' full list. public string $dnsZone = ''; @@ -297,6 +306,7 @@ class Integrations extends Component $this->entered[$field] = ''; $this->check = null; + $this->checkedKey = null; $this->dispatch('notify', message: __('secrets.saved')); } @@ -332,6 +342,7 @@ class Integrations extends Component app(SecretVault::class)->forget($key); $this->check = null; + $this->checkedKey = null; $this->dispatch('notify', message: __('secrets.removed')); } @@ -435,6 +446,7 @@ class Integrations extends Component $candidate = trim((string) ($this->entered[self::field($key)] ?? '')) ?: null; $this->check = app($checker)->run($candidate); + $this->checkedKey = $key; } /** The dotless form key for a registry key (a dot means nesting to Livewire). */ @@ -540,6 +552,7 @@ class Integrations extends Component $this->lockAgain(); $this->check = null; + $this->checkedKey = null; } private function guardInfra(): void @@ -668,6 +681,9 @@ class Integrations extends Component // Everything else here is issued by somebody else (Stripe, // Hetzner, the mail host) and can only be pasted in. 'generatable' => $key === 'ssh.private_key', + // Das Ergebnis GEHÖRT zu diesem Eintrag, sonst null. So + // steht die Antwort unter dem Knopf, der sie ausgelöst hat. + 'check' => $this->checkedKey === $key ? $this->check : null, ]) ->keyBy('key'), ]); diff --git a/app/Services/Secrets/SecretVault.php b/app/Services/Secrets/SecretVault.php index cd18471..d489d40 100644 --- a/app/Services/Secrets/SecretVault.php +++ b/app/Services/Secrets/SecretVault.php @@ -3,6 +3,7 @@ namespace App\Services\Secrets; use App\Models\Operator; +use App\Services\Dns\DnsTokenCheck; use App\Services\Stripe\StripeCheck; use App\Support\OperatingMode; use Illuminate\Contracts\Encryption\DecryptException; @@ -115,6 +116,12 @@ final class SecretVault 'dns.token' => [ 'config' => 'provisioning.dns.token', 'label' => 'secrets.item.dns_token', + // Die Prüfung gab es längst — sie lief nur auf der + // Bereitschaftsseite und nicht an der Karte, auf der der Token + // eingetippt wird. Ein Wert prüfen zu können, BEVOR er gespeichert + // ist, ist genau der Punkt dieses Knopfes: ein falscher Token + // fällt sonst erst auf, wenn eine Bereitstellung an ihm stirbt. + 'check' => DnsTokenCheck::class, 'env_key' => 'HETZNER_DNS_TOKEN', ], 'monitoring.token' => [ diff --git a/lang/de/secrets.php b/lang/de/secrets.php index 2b45a5e..44b500d 100644 --- a/lang/de/secrets.php +++ b/lang/de/secrets.php @@ -72,6 +72,12 @@ return [ 'saved' => 'Gespeichert. Der neue Wert gilt ab sofort.', 'removed' => 'Entfernt. Es gilt wieder der Wert aus der Serverdatei.', 'empty' => 'Bitte einen Wert eingeben.', + 'check_writable' => 'Der Token darf schreiben. Ein Probeeintrag wurde angelegt und wieder entfernt.', + 'check_read_only' => 'Der Token darf lesen, aber nicht schreiben. Damit scheitert der A-Eintrag jeder neuen Instanz — nach der Bezahlung.', + 'check_write_failed' => 'Der Schreibversuch wurde abgelehnt. Über die Berechtigung des Tokens sagt das nichts.', + 'check_zone_not_found' => 'Die eingetragene Zone liegt nicht in diesem Konto. Einzelheiten auf der Bereitschaftsseite.', + 'check_zone_list_failed' => 'Die Zonenliste kam nicht. Über die Zonen dieses Kontos sagt das nichts.', + 'check_zone_list_unreadable' => 'Die Antwort war keine Zonenliste. Da hat etwas anderes geantwortet.', 'check_title' => 'Ergebnis der Prüfung', 'check_missing' => 'Es ist kein Schlüssel hinterlegt und auch keiner in der Serverdatei.', diff --git a/lang/en/secrets.php b/lang/en/secrets.php index f54a8ae..8238def 100644 --- a/lang/en/secrets.php +++ b/lang/en/secrets.php @@ -70,6 +70,12 @@ return [ 'saved' => 'Saved. The new value applies immediately.', 'removed' => 'Removed. The server file applies again.', 'empty' => 'Enter a value.', + 'check_writable' => 'The token may write. A probe record was created and removed again.', + 'check_read_only' => 'The token may read but not write. Every new instance\'s A record then fails — after payment.', + 'check_write_failed' => 'The write was rejected. That says nothing about the token\'s permissions.', + 'check_zone_not_found' => 'The configured zone is not in this account. Details on the readiness page.', + 'check_zone_list_failed' => 'The zone list did not arrive. That says nothing about this account\'s zones.', + 'check_zone_list_unreadable' => 'The answer was not a zone list. Something else replied.', 'check_title' => 'Result', 'check_missing' => 'No key is stored here and none is in the server file.', diff --git a/resources/views/components/admin/check-result.blade.php b/resources/views/components/admin/check-result.blade.php new file mode 100644 index 0000000..2e3f5b0 --- /dev/null +++ b/resources/views/components/admin/check-result.blade.php @@ -0,0 +1,59 @@ +@props(['check', 'key']) +{{-- + Die Antwort einer Prüfung, direkt unter dem Knopf, der sie ausgelöst hat. + + Vorher stand dieser Block ganz unten auf der Seite — hinter allen Karten und + hinter dem Speichern-Knopf. Der Testknopf sitzt oben in der Stripe-Karte; + wer ihn drückte, sah die Antwort nie, weil sie ausserhalb des Bildschirms + erschien. Gemeldet wurde das als „der Knopf tut nichts", und genau so sah es + aus. + + Und der Block ging von einem STRIPE-Ergebnis aus: `account`, `live`, + `webhooks`. Ein DnsTokenCheck-Ergebnis hat davon nichts — und sein Erfolg + heisst `writable`, lief also in genau diesen Zweig. Deshalb wird hier nach + dem geprüften Eintrag verzweigt statt nach `ok`. +--}} +
+

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

+ + @if (! $check['ok']) + {{ __('secrets.check_'.$check['reason']) }} + @elseif ($key !== 'stripe.secret') + {{-- Jede andere Prüfung sagt genau eine Sache: es geht. Was sie im + Fehlerfall zu sagen hat, steht ausführlich auf der + Bereitschaftsseite, die dafür die Stelle ist. --}} + {{ __('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') : '' }} +
+
+ +
{{ __('secrets.check_webhooks') }}
+ @if ($check['webhooks'] === null) +

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

+ @elseif ($check['webhooks'] === []) + {{ __('secrets.check_webhooks_none') }} + @else + + @endif + @endif +
diff --git a/resources/views/components/admin/secret-field.blade.php b/resources/views/components/admin/secret-field.blade.php index 5dbb554..be34ee4 100644 --- a/resources/views/components/admin/secret-field.blade.php +++ b/resources/views/components/admin/secret-field.blade.php @@ -130,5 +130,9 @@ @endif + + @if ($entry['check'] !== null) + + @endif @endif diff --git a/resources/views/livewire/admin/integrations.blade.php b/resources/views/livewire/admin/integrations.blade.php index 5f38baf..9720962 100644 --- a/resources/views/livewire/admin/integrations.blade.php +++ b/resources/views/livewire/admin/integrations.blade.php @@ -368,49 +368,6 @@ @endif - {{-- Stripe's own answer, under the Stripe card it belongs to. --}} - @if ($tab === 'services' && $canSecrets && $unlocked && $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') : '' }} -
-
- -

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

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

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

- @elseif ($check['webhooks'] === []) - {{ __('secrets.check_webhooks_none') }} - @else - - @endif - @endif -
- @endif - {{-- Part B: the raw .env editor. Everything above has a field; everything that does not still needs to be reachable from the console, or an operator needing it needs a shell and this whole page misses the diff --git a/tests/Feature/Admin/ConnectionTestFeedbackTest.php b/tests/Feature/Admin/ConnectionTestFeedbackTest.php new file mode 100644 index 0000000..b6f5ff2 --- /dev/null +++ b/tests/Feature/Admin/ConnectionTestFeedbackTest.php @@ -0,0 +1,64 @@ +set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); + withStripeSecret(); + Http::preventStrayRequests(); +}); + +it('produces a result at all when the button is pressed', function () { + Http::fake(['api.stripe.com/v1/account' => Http::response([ + 'id' => 'acct_1', 'settings' => ['dashboard' => ['display_name' => 'CluPilot']], + ])]); + + $page = Livewire::actingAs(operator('Owner'), 'operator') + ->test(Integrations::class) + ->set('confirmablePassword', 'password') + ->call('confirmPassword') + ->call('test', 'stripe.secret'); + + // Erst: kommt überhaupt ein Ergebnis an? Wenn ja, ist es keine kaputte + // Prüfung, sondern eine Anzeige, die niemand findet. + expect($page->get('check'))->not->toBeNull(); +}); + +it('does not fall over when the DNS token is the one tested', function () { + // Der Ergebnisblock las `$check['account']`, `$check['live']`, + // `$check['webhooks']` — alles nur bei Stripe vorhanden. Ein + // DnsTokenCheck-Ergebnis hat davon nichts, und `writable` ist obendrein + // ein ERFOLG, laeuft also in genau diesen Zweig. + Settings::set('provisioning.dns_zone', 'probe.example'); + Http::fake([ + 'api.hetzner.cloud/v1/zones' => Http::response(['zones' => [['id' => 42, 'name' => 'probe.example']]]), + 'api.hetzner.cloud/v1/zones/probe.example/rrsets' => Http::response(['rrset' => ['id' => 'x/TXT']], 201), + 'api.hetzner.cloud/v1/zones/probe.example/rrsets/*' => Http::response([], 201), + ]); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Integrations::class) + ->set('confirmablePassword', 'password') + ->call('confirmPassword') + ->set('entered.dns_token', 'irgendein-token') + ->call('test', 'dns.token') + ->assertOk() + ->assertSee(__('secrets.check_writable')); +}); + +it('shows the result beside the key it was run for, not at the end of the page', function () { + // Der eigentliche Fund: das Ergebnis stand ganz unten, hinter allen + // Karten und hinter dem Speichern-Knopf. Der Knopf sitzt oben in der + // Stripe-Karte — wer ihn drückt, sieht die Antwort nicht, weil sie + // ausserhalb des Bildschirms erscheint. Für den Betreiber ist das + // ununterscheidbar von „der Knopf tut nichts". + $markup = file_get_contents(base_path('resources/views/components/admin/secret-field.blade.php')); + + expect($markup)->toContain('$entry[\'check\']'); +});