Testergebnis erscheint am Feld statt am Seitenende; DNS-Token bekommt einen Testknopf

main
nexxo 2026-07-31 21:26:14 +02:00
parent b8a774b604
commit f91726d078
8 changed files with 162 additions and 43 deletions

View File

@ -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'),
]);

View File

@ -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' => [

View File

@ -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.',

View File

@ -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.',

View File

@ -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`.
--}}
<div class="mt-4 rounded-lg border border-line bg-surface-2 p-4">
<h4 class="text-sm font-semibold text-ink">{{ __('secrets.check_title') }}</h4>
@if (! $check['ok'])
<x-ui.alert variant="danger" class="mt-3">{{ __('secrets.check_'.$check['reason']) }}</x-ui.alert>
@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. --}}
<x-ui.alert variant="success" class="mt-3">{{ __('secrets.check_'.$check['reason']) }}</x-ui.alert>
@else
<dl class="mt-3 space-y-1 text-sm">
<div class="flex gap-2"><dt class="text-muted">{{ __('secrets.check_account') }}:</dt>
<dd class="font-mono text-body">{{ $check['account'] }}{{ $check['business'] ? ' · '.$check['business'] : '' }}</dd></div>
<div class="flex gap-2"><dt class="text-muted">{{ __('secrets.check_mode') }}:</dt>
<dd class="{{ $check['live'] ? 'font-medium text-warning' : 'text-body' }}">
{{ $check['live'] ? __('secrets.mode_live') : __('secrets.mode_test') }}{{ $check['restricted'] ? ' · '.__('secrets.mode_restricted') : '' }}
</dd></div>
</dl>
<h5 class="mt-4 text-sm font-semibold text-ink">{{ __('secrets.check_webhooks') }}</h5>
@if ($check['webhooks'] === null)
<p class="mt-1 text-sm text-muted">{{ __('secrets.check_webhooks_unknown') }}</p>
@elseif ($check['webhooks'] === [])
<x-ui.alert variant="warning" class="mt-2">{{ __('secrets.check_webhooks_none') }}</x-ui.alert>
@else
<ul class="mt-2 space-y-3">
@foreach ($check['webhooks'] as $hook)
<li class="rounded-lg border border-line bg-surface px-4 py-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="font-mono text-xs text-body">{{ $hook['url'] }}</span>
<span class="text-xs {{ $hook['status'] === 'enabled' ? 'text-success' : 'text-warning' }}">{{ $hook['status'] }}</span>
</div>
<div class="mt-2 flex flex-wrap gap-1.5">
@foreach ($hook['events'] as $event)
<span class="rounded border border-line px-1.5 py-0.5 font-mono text-[11px] text-muted">{{ $event }}</span>
@endforeach
</div>
</li>
@endforeach
</ul>
@endif
@endif
</div>

View File

@ -130,5 +130,9 @@
</x-ui.button>
@endif
</div>
@if ($entry['check'] !== null)
<x-admin.check-result :check="$entry['check']" :key="$entry['key']" />
@endif
@endif
</div>

View File

@ -368,49 +368,6 @@
</div>
@endif
{{-- Stripe's own answer, under the Stripe card it belongs to. --}}
@if ($tab === 'services' && $canSecrets && $unlocked && $check !== null)
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<h2 class="font-semibold text-ink">{{ __('secrets.check_title') }}</h2>
@if (! $check['ok'])
<x-ui.alert variant="danger" class="mt-3">{{ __('secrets.check_'.$check['reason']) }}</x-ui.alert>
@else
<dl class="mt-3 space-y-1 text-sm">
<div class="flex gap-2"><dt class="text-muted">{{ __('secrets.check_account') }}:</dt>
<dd class="font-mono text-body">{{ $check['account'] }}{{ $check['business'] ? ' · '.$check['business'] : '' }}</dd></div>
<div class="flex gap-2"><dt class="text-muted">{{ __('secrets.check_mode') }}:</dt>
<dd class="{{ $check['live'] ? 'font-medium text-warning' : 'text-body' }}">
{{ $check['live'] ? __('secrets.mode_live') : __('secrets.mode_test') }}{{ $check['restricted'] ? ' · '.__('secrets.mode_restricted') : '' }}
</dd></div>
</dl>
<h3 class="mt-5 text-sm font-semibold text-ink">{{ __('secrets.check_webhooks') }}</h3>
@if ($check['webhooks'] === null)
<p class="mt-1 text-sm text-muted">{{ __('secrets.check_webhooks_unknown') }}</p>
@elseif ($check['webhooks'] === [])
<x-ui.alert variant="warning" class="mt-2">{{ __('secrets.check_webhooks_none') }}</x-ui.alert>
@else
<ul class="mt-2 space-y-3">
@foreach ($check['webhooks'] as $hook)
<li class="rounded-lg border border-line bg-surface-2 px-4 py-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="font-mono text-xs text-body">{{ $hook['url'] }}</span>
<span class="text-xs {{ $hook['status'] === 'enabled' ? 'text-success' : 'text-warning' }}">{{ $hook['status'] }}</span>
</div>
<div class="mt-2 flex flex-wrap gap-1.5">
@foreach ($hook['events'] as $event)
<span class="rounded border border-line px-1.5 py-0.5 font-mono text-[11px] text-muted">{{ $event }}</span>
@endforeach
</div>
</li>
@endforeach
</ul>
@endif
@endif
</div>
@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

View File

@ -0,0 +1,64 @@
<?php
use App\Livewire\Admin\Integrations;
use App\Support\Settings;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
/**
* Gemeldet: „Verbindung testen" gibt keine Rückmeldung.
*/
beforeEach(function () {
config()->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\']');
});