diff --git a/app/Livewire/Admin/CheckResult.php b/app/Livewire/Admin/CheckResult.php new file mode 100644 index 0000000..902d79e --- /dev/null +++ b/app/Livewire/Admin/CheckResult.php @@ -0,0 +1,49 @@ + */ + public array $result = []; + + /** @param array $result */ + public function mount(string $entryKey, array $result): void + { + $this->authorize('secrets.manage'); + abort_if(! array_key_exists($entryKey, SecretVault::REGISTRY), 404); + + $this->entryKey = $entryKey; + $this->result = $result; + } + + public function render() + { + return view('livewire.admin.check-result', [ + 'label' => __(SecretVault::REGISTRY[$this->entryKey]['label']), + ]); + } +} diff --git a/app/Livewire/Admin/Integrations.php b/app/Livewire/Admin/Integrations.php index 115b558..0f52492 100644 --- a/app/Livewire/Admin/Integrations.php +++ b/app/Livewire/Admin/Integrations.php @@ -447,6 +447,15 @@ class Integrations extends Component $candidate = trim((string) ($this->entered[self::field($key)] ?? '')) ?: null; $this->check = app($checker)->run($candidate); $this->checkedKey = $key; + + // Als Modal, nicht nur an der Karte. Zweimal gemeldet: erst „der Knopf + // tut nichts" (die Antwort stand am Seitenende), dann + // „runterscrollen tut niemand, wenn man es nicht weiss". Eine Antwort, + // die man suchen muss, ist für den Betreiber keine. + $this->dispatch('openModal', component: 'admin.check-result', arguments: [ + 'entryKey' => $key, + 'result' => $this->check, + ]); } /** The dotless form key for a registry key (a dot means nesting to Livewire). */ diff --git a/resources/views/components/admin/secret-field.blade.php b/resources/views/components/admin/secret-field.blade.php index be34ee4..abb21a3 100644 --- a/resources/views/components/admin/secret-field.blade.php +++ b/resources/views/components/admin/secret-field.blade.php @@ -131,8 +131,5 @@ @endif - @if ($entry['check'] !== null) - - @endif @endif diff --git a/resources/views/livewire/admin/check-result.blade.php b/resources/views/livewire/admin/check-result.blade.php new file mode 100644 index 0000000..437920e --- /dev/null +++ b/resources/views/livewire/admin/check-result.blade.php @@ -0,0 +1,14 @@ +{{-- + R24: die Webhook-Liste kann lang werden, also gehört sie in einen Rumpf, + der scrollt, während Kopf und Fuss stehen bleiben. Deshalb + und nicht der einfache Kasten der Rückfrage-Modals. +--}} + + + + + + {{ __('common.close') }} + + + diff --git a/tests/Feature/Admin/ConnectionTestFeedbackTest.php b/tests/Feature/Admin/ConnectionTestFeedbackTest.php index b6f5ff2..a65a028 100644 --- a/tests/Feature/Admin/ConnectionTestFeedbackTest.php +++ b/tests/Feature/Admin/ConnectionTestFeedbackTest.php @@ -1,12 +1,21 @@ set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); @@ -14,27 +23,66 @@ beforeEach(function () { Http::preventStrayRequests(); }); +function integrationsUnlockedForCheck() +{ + return Livewire::actingAs(operator('Owner'), 'operator') + ->test(Integrations::class) + ->set('confirmablePassword', 'password') + ->call('confirmPassword'); +} + 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(); + expect(integrationsUnlockedForCheck()->call('test', 'stripe.secret')->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. +it('opens the answer in a modal instead of leaving it at the foot of the page', function () { + Http::fake(['api.stripe.com/v1/account' => Http::response([ + 'id' => 'acct_1', 'settings' => ['dashboard' => ['display_name' => 'CluPilot']], + ])]); + + integrationsUnlockedForCheck() + ->call('test', 'stripe.secret') + ->assertDispatched('openModal', function (string $event, array $params) { + return $params['component'] === 'admin.check-result' + && $params['arguments']['entryKey'] === 'stripe.secret' + && is_array($params['arguments']['result']); + }); +}); + +it('shows the Stripe answer in that modal', function () { + $result = [ + 'ok' => true, 'account' => 'acct_1', 'business' => 'CluPilot', + 'live' => false, 'restricted' => false, 'webhooks' => [], + ]; + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(CheckResult::class, ['entryKey' => 'stripe.secret', 'result' => $result]) + ->assertOk() + ->assertSee(__('secrets.check_webhooks_none')); +}); + +it('shows a DNS answer in that modal without reaching for Stripe fields', function () { + // Der Ergebnisblock las `account`, `live`, `webhooks` — alles nur bei + // Stripe vorhanden. Seit der DNS-Token einen eigenen Testknopf hat, kommt + // hier auch ein DnsTokenCheck-Ergebnis an, und dessen Erfolg heisst + // `writable`, läuft also in genau denselben Zweig. + $result = ['ok' => true, 'reason' => 'writable', 'probe_removed' => true]; + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(CheckResult::class, ['entryKey' => 'dns.token', 'result' => $result]) + ->assertOk() + ->assertSee(__('secrets.check_writable')); +}); + +it('runs the DNS check against the value that was typed, not the stored one', function () { + // Der Grund, warum das Ergebnis hereingereicht und nicht im Modal erhoben + // wird: geprüft wird der EINGETIPPTE, noch nicht gespeicherte Token. Ein + // Modal, das selbst prüfte, sähe ihn nicht — und DnsTokenCheck legte bei + // jedem Öffnen einen neuen Probeeintrag in der echten Zone an. Settings::set('provisioning.dns_zone', 'probe.example'); Http::fake([ 'api.hetzner.cloud/v1/zones' => Http::response(['zones' => [['id' => 42, 'name' => 'probe.example']]]), @@ -42,23 +90,16 @@ it('does not fall over when the DNS token is the one tested', function () { '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')); + integrationsUnlockedForCheck() + ->set('entered.dns_token', 'frisch-eingetippt') + ->call('test', 'dns.token'); + + Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer frisch-eingetippt')); }); -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\']'); +it('does not open the modal for somebody without the capability', function () { + // Ein Modal ist OHNE die Route-Middleware der Seite erreichbar. + Livewire::actingAs(operator('Support'), 'operator') + ->test(CheckResult::class, ['entryKey' => 'stripe.secret', 'result' => ['ok' => true]]) + ->assertForbidden(); });