Fix round: name the slot on the card, and cover the modal that already worked

Names which of the two slots a card actually reads and writes right now, at
the card an operator types into (not only the page-top badge) — save() and
render() resolve without a mode argument, so the card looked identical in
both modes while quietly acting on whichever slot was active. Also adds the
ConfirmSwitchMode coverage IntegrationsPageTest already carries for
ConfirmSaveSecret/ConfirmForgetSecret: a modal is reachable without the
page's route middleware, and reading the code is not the same as testing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus
nexxo 2026-07-30 15:32:16 +02:00
parent 96f171b3b3
commit fe1110de71
5 changed files with 105 additions and 13 deletions

View File

@ -22,6 +22,17 @@ return [
'unlocked_note' => 'Entsperrt. Änderungen wirken sofort auf den laufenden Betrieb.',
'lock_again' => 'Wieder sperren',
// Welcher der zwei Plätze dieses Eintrags jetzt gilt — Speichern UND
// Lesen, weil save()/render() ohne Modus-Argument auf
// OperatingMode::current() auflösen. Benennt den PLATZ, nicht den Modus:
// „Testbetrieb"/„Livebetrieb" steht schon in der Plakette oben auf der
// Seite, hier geht es darum, in welche der zwei Zeilen ein neuer Wert
// tatsächlich landet.
'slot' => [
'test' => 'Testplatz aktiv',
'live' => 'Live-Platz aktiv',
],
'source_stored' => 'Hier hinterlegt',
'source_environment' => 'Aus der Serverdatei',
'source_none' => 'Nicht gesetzt',

View File

@ -22,6 +22,17 @@ return [
'unlocked_note' => 'Unlocked. Changes take effect on the running system immediately.',
'lock_again' => 'Lock again',
// Which of the two slots this entry resolves to right now — for both
// saving and reading, since save()/render() resolve against
// OperatingMode::current() without a mode argument. Names the SLOT, not
// the mode: "Test mode"/"Live mode" already sits in the badge at the top
// of the page; this is about which of the two rows a new value actually
// lands in.
'slot' => [
'test' => 'Test slot active',
'live' => 'Live slot active',
],
'source_stored' => 'Stored here',
'source_environment' => 'From the server file',
'source_none' => 'Not set',

View File

@ -1,4 +1,4 @@
@props(['entry', 'unlocked'])
@props(['entry', 'unlocked', 'mode'])
{{--
One vault entry outline, new-value field, test/save/forget reusable
across every integration section on App\Livewire\Admin\Integrations. Kept
@ -17,13 +17,28 @@
<h3 class="text-sm font-semibold text-ink">{{ $entry['label'] }}</h3>
<p class="mt-0.5 font-mono text-xs text-muted">{{ $entry['key'] }}</p>
</div>
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
{{ $entry['source'] === 'stored' ? 'border-success-border bg-success-bg text-success'
: ($entry['source'] === 'environment' ? 'border-info-border bg-info-bg text-info'
: 'border-warning-border bg-warning-bg text-warning') }}">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ __('secrets.source_'.$entry['source']) }}
</span>
<div class="flex flex-wrap items-center gap-2">
{{-- Which of the two slots THIS card reads and writes right now
fix round, Important: save()/render() resolve without a mode
argument, so they always act on OperatingMode::current()'s
slot, but nothing on the card said so. The badge in the
header above (Integrations::mode_title) is not enough: it is
not visible at the card an operator is about to type into,
and repeating "Testbetrieb"/"Livebetrieb" there would only
restate the MODE, not name the PLATZ a value lands in. Same
colours as that header badge, for one visual language. --}}
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
{{ $mode->isTest() ? 'border-warning-border bg-warning-bg text-warning' : 'border-success-border bg-success-bg text-success' }}">
{{ __('secrets.slot.'.$mode->value) }}
</span>
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
{{ $entry['source'] === 'stored' ? 'border-success-border bg-success-bg text-success'
: ($entry['source'] === 'environment' ? 'border-info-border bg-info-bg text-info'
: 'border-warning-border bg-warning-bg text-warning') }}">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ __('secrets.source_'.$entry['source']) }}
</span>
</div>
</div>
@if (! $unlocked)

View File

@ -103,7 +103,7 @@
<h2 class="font-semibold text-ink">{{ __('integrations.payments_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('integrations.payments_body') }}</p>
</div>
<x-admin.secret-field :entry="$entries['stripe.secret']" :unlocked="$unlocked" />
<x-admin.secret-field :entry="$entries['stripe.secret']" :unlocked="$unlocked" :mode="$mode" />
@if ($unlocked)
<x-ui.alert variant="info">{{ __('secrets.webhook_secret_note') }}</x-ui.alert>
@endif
@ -127,7 +127,7 @@
@if ($canSecrets)
@if ($canInfra)<hr class="border-line" />@endif
<x-admin.secret-field :entry="$entries['dns.token']" :unlocked="$unlocked" />
<x-admin.secret-field :entry="$entries['dns.token']" :unlocked="$unlocked" :mode="$mode" />
@endif
</div>
@endif
@ -153,7 +153,7 @@
@if ($canSecrets)
@if ($canInfra)<hr class="border-line" />@endif
<x-admin.secret-field :entry="$entries['inbound_mail.password']" :unlocked="$unlocked" />
<x-admin.secret-field :entry="$entries['inbound_mail.password']" :unlocked="$unlocked" :mode="$mode" />
@endif
</div>
@endif
@ -172,7 +172,7 @@
@if ($canSecrets)
@if ($canInfra)<hr class="border-line" />@endif
<x-admin.secret-field :entry="$entries['monitoring.token']" :unlocked="$unlocked" />
<x-admin.secret-field :entry="$entries['monitoring.token']" :unlocked="$unlocked" :mode="$mode" />
@endif
</div>
@endif
@ -218,7 +218,7 @@
@if ($canSecrets)
@if ($canInfra)<hr class="border-line" />@endif
<x-admin.secret-field :entry="$entries['ssh.private_key']" :unlocked="$unlocked" />
<x-admin.secret-field :entry="$entries['ssh.private_key']" :unlocked="$unlocked" :mode="$mode" />
@endif
</div>
@endif

View File

@ -1,5 +1,6 @@
<?php
use App\Livewire\Admin\ConfirmSwitchMode;
use App\Livewire\Admin\Integrations;
use App\Models\Operator;
use App\Support\OperatingMode;
@ -63,3 +64,57 @@ it('refuses an unknown mode', function () {
expect(OperatingMode::current())->toBe(OperatingMode::Live);
});
// ---- ConfirmSwitchMode selbst — nach dem Vorbild der ConfirmSaveSecret/
// ConfirmForgetSecret-Tests in tests/Feature/Admin/IntegrationsPageTest.php.
// Ein Modal ist OHNE die Route-Middleware der Seite erreichbar (R23-
// Kopfkommentar in CLAUDE.md) — "gelesen und für korrekt befunden" ist keine
// Prüfung, ein eigener Test schon.
it('does not open the switch confirmation without the capability', function () {
$admin = Operator::factory()->role('Admin')->create(); // hosts.manage, nicht secrets.manage
Livewire::actingAs($admin, 'operator')
->test(ConfirmSwitchMode::class, ['mode' => 'test'])
->assertForbidden();
});
it('confirms a switch through the modal without changing the mode itself', function () {
$owner = Operator::factory()->role('Owner')->create();
Livewire::actingAs($owner, 'operator')
->test(ConfirmSwitchMode::class, ['mode' => 'test'])
->call('confirm')
->assertDispatched('mode-switch-confirmed', mode: 'test');
// Das Modal selbst ruft OperatingMode::set() nirgends auf — nur
// Integrations::switchMode() darf das, hinter guardSecrets(). Bestätigen
// im Modal allein darf den Modus nicht bewegen.
expect(OperatingMode::current())->toBe(OperatingMode::Live);
});
// ---- Fix-Runde, Befund 1 (Important): keine Karte sagte, in welchen Platz
// sie schreibt. save()/render() lösen ohne Modus-Argument gegen
// OperatingMode::current() auf — funktional kein Problem, aber die Karte sah
// in Test und Live identisch aus. Reine Darstellung (secret-field.blade.php
// liest nur $mode, das Integrations::render() schon berechnet — kein neuer
// Aufruf von source()/outline()/updatedAt()), deshalb genügt ein assertSee je
// Modus statt eines erfundenen Verhaltens-Tests.
it('names the slot a saved value would land in, on the card itself', function () {
$owner = Operator::factory()->role('Owner')->create();
Livewire::actingAs($owner, 'operator')
->test(Integrations::class)
->set('confirmablePassword', 'passwort-fuer-tests')
->call('confirmPassword')
->assertSee(__('secrets.slot.live'))
->assertDontSee(__('secrets.slot.test'));
OperatingMode::set(OperatingMode::Test);
Livewire::actingAs($owner, 'operator')
->test(Integrations::class)
->set('confirmablePassword', 'passwort-fuer-tests')
->call('confirmPassword')
->assertSee(__('secrets.slot.test'))
->assertDontSee(__('secrets.slot.live'));
});