Compare commits
10 Commits
494a3a817e
...
2d915be003
| Author | SHA1 | Date |
|---|---|---|
|
|
2d915be003 | |
|
|
786318b6d4 | |
|
|
26cf1a72a0 | |
|
|
823eeaf413 | |
|
|
5094f70c19 | |
|
|
507c976035 | |
|
|
49d528dbea | |
|
|
e83b17ec37 | |
|
|
fe1110de71 | |
|
|
96f171b3b3 |
|
|
@ -11,6 +11,8 @@ use App\Services\Billing\AddonPrices;
|
|||
use App\Services\Billing\PlanPrices;
|
||||
use App\Services\Billing\TaxTreatment;
|
||||
use App\Services\Stripe\StripeClient;
|
||||
use App\Support\OperatingMode;
|
||||
use App\Support\StripeCatalogueMode;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
|
|
@ -59,6 +61,14 @@ use Illuminate\Console\Command;
|
|||
* Product for it would be a price list entry for something that may never
|
||||
* exist.
|
||||
*
|
||||
* **A run belongs to ONE Stripe account, and it says which.** Products and
|
||||
* Prices are account-bound, and the operating mode picks the key — so the mode
|
||||
* a run worked in is recorded (App\Support\StripeCatalogueMode) and a run into
|
||||
* a catalogue that belongs to the other account is refused rather than allowed
|
||||
* to skip its way to "already in step". Without that, switching to live left
|
||||
* every stored id pointing at test objects while the readiness page went on
|
||||
* reporting a synced catalogue, and the first real order met "No such price".
|
||||
*
|
||||
* **Modules were missing from this entirely**, which is why a booked module was
|
||||
* charged in the month it was booked and never again: nothing in Stripe existed
|
||||
* to put on the subscription. They are pushed at today's catalogue price on both
|
||||
|
|
@ -83,6 +93,34 @@ class SyncStripeCatalogue extends Command
|
|||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (StripeCatalogueMode::hasStoredObjects() && StripeCatalogueMode::belongsToAnotherMode()) {
|
||||
$this->error($this->staleCatalogueMessage());
|
||||
|
||||
// Auch der Trockenlauf endet hier, und mit FAILURE: er zählt, was
|
||||
// FEHLT, und in diesem Zustand fehlt nichts — jede Zeile trägt
|
||||
// schon eine ID, nur eine aus dem falschen Konto. „0 object(s)
|
||||
// would be created" wäre die beruhigende Antwort auf die
|
||||
// gefährliche Lage.
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// VOR der Anlegeschleife, nicht danach. Hier ist bereits bewiesen, dass
|
||||
// entweder nichts liegt oder das Vorhandene dem aktiven Konto gehört —
|
||||
// der Zeitpunkt trägt die Aussage also genauso gut. Am Ende von
|
||||
// handle() trug er sie NICHT: createProduct() und ensure() werfen
|
||||
// ungefangen, und ein Lauf, der nach dem ersten angelegten Objekt
|
||||
// stirbt, hinterließ IDs ohne Herkunft. Der nächste Lauf hielt das für
|
||||
// ein fremdes Konto und trug dem Betreiber auf, einen Katalog zu leeren,
|
||||
// an dem laufende Verträge abgerechnet werden — statt schlicht
|
||||
// wiederaufzusetzen, wofür die Idempotenzschlüssel unten gebaut sind.
|
||||
if (! $dryRun) {
|
||||
if (StripeCatalogueMode::recorded() === null && StripeCatalogueMode::hasStoredObjects()) {
|
||||
$this->line(' note taking up a catalogue whose account was never recorded; nothing is replaced.');
|
||||
}
|
||||
|
||||
StripeCatalogueMode::record();
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
|
||||
foreach (PlanFamily::query()->with('versions.prices')->orderBy('tier')->get() as $family) {
|
||||
|
|
@ -137,6 +175,40 @@ class SyncStripeCatalogue extends Command
|
|||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a run is refused outright rather than left to skip its way through.
|
||||
*
|
||||
* A Stripe Product and a Stripe Price belong to the ACCOUNT that issued
|
||||
* them, and a test account and a live account are two accounts. This
|
||||
* command skips every row that already carries an id (PlanPrices::inStep()
|
||||
* asks the register, not Stripe), so a run after the switch would report
|
||||
* "already in step" and change nothing — while recording that the
|
||||
* catalogue now belongs to the account it never touched.
|
||||
*
|
||||
* The registers have to go too, not only the two columns: inStep() takes
|
||||
* a registered row as proof on its own for the reverse-charge half, so a
|
||||
* cleared pointer with the register left behind would leave exactly that
|
||||
* half pointing at the old account, and nothing would say so.
|
||||
*
|
||||
* Only reached where the OTHER account is established fact. A catalogue
|
||||
* whose account was never recorded is a different state with a different
|
||||
* cure — see StripeCatalogueMode::belongsToAnotherMode(). Telling that
|
||||
* operator to clear anything would be telling them to delete a catalogue
|
||||
* their live contracts bill on.
|
||||
*/
|
||||
private function staleCatalogueMessage(): string
|
||||
{
|
||||
return sprintf(
|
||||
'The stored catalogue was created in the [%s] account; this installation is now in [%s] mode. '
|
||||
.'Stripe objects belong to the account that issued them, and this command skips every row that '
|
||||
.'already carries an id — so it cannot repair this by running again. Clear '
|
||||
.'plan_families.stripe_product_id, plan_prices.stripe_price_id and the stripe_plan_prices / '
|
||||
.'stripe_addon_prices registers first, then run it. Nothing was created.',
|
||||
StripeCatalogueMode::recorded()?->value,
|
||||
OperatingMode::current()->value,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Stripe Prices one priced catalogue row is sold on, brought into step.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Support\OperatingMode;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* Confirmation before the operating mode changes (R23).
|
||||
*
|
||||
* Wortgleiches Muster zu ConfirmSaveSecret: dieses Modal mutiert nichts selbst
|
||||
* — es ruft `OperatingMode::set()` nirgends auf. Bestätigen löst nur ein
|
||||
* Ereignis aus, das Integrations::switchMode() auffängt; deren eigene Sperre
|
||||
* (secrets.manage + kürzlich bestätigtes Passwort) bleibt die einzige Stelle,
|
||||
* die tatsächlich etwas schreibt. Ein Modus, der hier gefälscht hereinkäme,
|
||||
* muss trotzdem switchMode()s eigenes tryFrom() und guardSecrets() passieren.
|
||||
*/
|
||||
class ConfirmSwitchMode extends ModalComponent
|
||||
{
|
||||
public string $mode;
|
||||
|
||||
public function mount(string $mode): void
|
||||
{
|
||||
$this->authorize('secrets.manage');
|
||||
|
||||
// Ein Wert aus dem Aufrufort dieses Modals — dieselbe Prüfung, die
|
||||
// switchMode() gleich noch einmal macht: ein unbekannter Modus 404t
|
||||
// hier, statt eine Bestätigung für etwas zu zeigen, das nachher
|
||||
// ohnehin still nichts tut.
|
||||
abort_if(OperatingMode::tryFrom($mode) === null, 404);
|
||||
|
||||
$this->mode = $mode;
|
||||
}
|
||||
|
||||
public function confirm(): void
|
||||
{
|
||||
$this->authorize('secrets.manage');
|
||||
$this->dispatch('mode-switch-confirmed', mode: $this->mode);
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.confirm-switch-mode');
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ use App\Services\Deployment\UpdateChannel;
|
|||
use App\Services\Env\EnvFileEditor;
|
||||
use App\Services\Env\InvalidEnvContentException;
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use App\Support\OperatingMode;
|
||||
use App\Support\ProvisioningSettings;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
|
@ -188,6 +189,34 @@ class Integrations extends Component
|
|||
}
|
||||
}
|
||||
|
||||
// ---- The operating mode — governs every vault entry below it. ----
|
||||
|
||||
/**
|
||||
* Den Betriebsmodus umlegen.
|
||||
*
|
||||
* Hinter derselben Sperre wie der Tresor — `secrets.manage` plus bestätigtes
|
||||
* Passwort — weil der Wechsel auf Live der Moment ist, ab dem echtes Geld
|
||||
* fließt. Das ist keine Umschaltfläche zum Danebenklicken.
|
||||
*/
|
||||
#[On('mode-switch-confirmed')]
|
||||
public function switchMode(string $mode): void
|
||||
{
|
||||
$this->guardSecrets();
|
||||
|
||||
// Ein String aus dem Browser. tryFrom, nicht from.
|
||||
$target = OperatingMode::tryFrom($mode);
|
||||
|
||||
if ($target === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
OperatingMode::set($target);
|
||||
|
||||
$this->dispatch('notify', message: __('integrations.mode_switched', [
|
||||
'mode' => __('readiness.mode.'.$target->value),
|
||||
]));
|
||||
}
|
||||
|
||||
// ---- Plain settings — App\Support\Settings, hosts.manage, no password. ----
|
||||
|
||||
public function saveInfra(): void
|
||||
|
|
@ -462,6 +491,7 @@ class Integrations extends Component
|
|||
'canInfra' => $canInfra,
|
||||
'unlocked' => $unlocked,
|
||||
'usable' => $vault->isUsable(),
|
||||
'mode' => OperatingMode::current(),
|
||||
'restart' => $restart,
|
||||
'entries' => collect(SecretVault::REGISTRY)
|
||||
->map(fn (array $meta, string $key) => [
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@ namespace App\Livewire\Admin;
|
|||
use App\Livewire\Concerns\BuildsRunSteps;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Host;
|
||||
use App\Services\Provisioning\HostCapacity;
|
||||
use App\Models\Instance;
|
||||
use App\Models\MonitoringTarget;
|
||||
use App\Models\ProvisioningRun;
|
||||
use App\Models\Subscription;
|
||||
use App\Services\Billing\PlanCatalogue;
|
||||
use App\Services\Provisioning\HostCapacity;
|
||||
use App\Support\Readiness;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Number;
|
||||
use Livewire\Attributes\Layout;
|
||||
|
|
@ -232,7 +234,7 @@ class Overview extends Component
|
|||
$capacity = app(HostCapacity::class);
|
||||
|
||||
try {
|
||||
$sellable = app(\App\Services\Billing\PlanCatalogue::class)->sellable();
|
||||
$sellable = app(PlanCatalogue::class)->sellable();
|
||||
} catch (\Throwable) {
|
||||
// A catalogue that cannot be read has its own alarm. It does not
|
||||
// get to take the console's front page down with it.
|
||||
|
|
@ -367,6 +369,19 @@ class Overview extends Component
|
|||
])];
|
||||
}
|
||||
|
||||
// What is not yet set up at all, as opposed to everything above (what
|
||||
// IS set up but currently unhealthy). Readiness::blocking() is the
|
||||
// same list App\Livewire\Admin\Readiness renders in full — this is
|
||||
// only the count and a link to it, not a second copy of the reasons.
|
||||
$blockingReadiness = count(Readiness::blocking());
|
||||
if ($blockingReadiness > 0) {
|
||||
$notices[] = [
|
||||
'level' => 'warning',
|
||||
'text' => trans_choice('admin.notice.readiness_blocking', $blockingReadiness, ['n' => $blockingReadiness]),
|
||||
'route' => 'admin.readiness',
|
||||
];
|
||||
}
|
||||
|
||||
return $notices;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,181 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Services\Dns\DnsTokenCheck;
|
||||
use App\Services\Proxmox\VmTemplateCheck;
|
||||
use App\Services\Stripe\StripeCheck;
|
||||
use App\Services\Vpn\WireguardEndpointCheck;
|
||||
use App\Support\OperatingMode;
|
||||
use App\Support\Readiness as ReadinessRegistry;
|
||||
use App\Support\Readiness\Check;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* The one page an operator setting up a live server goes through top to
|
||||
* bottom, until nothing blocking is left — App\Support\Readiness rendered,
|
||||
* with a button on the handful of checks that have to ask a real service
|
||||
* instead of just reading a field.
|
||||
*
|
||||
* Aliased on import (`ReadinessRegistry`) rather than `use App\Support\
|
||||
* Readiness;` bare: this class is ALSO named Readiness, in the same PHP
|
||||
* process, and an unaliased reference here would resolve to itself — which
|
||||
* has no byGroup()/blocking()/isReady() — rather than to the registry.
|
||||
*
|
||||
* mount() checks the same gate as Admin\Integrations
|
||||
* (Gate::any(['hosts.manage', 'secrets.manage'])), because this page is where
|
||||
* both halves it configures — App\Support\Settings and SecretVault — come
|
||||
* back together as one list of what is missing.
|
||||
*/
|
||||
#[Layout('layouts.admin')]
|
||||
class Readiness extends Component
|
||||
{
|
||||
/**
|
||||
* Prüfschlüssel → Klasse. Die Liste IST die Erlaubnis.
|
||||
*
|
||||
* runCheck() receives its argument from wire:click in the browser — a
|
||||
* string a stranger could type into devtools just as easily as an
|
||||
* operator clicking the real button. Without this fixed map, that
|
||||
* argument would be a way to instantiate and run ANY class through app().
|
||||
* Four entries: the three on-demand checks Task 9 built (each asks a real
|
||||
* service instead of only reading a field) plus StripeCheck, which
|
||||
* already had this exact run() shape from Admin\Integrations' own
|
||||
* "Testen" button.
|
||||
*/
|
||||
private const RUNNABLE = [
|
||||
'provisioning.dns_token' => DnsTokenCheck::class,
|
||||
'onboarding.wg_hub' => WireguardEndpointCheck::class,
|
||||
'provisioning.vm_template' => VmTemplateCheck::class,
|
||||
'billing.stripe_secret' => StripeCheck::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* Check::key → the App\Support\Settings row it is written under.
|
||||
*
|
||||
* Display only, and carries no threshold of its own: whether a heartbeat
|
||||
* counts as fresh is decided exactly once, by
|
||||
* App\Support\Readiness\OperationChecks::isFresh(), and shows up here
|
||||
* only as the check's own `satisfied`/severity — this map exists solely
|
||||
* so heartbeatDisplay() knows which stored value to format for which
|
||||
* check. (A previous version of this comment claimed a staleness
|
||||
* threshold was kept in sync with OperationChecks::STALE_AFTER_MINUTES —
|
||||
* there is no such value here to keep in sync. CLAUDE.md's R19 names this
|
||||
* exact shape of mistake — a call that reads as an assurance and is not
|
||||
* one — as worse than no comment at all, because it stops the next
|
||||
* reader from checking for themselves.)
|
||||
*/
|
||||
private const HEARTBEAT_KEYS = [
|
||||
'operation.scheduler' => 'heartbeat.scheduler',
|
||||
'operation.queue_provisioning' => 'heartbeat.queue_provisioning',
|
||||
];
|
||||
|
||||
/**
|
||||
* The on-demand checks' own answers, keyed by Check::key.
|
||||
*
|
||||
* Never pre-filled in mount(): DnsTokenCheck writes a real TXT record to
|
||||
* the production DNS zone the moment it runs, so nothing here may run
|
||||
* except in direct response to an operator's own click.
|
||||
*
|
||||
* @var array<string, array<string, mixed>>
|
||||
*/
|
||||
public array $results = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
abort_unless(Gate::any(['hosts.manage', 'secrets.manage']), 403);
|
||||
}
|
||||
|
||||
public function runCheck(string $key): void
|
||||
{
|
||||
$this->authorize('secrets.manage');
|
||||
|
||||
// Der Schlüssel kommt aus dem Browser. Ohne diese Zeile wäre das ein
|
||||
// Weg, beliebige Klassen aufzurufen.
|
||||
$class = self::RUNNABLE[$key] ?? null;
|
||||
|
||||
if ($class === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->results[$key] = app($class)->run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an operator goes to actually fix ONE entry.
|
||||
*
|
||||
* Not always Admin\Integrations: six of the nine `tab` values used across
|
||||
* the five check groups name a DIFFERENT admin page entirely — 'hosts',
|
||||
* 'plans', 'datacenters', 'mail', 'templates' and 'company' are pages of
|
||||
* their own, not Integrations tabs, and forwarding them as `?tab=` would
|
||||
* either 404 or (since Integrations::mount() falls back silently to its
|
||||
* own first tab for an unrecognised value) land quietly on the wrong
|
||||
* section. Every OTHER `tab` value that exists in the five check group
|
||||
* files today (checked at the source, Fix-Runde) really IS one of
|
||||
* Integrations::TABS — nine of them were not, until that same Fix-Runde
|
||||
* corrected them there rather than papering over it here (see
|
||||
* BillingChecks/OnboardingChecks/ProvisioningChecks/DeliveryChecks). The
|
||||
* `default` arm below is therefore a pure safety net for a value nobody
|
||||
* currently produces, not a route any check relies on — kept so a future
|
||||
* typo degrades to the Integrations landing tab instead of an
|
||||
* UnhandledMatchError taking the whole page down.
|
||||
*/
|
||||
public function checkUrl(Check $check): string
|
||||
{
|
||||
if (in_array($check->tab, Integrations::TABS, true)) {
|
||||
return route('admin.integrations', ['tab' => $check->tab]);
|
||||
}
|
||||
|
||||
return match ($check->tab) {
|
||||
'hosts' => route('admin.hosts'),
|
||||
'plans' => route('admin.plans'),
|
||||
'datacenters' => route('admin.datacenters'),
|
||||
'mail' => route('admin.mail'),
|
||||
'templates' => route('admin.templates'),
|
||||
// Company details AND the invoice series both live on
|
||||
// Admin\Finance — there is no dedicated "company" page.
|
||||
'company' => route('admin.finance'),
|
||||
default => route('admin.integrations'),
|
||||
};
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.readiness', [
|
||||
'groups' => ReadinessRegistry::byGroup(),
|
||||
'blockingCount' => count(ReadinessRegistry::blocking()),
|
||||
'ready' => ReadinessRegistry::isReady(),
|
||||
'mode' => OperatingMode::current(),
|
||||
'runnable' => array_keys(self::RUNNABLE),
|
||||
'heartbeats' => collect(self::HEARTBEAT_KEYS)
|
||||
->map(fn (string $settingsKey) => $this->heartbeatDisplay($settingsKey))
|
||||
->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The heartbeat's own stored moment, in the operator's zone (R19) — or
|
||||
* null for "nothing stored" AND for "unreadable", the same forgiving
|
||||
* either-way OperationChecks::isFresh() already uses. A readiness page
|
||||
* whose only job is to calmly report what is missing must not itself go
|
||||
* down over a garbled timestamp (see that method's own docblock for the
|
||||
* incident this already caused once, inside Readiness::all() itself).
|
||||
*/
|
||||
private function heartbeatDisplay(string $settingsKey): ?string
|
||||
{
|
||||
$raw = Settings::get($settingsKey);
|
||||
|
||||
if (blank($raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse($raw)->local()->isoFormat('DD.MM.YYYY HH:mm:ss');
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -131,16 +131,7 @@ final class SecretVault
|
|||
{
|
||||
$this->assertKnown($key);
|
||||
|
||||
$mode = OperatingMode::current();
|
||||
$row = $this->row($key, $mode);
|
||||
|
||||
// Fall back to the live slot when the active mode's slot is empty.
|
||||
// ONLY from test to live, never the other way: using a test credential
|
||||
// in live operation because the real one is missing is the dangerous
|
||||
// direction, and must stay an explicit, visible gap instead.
|
||||
if ($row === null && $mode->isTest() && ! $this->isStrict($key)) {
|
||||
$row = $this->row($key, OperatingMode::Live);
|
||||
}
|
||||
$row = $this->inForce($key, OperatingMode::current());
|
||||
|
||||
if ($row === null) {
|
||||
// Strict entries never see the environment: see isStrict() below.
|
||||
|
|
@ -162,13 +153,34 @@ final class SecretVault
|
|||
}
|
||||
}
|
||||
|
||||
/** Where the value in force comes from: stored, environment, or nowhere. */
|
||||
/**
|
||||
* Where the value in force under this mode comes from — four answers now.
|
||||
*
|
||||
* 'stored_live' is the third, and it exists because the second-best answers
|
||||
* were both lies. The measured case: mode test, dns.token only in the :live
|
||||
* slot, HETZNER_DNS_TOKEN also in the .env. get() hands out the stored live
|
||||
* value (the fallback it has always had), while this method reported
|
||||
* 'environment' — the console then read "Aus der Serverdatei" over a
|
||||
* credential that is in force from the database, and an operator changing
|
||||
* the .env line would have seen no effect at all. That is R19's dummy in
|
||||
* miniature: a display that reads like an answer, is wrong, and stops the
|
||||
* next person looking.
|
||||
*
|
||||
* Returning 'stored' instead would trade one lie for another: the console
|
||||
* shows the "Vergessen" button only for 'stored', and it would then have
|
||||
* pointed at the EMPTY test slot, which forget() would happily delete
|
||||
* nothing from.
|
||||
*
|
||||
* The $mode argument names which mode is IN FORCE, not which row to look
|
||||
* at — same question get() answers, so that the two cannot disagree.
|
||||
*/
|
||||
public function source(string $key, ?OperatingMode $mode = null): string
|
||||
{
|
||||
$this->assertKnown($key);
|
||||
|
||||
return match (true) {
|
||||
$this->row($key, $mode) !== null => 'stored',
|
||||
$this->fallback($key, $mode) !== null => 'stored_live',
|
||||
$this->isStrict($key) => 'none',
|
||||
filled(config(self::REGISTRY[$key]['config'])) => 'environment',
|
||||
default => 'none',
|
||||
|
|
@ -177,12 +189,12 @@ final class SecretVault
|
|||
|
||||
public function outline(string $key, ?OperatingMode $mode = null): ?string
|
||||
{
|
||||
return $this->row($key, $mode)?->outline;
|
||||
return $this->inForce($key, $mode)?->outline;
|
||||
}
|
||||
|
||||
public function updatedAt(string $key, ?OperatingMode $mode = null): ?string
|
||||
{
|
||||
return $this->row($key, $mode)?->updated_at;
|
||||
return $this->inForce($key, $mode)?->updated_at;
|
||||
}
|
||||
|
||||
public function put(string $key, string $value, Operator $by, ?OperatingMode $mode = null): void
|
||||
|
|
@ -234,6 +246,39 @@ final class SecretVault
|
|||
return DB::table('app_secrets')->where('key', $this->rowKey($key, $mode))->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored row that actually applies under this mode: its own slot, or
|
||||
* the live slot standing in for it.
|
||||
*
|
||||
* ONE place, called by get(), outline() and updatedAt() — and asked by
|
||||
* source() through fallback() — because these four answering the same
|
||||
* question separately is precisely how the console came to say "nothing
|
||||
* stored" about a credential the provisioning run was using.
|
||||
*/
|
||||
private function inForce(string $key, ?OperatingMode $mode = null): ?object
|
||||
{
|
||||
return $this->row($key, $mode) ?? $this->fallback($key, $mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* The live slot filling in for an empty test slot.
|
||||
*
|
||||
* ONLY from test to live, never the other way: using a test credential in
|
||||
* live operation because the real one is missing is the dangerous
|
||||
* direction, and must stay an explicit, visible gap instead. Strict entries
|
||||
* (Stripe) have no fallback at all, in either direction.
|
||||
*/
|
||||
private function fallback(string $key, ?OperatingMode $mode = null): ?object
|
||||
{
|
||||
$mode ??= OperatingMode::current();
|
||||
|
||||
if (! $mode->isTest() || $this->isStrict($key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->row($key, OperatingMode::Live);
|
||||
}
|
||||
|
||||
/** Does this entry carry the strict trait? Populated in task 3. */
|
||||
private function isStrict(string $key): bool
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Services\Stripe;
|
||||
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use App\Support\OperatingMode;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Throwable;
|
||||
|
||||
|
|
@ -50,7 +51,11 @@ final class StripeCheck
|
|||
'business' => $account->json('settings.dashboard.display_name') ?: $account->json('business_profile.name'),
|
||||
// Stripe does not label the key; the mode is inferred from the
|
||||
// prefix, which is the only thing that is true of both key kinds.
|
||||
'live' => str_starts_with($secret, 'sk_live_') || str_starts_with($secret, 'rk_live_'),
|
||||
// Asked of OperatingMode, not spelled out again here: the same
|
||||
// question decides which slot the migration files a key into and
|
||||
// whether the readiness page reports a key sitting in the wrong
|
||||
// one. Unrecognisable (null) stays false, exactly as before.
|
||||
'live' => OperatingMode::ofStripeKey($secret) === OperatingMode::Live,
|
||||
'restricted' => str_starts_with($secret, 'rk_'),
|
||||
'webhooks' => $this->webhooks($secret),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -86,6 +86,10 @@ final class Navigation
|
|||
// never "all of these" (contrast a single string elsewhere in
|
||||
// this file, which x-shell.nav checks with plain ->can()).
|
||||
['admin.integrations', 'plug', 'integrations', ['hosts.manage', 'secrets.manage']],
|
||||
// Every check App\Support\Readiness knows about, gathered on
|
||||
// one page — same gate as Integrations just above, because
|
||||
// both halves it configures come back together here.
|
||||
['admin.readiness', 'shield-check', 'readiness', ['hosts.manage', 'secrets.manage']],
|
||||
// Two-factor enrolment used to sit here as an entry of its
|
||||
// own. It is a tab of Settings now — it is a setting, and it
|
||||
// was the one place an operator had to leave the settings page
|
||||
|
|
|
|||
|
|
@ -34,6 +34,37 @@ enum OperatingMode: string
|
|||
Settings::set(self::SETTING, $mode->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zu welchem Modus ein Stripe-Schlüssel gehört — am Präfix, ohne Netzverkehr.
|
||||
*
|
||||
* Stripe beschriftet den Schlüssel nicht; das Präfix ist das Einzige, was
|
||||
* bei beiden Schlüsselarten (`sk_`, und der eingeschränkte `rk_`) wahr ist.
|
||||
* Das reicht, und es muss reichen: die Bereitschaftsseite darf beim
|
||||
* Seitenaufruf nichts erreichen, und die Migration läuft, bevor irgendein
|
||||
* Dienst antwortet.
|
||||
*
|
||||
* `null` heißt „nicht entscheidbar", nicht „live". Wer daraus eine
|
||||
* Vorgabe braucht, sagt sie an seiner Stelle selbst — die Migration setzt
|
||||
* `?? Live` (der Zustand vor ihr, also Fortsetzung statt neuer Behauptung),
|
||||
* die Bereitschaftsprüfung meldet dagegen NUR einen Widerspruch, den sie
|
||||
* beweisen kann.
|
||||
*
|
||||
* Diese eine Stelle, weil dieselbe Frage vorher an drei Orten unabhängig
|
||||
* beantwortet wurde: in der Migration, in StripeCheck und — gar nicht — auf
|
||||
* der Bereitschaftsseite. Drei Formulierungen einer Frage sind der Weg, auf
|
||||
* dem sie auseinanderlaufen.
|
||||
*/
|
||||
public static function ofStripeKey(?string $secret): ?self
|
||||
{
|
||||
$secret = trim((string) $secret);
|
||||
|
||||
return match (true) {
|
||||
str_starts_with($secret, 'sk_test_'), str_starts_with($secret, 'rk_test_') => self::Test,
|
||||
str_starts_with($secret, 'sk_live_'), str_starts_with($secret, 'rk_live_') => self::Live,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function isTest(): bool
|
||||
{
|
||||
return $this === self::Test;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use App\Models\PlanPrice;
|
|||
use App\Services\Secrets\SecretVault;
|
||||
use App\Support\CompanyProfile;
|
||||
use App\Support\OperatingMode;
|
||||
use App\Support\StripeCatalogueMode;
|
||||
use App\Support\StripeWebhookSecret;
|
||||
|
||||
/**
|
||||
|
|
@ -22,18 +23,63 @@ final class BillingChecks
|
|||
{
|
||||
$mode = OperatingMode::current();
|
||||
|
||||
// get() löst den aktiven Modus selbst auf UND ist für Stripe strikt —
|
||||
// ein Live-Schlüssel im LIVE-Platz zählt im Testbetrieb nicht als
|
||||
// Nachweis, sonst widerspräche diese Seite der Kassensperre.
|
||||
$secret = app(SecretVault::class)->get('stripe.secret');
|
||||
|
||||
// Was im Platz des aktiven Modus liegt, kann trotzdem der Schlüssel des
|
||||
// anderen Kontos sein — eingetippt, nicht zurückgefallen. Am Präfix
|
||||
// ohne Netzverkehr entscheidbar; `null` heißt „nicht entscheidbar" und
|
||||
// wird hier bewusst NICHT als Widerspruch gemeldet: diese Prüfung sagt
|
||||
// nur, was sie beweisen kann.
|
||||
$keyMode = OperatingMode::ofStripeKey($secret);
|
||||
$wrongSlot = $keyMode !== null && $keyMode !== $mode;
|
||||
|
||||
// stripe:sync-catalogue (SyncStripeCatalogue::handle()) only ever
|
||||
// touches PUBLISHED versions — "a draft has promised nothing, and a
|
||||
// Product for it would be a price list entry for something that may
|
||||
// never exist" (its own header comment). A draft's own unsynced price
|
||||
// is therefore not a gap; counting it anyway turns this check into an
|
||||
// alarm that never clears on any installation that has ever drafted a
|
||||
// next version, which stops it being read at all.
|
||||
$published = PlanPrice::query()
|
||||
->whereHas('version', fn ($query) => $query->whereNotNull('published_at'));
|
||||
|
||||
$unsyncedPrices = (clone $published)->whereNull('stripe_price_id')->exists();
|
||||
|
||||
// Eine gefüllte Spalte beweist nur, dass IRGENDEIN Konto diese ID
|
||||
// ausgestellt hat. Welches, steht seit dem Abgleich in einer eigenen
|
||||
// Zeile (StripeCatalogueMode) — ohne sie meldete diese Prüfung nach
|
||||
// dem Umschalten auf Live weiter „erfüllt", und die erste echte
|
||||
// Bestellung bekam von Stripe *No such price*. Ohne Netzverkehr
|
||||
// entschieden: die Bereitschaftsseite erreicht beim Aufruf nichts.
|
||||
//
|
||||
// ZWEI Fälle, nicht einer. „Steht fest: anderes Konto" verlangt das
|
||||
// Leeren der Zeiger und Register; „Herkunft nie festgehalten" verlangt
|
||||
// nur einen neuen Lauf. Der zweite entsteht ohne jeden Kontowechsel —
|
||||
// ein abgebrochener Abgleich, oder eine Bestellung, die sich ihren
|
||||
// fehlenden Preis selbst geholt hat — und die Leeranweisung wäre dort
|
||||
// die Aufforderung, einen Katalog zu löschen, an dem laufende Verträge
|
||||
// abgerechnet werden.
|
||||
$somethingSynced = (clone $published)->whereNotNull('stripe_price_id')->exists();
|
||||
$wrongAccount = $somethingSynced && StripeCatalogueMode::belongsToAnotherMode();
|
||||
$unknownOrigin = $somethingSynced && StripeCatalogueMode::recorded() === null;
|
||||
|
||||
return [
|
||||
new Check(
|
||||
key: 'billing.stripe_secret',
|
||||
group: self::GROUP,
|
||||
severity: Check::SEVERITY_BLOCKING,
|
||||
label: __('readiness.billing.stripe_secret', ['mode' => __('readiness.mode.'.$mode->value)]),
|
||||
breaks: __('readiness.billing.stripe_secret_breaks'),
|
||||
// Zwei Ausfälle, zwei Folgen — und „ohne ihn nimmt die Kasse
|
||||
// keine Bestellung an" wäre über einen Schlüssel, der DA ist,
|
||||
// schlicht falsch.
|
||||
breaks: $wrongSlot
|
||||
? __('readiness.billing.stripe_secret_'.$mode->value.'_slot_breaks')
|
||||
: __('readiness.billing.stripe_secret_breaks'),
|
||||
tab: 'services',
|
||||
// get() löst den aktiven Modus selbst auf UND ist für Stripe
|
||||
// strikt — ein Live-Schlüssel zählt im Testbetrieb nicht als
|
||||
// Nachweis, sonst widerspräche diese Seite der Kassensperre.
|
||||
satisfied: filled(app(SecretVault::class)->get('stripe.secret')),
|
||||
satisfied: filled($secret) && ! $wrongSlot,
|
||||
),
|
||||
new Check(
|
||||
key: 'billing.webhook_secret',
|
||||
|
|
@ -83,20 +129,21 @@ final class BillingChecks
|
|||
group: self::GROUP,
|
||||
severity: Check::SEVERITY_BLOCKING,
|
||||
label: __('readiness.billing.catalogue_synced'),
|
||||
breaks: __('readiness.billing.catalogue_synced_breaks'),
|
||||
// Der bewiesene Widerspruch zuerst: „noch nicht abgeglichen"
|
||||
// wäre über einen Katalog, der abgeglichen IST — nur im anderen
|
||||
// Konto — die falsche Anweisung, und es ist die teure Richtung
|
||||
// (der Betreiber ruft den Befehl, bekommt „already in step"
|
||||
// und glaubt, es sei erledigt). Danach die unbekannte Herkunft,
|
||||
// deren Kur ein Lauf ist und kein Löschen. Zuletzt die Lücke.
|
||||
breaks: match (true) {
|
||||
$wrongAccount => __('readiness.billing.catalogue_account_breaks', [
|
||||
'mode' => __('readiness.mode.'.$mode->value),
|
||||
]),
|
||||
$unknownOrigin => __('readiness.billing.catalogue_origin_breaks'),
|
||||
default => __('readiness.billing.catalogue_synced_breaks'),
|
||||
},
|
||||
tab: 'services',
|
||||
// stripe:sync-catalogue (SyncStripeCatalogue::handle()) only
|
||||
// ever touches PUBLISHED versions — "a draft has promised
|
||||
// nothing, and a Product for it would be a price list entry
|
||||
// for something that may never exist" (its own header
|
||||
// comment). A draft's own unsynced price is therefore not a
|
||||
// gap; counting it anyway turns this check into an alarm that
|
||||
// never clears on any installation that has ever drafted a
|
||||
// next version, which stops it being read at all.
|
||||
satisfied: PlanPrice::query()
|
||||
->whereHas('version', fn ($query) => $query->whereNotNull('published_at'))
|
||||
->whereNull('stripe_price_id')
|
||||
->doesntExist(),
|
||||
satisfied: ! $unsyncedPrices && ! $wrongAccount && ! $unknownOrigin,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,12 +53,18 @@ final class DeliveryChecks
|
|||
satisfied: MailTemplate::query()->exists(),
|
||||
),
|
||||
new Check(
|
||||
key: 'delivery.inbound_password',
|
||||
// Named after the vault entry it checks (inbound_mail.password),
|
||||
// not a shorthand — see the identical reasoning on
|
||||
// onboarding.ssh_private_key in OnboardingChecks.php.
|
||||
key: 'delivery.inbound_mail_password',
|
||||
group: self::GROUP,
|
||||
severity: Check::SEVERITY_WARNING,
|
||||
label: __('readiness.delivery.inbound_password'),
|
||||
breaks: __('readiness.delivery.inbound_password_breaks'),
|
||||
tab: 'integrations',
|
||||
// The inbound-mail card (host/port/user/folder + this
|
||||
// password) is on the 'services' tab — 'integrations' is not
|
||||
// a member of Integrations::TABS at all (Fix-Runde, Befund 1).
|
||||
tab: 'services',
|
||||
satisfied: filled(app(SecretVault::class)->get('inbound_mail.password')),
|
||||
),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -22,12 +22,24 @@ final class OnboardingChecks
|
|||
{
|
||||
return [
|
||||
new Check(
|
||||
key: 'onboarding.ssh_key',
|
||||
// Named after the vault entry it checks (ssh.private_key), not
|
||||
// a shorthand: ReadinessPageTest's guard walks every
|
||||
// SecretVault::REGISTRY key and demands a Check key that
|
||||
// CONTAINS it (dots turned to underscores) — 'onboarding.
|
||||
// ssh_key' does not contain 'ssh_private_key' and slipped
|
||||
// through unnoticed until that guard existed (Task 12).
|
||||
key: 'onboarding.ssh_private_key',
|
||||
group: self::GROUP,
|
||||
severity: Check::SEVERITY_BLOCKING,
|
||||
label: __('readiness.onboarding.ssh_key'),
|
||||
breaks: __('readiness.onboarding.ssh_key_breaks'),
|
||||
tab: 'integrations',
|
||||
// The SSH identity card lives on the 'platform' tab
|
||||
// (integrations.blade.php: "own machines"), beside the
|
||||
// public-key setting it pairs with — not 'integrations',
|
||||
// which is not a member of Integrations::TABS at all and
|
||||
// sent an operator clicking this exact blocking check to the
|
||||
// wrong tab (Fix-Runde, Befund 1).
|
||||
tab: 'platform',
|
||||
// EstablishSshTrust only deploys the PUBLIC half. Every step
|
||||
// after it (HostStep's own shell helper), and later every
|
||||
// Traefik write on a host already onboarded (SshTraefikWriter),
|
||||
|
|
@ -40,7 +52,10 @@ final class OnboardingChecks
|
|||
severity: Check::SEVERITY_BLOCKING,
|
||||
label: __('readiness.onboarding.secrets_key'),
|
||||
breaks: __('readiness.onboarding.secrets_key_breaks'),
|
||||
tab: 'integrations',
|
||||
// SECRETS_KEY has no field anywhere on Integrations' 'services'
|
||||
// or 'platform' tabs — the only place an operator can actually
|
||||
// set it is the raw .env editor on the 'env' tab.
|
||||
tab: 'env',
|
||||
// isUsable() rather than a bare filled() on the config value —
|
||||
// SecretCipher's own docblock warns that a second, shorter
|
||||
// rule that only checks emptiness lets a malformed-but-non-
|
||||
|
|
@ -54,7 +69,10 @@ final class OnboardingChecks
|
|||
severity: Check::SEVERITY_BLOCKING,
|
||||
label: __('readiness.onboarding.vpn_config_key'),
|
||||
breaks: __('readiness.onboarding.vpn_config_key_breaks'),
|
||||
tab: 'integrations',
|
||||
// Same reasoning as onboarding.secrets_key just above:
|
||||
// VPN_CONFIG_KEY has no console field either, only the raw
|
||||
// .env editor.
|
||||
tab: 'env',
|
||||
// Same reuse-not-reimplement reasoning as isUsable() above:
|
||||
// ConfigVault::available() is the one place that already knows
|
||||
// what counts as a usable key.
|
||||
|
|
@ -66,7 +84,12 @@ final class OnboardingChecks
|
|||
severity: Check::SEVERITY_BLOCKING,
|
||||
label: __('readiness.onboarding.wg_hub'),
|
||||
breaks: __('readiness.onboarding.wg_hub_breaks'),
|
||||
tab: 'integrations',
|
||||
// Two of the three values this check reads (wgHubPubkey,
|
||||
// wgEndpoint) are 'platform'-tab fields; only the third
|
||||
// (subnet) is .env-only and cannot be changed from the
|
||||
// console at all (see the docblock on satisfied: below). The
|
||||
// tab an operator can actually act on is 'platform'.
|
||||
tab: 'platform',
|
||||
// Two of three is no half a tunnel, it is none: ConfigureWireguard
|
||||
// writes the peer config from all three at once. Subnet is read
|
||||
// straight from config(), never through Settings/ProvisioningSettings
|
||||
|
|
|
|||
|
|
@ -26,7 +26,10 @@ final class ProvisioningChecks
|
|||
severity: Check::SEVERITY_BLOCKING,
|
||||
label: __('readiness.provisioning.dns_token'),
|
||||
breaks: __('readiness.provisioning.dns_token_breaks'),
|
||||
tab: 'integrations',
|
||||
// The DNS card (token + zone + Traefik path) is on the
|
||||
// 'services' tab — 'integrations' is not a member of
|
||||
// Integrations::TABS at all (Fix-Runde, Befund 1).
|
||||
tab: 'services',
|
||||
satisfied: filled(app(SecretVault::class)->get('dns.token')),
|
||||
),
|
||||
new Check(
|
||||
|
|
@ -35,7 +38,8 @@ final class ProvisioningChecks
|
|||
severity: Check::SEVERITY_BLOCKING,
|
||||
label: __('readiness.provisioning.dns_zone'),
|
||||
breaks: __('readiness.provisioning.dns_zone_breaks'),
|
||||
tab: 'integrations',
|
||||
// Same DNS card as provisioning.dns_token just above.
|
||||
tab: 'services',
|
||||
satisfied: filled(ProvisioningSettings::dnsZone()),
|
||||
),
|
||||
new Check(
|
||||
|
|
@ -44,7 +48,8 @@ final class ProvisioningChecks
|
|||
severity: Check::SEVERITY_WARNING,
|
||||
label: __('readiness.provisioning.traefik_path'),
|
||||
breaks: __('readiness.provisioning.traefik_path_breaks'),
|
||||
tab: 'integrations',
|
||||
// Same DNS card — traefikDynamicPath is a field on it.
|
||||
tab: 'services',
|
||||
satisfied: filled(ProvisioningSettings::traefikDynamicPath()),
|
||||
),
|
||||
new Check(
|
||||
|
|
@ -93,7 +98,8 @@ final class ProvisioningChecks
|
|||
: Check::SEVERITY_WARNING,
|
||||
label: __('readiness.provisioning.monitoring_token'),
|
||||
breaks: __('readiness.provisioning.monitoring_token_breaks'),
|
||||
tab: 'integrations',
|
||||
// The monitoring card is on the 'services' tab too.
|
||||
tab: 'services',
|
||||
satisfied: filled(app(SecretVault::class)->get('monitoring.token')),
|
||||
),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\PlanFamily;
|
||||
use App\Models\PlanPrice;
|
||||
use App\Models\StripeAddonPrice;
|
||||
use App\Models\StripePlanPrice;
|
||||
|
||||
/**
|
||||
* In welchem Stripe-KONTO die gespeicherten Objekt-IDs entstanden sind.
|
||||
*
|
||||
* Der Betriebsmodus schaltet die Zugangsdaten um. Er schaltet nicht um, was aus
|
||||
* ihnen entstanden ist: eine Stripe-Price-ID und eine Product-ID gehören dem
|
||||
* Konto, das sie ausgestellt hat, und ein Testkonto und ein Livekonto sind zwei
|
||||
* Konten. Jedes Zugangsdatum hat auf dieser Installation zwei Plätze; die
|
||||
* daraus abgeleiteten IDs stehen in einwertigen Spalten
|
||||
* (`plan_families.stripe_product_id`, `plan_prices.stripe_price_id`) und in den
|
||||
* zwei Registern (`stripe_plan_prices`, `stripe_addon_prices`).
|
||||
*
|
||||
* Ohne diese Zeile lief der geplante Ablauf dieser Installation ins Leere:
|
||||
* abgleichen im Testbetrieb, Live-Schlüssel hinterlegen, umschalten — und
|
||||
* `billing.catalogue_synced` meldete weiter „erfüllt", weil es nur prüfte, ob
|
||||
* die Spalte gefüllt ist. Die Seite sagte „Bereit für Livebetrieb", und die
|
||||
* erste echte Bestellung schickte eine Test-Preis-ID an die Live-API: *No such
|
||||
* price*, kein Auftrag. Genau die falsche Grünmeldung, gegen die diese Seite
|
||||
* gebaut ist, ausgelöst durch genau den Schalter, den dieses Vorhaben einführt.
|
||||
*
|
||||
* **Warum eine Einstellungszeile und nicht die Objekte selbst.** Stripe schreibt
|
||||
* die Kontoart nicht in die ID: `prod_…` und `price_…` sehen im Test- und im
|
||||
* Livekonto gleich aus (nur SCHLÜSSEL tragen `_test_`/`_live_` im Präfix — das
|
||||
* ist OperatingMode::ofStripeKey()). Die Herkunft ist aus einer gespeicherten ID
|
||||
* also nicht ableitbar, und nachfragen dürfte die Bereitschaftsseite nicht: sie
|
||||
* erreicht beim Seitenaufruf nichts über das Netz. Bleibt: beim Abgleich
|
||||
* festhalten, in welchem Modus die IDs entstanden sind.
|
||||
*
|
||||
* **Warum EINE Zeile für den ganzen Katalog.** Ein Lauf von
|
||||
* stripe:sync-catalogue benutzt genau einen Schlüssel, also genau ein Konto. Und
|
||||
* der Befehl weigert sich, in einen Katalog hineinzuarbeiten, der zu einem
|
||||
* anderen Konto gehört (siehe dort) — ohne diese Weigerung könnte ein Lauf
|
||||
* einen halb gemischten Zustand hinterlassen, über den eine einzelne Zeile dann
|
||||
* lügen würde.
|
||||
*/
|
||||
final class StripeCatalogueMode
|
||||
{
|
||||
public const SETTING = 'billing.catalogue_mode';
|
||||
|
||||
/** Der Modus, in dem der Katalog zuletzt angelegt wurde — null: noch nie. */
|
||||
public static function recorded(): ?OperatingMode
|
||||
{
|
||||
$stored = Settings::get(self::SETTING);
|
||||
|
||||
return $stored === null ? null : OperatingMode::tryFrom((string) $stored);
|
||||
}
|
||||
|
||||
/** Festhalten, wessen Konto die gerade angelegten IDs gehören. */
|
||||
public static function record(?OperatingMode $mode = null): void
|
||||
{
|
||||
Settings::set(self::SETTING, ($mode ?? OperatingMode::current())->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Steht fest, dass der gespeicherte Katalog einem ANDEREN Konto gehört?
|
||||
*
|
||||
* Nur dann — nicht schon, wenn die Herkunft unbekannt ist. Das sind zwei
|
||||
* Zustände mit zwei Handlungsanweisungen, und sie zusammenzuwerfen war ein
|
||||
* teurer Fehler: „Herkunft nie festgehalten" entsteht ganz ohne Kontowechsel,
|
||||
* nämlich wenn ein Abgleich mitten im Anlegen abbricht oder wenn eine
|
||||
* Bestellung sich ihren fehlenden Preis selbst holt (CheckoutController →
|
||||
* PlanPrices::ensure(), BookAddon → SyncStripeAddonItems →
|
||||
* AddonPrices::ensure() — keiner der beiden ruft record()). Die Objekte
|
||||
* stammen dann aus genau dem geltenden Konto, und wer in diesem Zustand die
|
||||
* Leeranweisung für einen Kontowechsel liest, löscht einen Katalog, an dem
|
||||
* laufende Verträge abgerechnet werden.
|
||||
*
|
||||
* Unbekannte Herkunft ist trotzdem kein Freibrief: die Bereitschaftsseite
|
||||
* meldet sie weiter blockierend, nur mit dem Satz, der dazu passt („lauf den
|
||||
* Abgleich noch einmal"). Beweisen kann sie die Herkunft nicht.
|
||||
*/
|
||||
public static function belongsToAnotherMode(): bool
|
||||
{
|
||||
$recorded = self::recorded();
|
||||
|
||||
return $recorded !== null && $recorded !== OperatingMode::current();
|
||||
}
|
||||
|
||||
/**
|
||||
* Liegt überhaupt eine Stripe-Objekt-ID in der Datenbank?
|
||||
*
|
||||
* Alle vier Orte, an denen der Abgleich etwas hinterlässt — auch die zwei
|
||||
* Register, weil PlanPrices::inStep() für den Netto-Preis eines
|
||||
* Reverse-Charge-Kunden ALLEIN das Register fragt: eine geleerte Spalte in
|
||||
* `plan_prices` ohne gelöschte Registerzeile ließe genau diese Hälfte des
|
||||
* Katalogs still auf das alte Konto zeigen.
|
||||
*/
|
||||
public static function hasStoredObjects(): bool
|
||||
{
|
||||
return PlanFamily::query()->whereNotNull('stripe_product_id')->exists()
|
||||
|| PlanPrice::query()->whereNotNull('stripe_price_id')->exists()
|
||||
|| StripePlanPrice::query()->exists()
|
||||
|| StripeAddonPrice::query()->exists();
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
use App\Services\Secrets\SecretCipher;
|
||||
use App\Support\OperatingMode;
|
||||
use App\Support\Settings;
|
||||
use App\Support\StripeCatalogueMode;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
|
@ -45,6 +46,20 @@ return new class extends Migration
|
|||
// stehen, und die ist live.
|
||||
if ($stripeMode !== null) {
|
||||
Settings::set(OperatingMode::SETTING, $stripeMode->value);
|
||||
|
||||
// Und derselbe Schluss noch einmal, eine Ebene weiter: was bei
|
||||
// Stripe an Produkten und Preisen angelegt wurde, wurde mit
|
||||
// GENAU DIESEM Schlüssel angelegt — ein anderer war auf dieser
|
||||
// Installation nie hinterlegt. Die IDs gehören also dem Konto, das
|
||||
// dieser Schlüssel öffnet, und das ist ab jetzt festgehalten
|
||||
// (StripeCatalogueMode). Ohne diesen Nachtrag stünde ein längst
|
||||
// abgeglichener Katalog als „Herkunft unbekannt" da, und die
|
||||
// Bereitschaftsseite verlangte einen neuen Abgleich, den niemand
|
||||
// braucht. Nur wenn wirklich IDs da sind: eine Zeile über einen
|
||||
// Katalog, den es nicht gibt, wäre eine Behauptung ohne Gegenstand.
|
||||
if (StripeCatalogueMode::hasStoredObjects()) {
|
||||
Settings::set(StripeCatalogueMode::SETTING, $stripeMode->value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -59,7 +74,12 @@ return new class extends Migration
|
|||
return OperatingMode::Live;
|
||||
}
|
||||
|
||||
return Str::contains($secret, '_test_') ? OperatingMode::Test : OperatingMode::Live;
|
||||
// Die Präfixregel steht in OperatingMode und wird hier gerufen, nicht
|
||||
// wiederholt: dieselbe Frage entscheidet auf der Bereitschaftsseite, ob
|
||||
// ein Schlüssel im falschen Platz liegt, und in StripeCheck, was der
|
||||
// Konsole als Kontoart gemeldet wird. `?? Live` ist wie oben der
|
||||
// Status quo für einen Wert, der sich nicht zuordnen lässt.
|
||||
return OperatingMode::ofStripeKey($secret) ?? OperatingMode::Live;
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
|
|
@ -101,5 +121,6 @@ return new class extends Migration
|
|||
}
|
||||
|
||||
Settings::forget(OperatingMode::SETTING);
|
||||
Settings::forget(StripeCatalogueMode::SETTING);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ return [
|
|||
'revenue' => 'Umsatz',
|
||||
'mail' => 'E-Mail',
|
||||
'integrations' => 'Integrationen',
|
||||
'readiness' => 'Bereitschaft',
|
||||
'settings' => 'Einstellungen',
|
||||
'two_factor_setup' => 'Zwei-Faktor-Anmeldung',
|
||||
],
|
||||
|
|
@ -93,6 +94,7 @@ return [
|
|||
'host_error' => 'Host :host meldet einen Fehler.',
|
||||
'host_silent' => 'Host :host hat sich seit über :minutes Minuten nicht gemeldet.',
|
||||
'monitoring_down' => ':n überwachte Instanz(en) nicht erreichbar.',
|
||||
'readiness_blocking' => '{1} :n Punkt auf der Bereitschaftsseite blockiert den Livebetrieb.|[2,*] :n Punkte auf der Bereitschaftsseite blockieren den Livebetrieb.',
|
||||
],
|
||||
|
||||
'customers_sub' => 'Alle Kunden und ihre Pakete.',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,20 @@ return [
|
|||
'title' => 'Integrationen',
|
||||
'subtitle' => 'Angebundene Dienste, gruppiert nach Zweck — Zugangsdaten und Einstellungen nebeneinander, nicht auf getrennten Seiten nach Speicherort.',
|
||||
|
||||
// Der Umschalter — über allen Zugangsdaten, weil er regiert, welcher
|
||||
// Platz je Eintrag gilt.
|
||||
'mode_title' => 'Betriebsmodus',
|
||||
'mode_body' => 'Entscheidet, welcher Platz je Zugangsdatum unten gilt. Ist der Testplatz leer, gilt im Testbetrieb der Live-Platz — außer beim Stripe-Schlüssel, der in keine Richtung zurückfällt.',
|
||||
'mode_switch_to_test' => 'Zu Testbetrieb wechseln',
|
||||
'mode_switch_to_live' => 'Zu Livebetrieb wechseln',
|
||||
'mode_switched' => 'Betriebsmodus umgelegt: :mode.',
|
||||
'mode_switch_title_test' => 'Wirklich zu Testbetrieb wechseln?',
|
||||
'mode_switch_body_test' => 'Ab sofort gilt für jedes Zugangsdatum der Testplatz — oder, wo dieser leer ist, der Live-Platz. Zahlungen laufen weiter über den aktiven Stripe-Schlüssel des jeweiligen Modus.',
|
||||
'mode_switch_confirm_test' => 'Umlegen',
|
||||
'mode_switch_title_live' => 'Wirklich zu Livebetrieb wechseln?',
|
||||
'mode_switch_body_live' => 'Ab sofort fließt echtes Geld: jede Bestellung wird über den Live-Stripe-Schlüssel abgerechnet, nicht mehr über den Testschlüssel. Das ist keine Umschaltfläche zum Danebenklicken.',
|
||||
'mode_switch_confirm_live' => 'Umlegen',
|
||||
|
||||
'payments_title' => 'Zahlungen (Stripe)',
|
||||
'payments_body' => 'Der Secret Key, mit dem Zahlungen entgegengenommen und Webhooks geprüft werden.',
|
||||
|
||||
|
|
@ -96,4 +110,4 @@ return [
|
|||
'platform' => 'Plattform',
|
||||
'env' => 'Umgebung (.env)',
|
||||
],
|
||||
];
|
||||
];
|
||||
|
|
|
|||
|
|
@ -6,6 +6,34 @@
|
|||
* fehlt bleibt" — siehe den Kopfkommentar von App\Support\Readiness\Check.
|
||||
*/
|
||||
return [
|
||||
'page_title' => 'Betriebsbereitschaft',
|
||||
'page_sub' => 'Jedes Pflichtfeld dieser Installation an einer Stelle — bis oben „Bereit für Livebetrieb" steht, ist hier noch etwas offen.',
|
||||
'ready_test' => 'Bereit für Testbetrieb',
|
||||
'ready_live' => 'Bereit für Livebetrieb',
|
||||
'not_ready' => '{1} Noch nicht bereit — :n blockierender Punkt offen|[2,*] Noch nicht bereit — :n blockierende Punkte offen',
|
||||
|
||||
'group' => [
|
||||
'billing' => 'Abrechnung',
|
||||
'onboarding' => 'Host-Onboarding',
|
||||
'provisioning' => 'Bereitstellung',
|
||||
'delivery' => 'Zustellung',
|
||||
'operation' => 'Betrieb',
|
||||
],
|
||||
|
||||
'status' => [
|
||||
'satisfied' => 'Erfüllt',
|
||||
'blocking' => 'Blockierend',
|
||||
'warning' => 'Warnung',
|
||||
],
|
||||
|
||||
'run_check' => 'Prüfen',
|
||||
'check_ok' => 'Geprüft: in Ordnung',
|
||||
'check_failed' => 'Geprüft: nicht in Ordnung',
|
||||
'check_key_mode' => 'Der Schlüssel gehört zum Konto für :mode.',
|
||||
'check_key_mode_conflict' => 'Diese Installation läuft aber im :mode — der Schlüssel liegt im falschen Platz.',
|
||||
'fix_link' => 'Beheben',
|
||||
'last_heartbeat' => 'Letzter Herzschlag: :when',
|
||||
|
||||
'mode' => [
|
||||
'test' => 'Testbetrieb',
|
||||
'live' => 'Livebetrieb',
|
||||
|
|
@ -14,6 +42,12 @@ return [
|
|||
'billing' => [
|
||||
'stripe_secret' => 'Stripe-Schlüssel (:mode)',
|
||||
'stripe_secret_breaks' => 'Ohne ihn nimmt die Kasse keine Bestellung an — es entsteht gar kein Auftrag.',
|
||||
// Zwei getrennte Sätze, weil die Folgen entgegengesetzt sind: einmal
|
||||
// fließt echtes Geld, das nicht fließen sollte, einmal fließt keines,
|
||||
// das fließen sollte. Ein gemeinsamer Satz müsste beides andeuten und
|
||||
// sagte am Ende keins von beidem.
|
||||
'stripe_secret_test_slot_breaks' => 'Im Testplatz liegt ein Live-Schlüssel. Jede Bestellung bucht damit echtes Geld ab, während diese Seite und die Konsole „Testbetrieb" anzeigen — ein Probekauf ist dann ein echter Kauf, mit echter Karte und echter Abbuchung.',
|
||||
'stripe_secret_live_slot_breaks' => 'Im Live-Platz liegt ein Testschlüssel. Der Kunde durchläuft die Kasse, es wird nie Geld eingezogen, und die Bestellung sieht trotzdem bezahlt aus — die Bereitstellung läuft, der Beleg wird ausgestellt, und auf dem Konto kommt nichts an.',
|
||||
'webhook_secret' => 'Stripe-Signaturschlüssel',
|
||||
'webhook_secret_breaks' => 'Ohne ihn wird eine Zahlung nie verbucht: der Kunde zahlt, und nichts passiert.',
|
||||
'company_details' => 'Firmendaten vollständig',
|
||||
|
|
@ -22,6 +56,17 @@ return [
|
|||
'invoice_series_breaks' => 'IssueInvoice bricht beim Ausstellen ab, sobald eine Serie fehlt oder deaktiviert ist. Die Bereitstellung läuft trotzdem durch — der Kunde bekommt eine laufende Cloud ohne Beleg, und der Fehler landet nur im Log.',
|
||||
'catalogue_synced' => 'Stripe-Katalog abgeglichen',
|
||||
'catalogue_synced_breaks' => 'Ein geprüfter EU-Firmenkunde kann nicht bestellen, weil sein Netto-Preis bei Stripe fehlt.',
|
||||
// Sagt, was passiert, und was dagegen hilft — inklusive der
|
||||
// unbequemen Hälfte: `stripe:sync-catalogue` überspringt jede Zeile,
|
||||
// die schon eine ID trägt, repariert das also NICHT von selbst. Ein
|
||||
// Satz, der nur „bitte neu abgleichen" sagte, schickte den Betreiber
|
||||
// auf einen Befehl, der ihm „already in step" antwortet.
|
||||
// Der dritte Zustand, und ausdrücklich OHNE Leeranweisung: er entsteht
|
||||
// ohne jeden Kontowechsel, und die Objekte gehören zum geltenden Konto.
|
||||
// Wer hier zu löschen anfinge, löschte einen Katalog, an dem laufende
|
||||
// Verträge abgerechnet werden.
|
||||
'catalogue_origin_breaks' => 'Zu den gespeicherten Stripe-IDs ist nicht festgehalten, in welchem Konto sie entstanden sind — etwa weil ein Abgleich mitten im Anlegen abgebrochen ist oder weil eine Bestellung sich ihren fehlenden Preis selbst geholt hat. Solange das offen ist, kann diese Seite nicht sagen, ob die Kasse mit ihnen verkaufen kann. Ein Lauf von stripe:sync-catalogue setzt auf dem Vorhandenen auf, legt nur das Fehlende nach und hält die Herkunft fest — gelöscht werden muss dafür nichts.',
|
||||
'catalogue_account_breaks' => 'Die gespeicherten Stripe-Produkt- und Preis-IDs wurden in einem anderen Konto angelegt als dem, das jetzt gilt (:mode) — Stripe-Objekte gehören dem Konto, das sie ausgestellt hat. Jede Bestellung scheitert damit an der Kasse mit „No such price": kein Auftrag, keine Zahlung. Ein neuer Lauf von stripe:sync-catalogue allein genügt nicht, er überspringt jede Zeile, die schon eine ID trägt; erst nach dem Leeren von plan_families.stripe_product_id und plan_prices.stripe_price_id sowie der Register stripe_plan_prices und stripe_addon_prices legt er den Katalog im aktiven Konto neu an.',
|
||||
],
|
||||
|
||||
'onboarding' => [
|
||||
|
|
|
|||
|
|
@ -22,7 +22,23 @@ 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',
|
||||
// Im Testbetrieb gilt der Live-Wert, wenn der Testplatz leer ist — genau
|
||||
// das, was SecretVault::get() tut. Ohne eigene Beschriftung stand hier
|
||||
// „Aus der Serverdatei" über einem Wert aus der Datenbank, und wer die
|
||||
// .env daraufhin änderte, änderte nichts.
|
||||
'source_stored_live' => 'Aus dem Live-Platz',
|
||||
'source_environment' => 'Aus der Serverdatei',
|
||||
'source_none' => 'Nicht gesetzt',
|
||||
'stored_value' => 'Hinterlegt',
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ return [
|
|||
'revenue' => 'Revenue',
|
||||
'mail' => 'Email',
|
||||
'integrations' => 'Integrations',
|
||||
'readiness' => 'Readiness',
|
||||
'settings' => 'Settings',
|
||||
'two_factor_setup' => 'Two-factor login',
|
||||
],
|
||||
|
|
@ -93,6 +94,7 @@ return [
|
|||
'host_error' => 'Host :host reports an error.',
|
||||
'host_silent' => 'Host :host has not checked in for over :minutes minutes.',
|
||||
'monitoring_down' => ':n monitored instance(s) unreachable.',
|
||||
'readiness_blocking' => '{1} :n item on the readiness page blocks going live.|[2,*] :n items on the readiness page block going live.',
|
||||
],
|
||||
|
||||
'customers_sub' => 'All customers and their plans.',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,20 @@ return [
|
|||
'title' => 'Integrations',
|
||||
'subtitle' => 'Connected services, grouped by purpose — credentials and settings side by side, not on separate pages by storage mechanism.',
|
||||
|
||||
// The switch — above every credential, because it governs which slot of
|
||||
// each entry below applies.
|
||||
'mode_title' => 'Operating mode',
|
||||
'mode_body' => 'Decides which slot of each credential below applies. In test mode, an empty test slot falls back to the live slot — except the Stripe key, which does not fall back either way.',
|
||||
'mode_switch_to_test' => 'Switch to test mode',
|
||||
'mode_switch_to_live' => 'Switch to live mode',
|
||||
'mode_switched' => 'Operating mode switched: :mode.',
|
||||
'mode_switch_title_test' => 'Really switch to test mode?',
|
||||
'mode_switch_body_test' => 'From now on, every credential uses its test slot — or, where that one is empty, the live slot. Payments continue to go through whichever Stripe key belongs to the active mode.',
|
||||
'mode_switch_confirm_test' => 'Switch',
|
||||
'mode_switch_title_live' => 'Really switch to live mode?',
|
||||
'mode_switch_body_live' => 'From now on, real money moves: every order is charged through the live Stripe key, not the test one. This is not a switch to click by accident.',
|
||||
'mode_switch_confirm_live' => 'Switch',
|
||||
|
||||
'payments_title' => 'Payments (Stripe)',
|
||||
'payments_body' => 'The secret key used to accept payments and verify webhooks.',
|
||||
|
||||
|
|
@ -96,4 +110,4 @@ return [
|
|||
'platform' => 'Platform',
|
||||
'env' => 'Environment (.env)',
|
||||
],
|
||||
];
|
||||
];
|
||||
|
|
|
|||
|
|
@ -6,6 +6,34 @@
|
|||
* the header comment of App\Support\Readiness\Check.
|
||||
*/
|
||||
return [
|
||||
'page_title' => 'Readiness',
|
||||
'page_sub' => 'Every required field of this installation in one place — until it says "Ready for live mode" at the top, something here is still open.',
|
||||
'ready_test' => 'Ready for test mode',
|
||||
'ready_live' => 'Ready for live mode',
|
||||
'not_ready' => '{1} Not ready yet — :n blocking item open|[2,*] Not ready yet — :n blocking items open',
|
||||
|
||||
'group' => [
|
||||
'billing' => 'Billing',
|
||||
'onboarding' => 'Host onboarding',
|
||||
'provisioning' => 'Provisioning',
|
||||
'delivery' => 'Delivery',
|
||||
'operation' => 'Operation',
|
||||
],
|
||||
|
||||
'status' => [
|
||||
'satisfied' => 'Satisfied',
|
||||
'blocking' => 'Blocking',
|
||||
'warning' => 'Warning',
|
||||
],
|
||||
|
||||
'run_check' => 'Check',
|
||||
'check_ok' => 'Checked: OK',
|
||||
'check_failed' => 'Checked: not OK',
|
||||
'check_key_mode' => 'This key belongs to the :mode account.',
|
||||
'check_key_mode_conflict' => 'This installation is running in :mode — the key is in the wrong slot.',
|
||||
'fix_link' => 'Fix',
|
||||
'last_heartbeat' => 'Last heartbeat: :when',
|
||||
|
||||
'mode' => [
|
||||
'test' => 'Test mode',
|
||||
'live' => 'Live mode',
|
||||
|
|
@ -14,6 +42,10 @@ return [
|
|||
'billing' => [
|
||||
'stripe_secret' => 'Stripe key (:mode)',
|
||||
'stripe_secret_breaks' => 'Without it the checkout accepts no order at all — no order is ever created.',
|
||||
// Two separate sentences, because the consequences are opposite ones:
|
||||
// real money moving that should not, and no money moving that should.
|
||||
'stripe_secret_test_slot_breaks' => 'The test slot holds a live key. Every order charges real money while this page and the console say "test mode" — a trial purchase is a real purchase, on a real card.',
|
||||
'stripe_secret_live_slot_breaks' => 'The live slot holds a test key. The customer completes checkout, no money is ever taken, and the order still looks paid — provisioning runs, the invoice is issued, and nothing arrives in the account.',
|
||||
'webhook_secret' => 'Stripe signing secret',
|
||||
'webhook_secret_breaks' => 'Without it a payment is never recorded: the customer pays, and nothing happens.',
|
||||
'company_details' => 'Company details complete',
|
||||
|
|
@ -22,6 +54,14 @@ return [
|
|||
'invoice_series_breaks' => 'IssueInvoice aborts while issuing whenever a series is missing or switched off. Provisioning still goes through — the customer gets a running cloud with no invoice, and the failure is only written to the log.',
|
||||
'catalogue_synced' => 'Stripe catalogue in sync',
|
||||
'catalogue_synced_breaks' => 'A verified EU business customer cannot check out, because their net price is missing at Stripe.',
|
||||
// Says what happens and what actually helps, including the awkward
|
||||
// half: stripe:sync-catalogue skips every row that already carries an
|
||||
// id, so it does not repair this on its own.
|
||||
// The third state, deliberately WITHOUT a clear-it-out instruction: it
|
||||
// arises without any account change, and the objects belong to the
|
||||
// account in force.
|
||||
'catalogue_origin_breaks' => 'Nothing records which account the stored Stripe ids were created in — a sync may have died part-way through, or an order may have minted its own missing price. Until that is settled this page cannot say whether the checkout can sell with them. Running stripe:sync-catalogue takes up what is already there, creates only what is missing, and records the account — nothing has to be deleted for it.',
|
||||
'catalogue_account_breaks' => 'The stored Stripe product and price ids were created in a different account from the one now in force (:mode) — a Stripe object belongs to the account that issued it. Every order then fails at the checkout with "No such price": no order, no payment. Re-running stripe:sync-catalogue is not enough on its own, because it skips every row that already carries an id; only after clearing plan_families.stripe_product_id and plan_prices.stripe_price_id as well as the stripe_plan_prices and stripe_addon_prices registers does it recreate the catalogue in the active account.',
|
||||
],
|
||||
|
||||
'onboarding' => [
|
||||
|
|
|
|||
|
|
@ -22,7 +22,22 @@ 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',
|
||||
// In test mode the live value applies when the test slot is empty —
|
||||
// exactly what SecretVault::get() does. Without a label of its own this
|
||||
// read "From the server file" over a value coming from the database.
|
||||
'source_stored_live' => 'From the live slot',
|
||||
'source_environment' => 'From the server file',
|
||||
'source_none' => 'Not set',
|
||||
'stored_value' => 'Stored',
|
||||
|
|
|
|||
|
|
@ -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,33 @@
|
|||
<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>
|
||||
{{-- Aufgezählt nach den zwei Enden statt nach jedem Wert einzeln:
|
||||
grün heißt „liegt in DIESEM Platz", gelb heißt „nirgends", und
|
||||
alles dazwischen (aus der Serverdatei, aus dem Live-Platz) ist
|
||||
blau — in Kraft, aber woanders her. So bekommt ein künftiger
|
||||
vierter Wert nicht still die Farbe von „Nicht gesetzt". --}}
|
||||
<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'] === 'none' ? 'border-warning-border bg-warning-bg text-warning'
|
||||
: 'border-info-border bg-info-bg text-info') }}">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||||
{{ __('secrets.source_'.$entry['source']) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (! $unlocked)
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
'globe' => '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
|
||||
'send' => '<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/>',
|
||||
'plug' => '<path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/>',
|
||||
'beaker' => '<path d="M4.5 3h15"/><path d="M6 3v16a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V3"/><path d="M6 14h12"/>',
|
||||
];
|
||||
$body = $icons[$name] ?? '';
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,16 @@
|
|||
|
||||
<div class="flex min-w-0 flex-1 flex-col lg:h-screen lg:overflow-hidden">
|
||||
<x-shell.topbar :crumbs="array_values(array_filter([__('admin.console'), $title ?? \App\Support\Navigation::currentLabel(console: true)]))">
|
||||
{{-- "Bin ich im Test?" darf keine Frage sein, die man erst
|
||||
durch Navigieren zur Integrationsseite beantwortet — die
|
||||
Plakette steht im Kopf jeder Konsolenseite, solange der
|
||||
Modus test ist, und verschwindet von selbst im Livebetrieb. --}}
|
||||
@if (\App\Support\OperatingMode::current()->isTest())
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-900 dark:bg-amber-900/40 dark:text-amber-200">
|
||||
<x-ui.icon name="beaker" class="size-4" />
|
||||
{{ __('readiness.mode.test') }}
|
||||
</span>
|
||||
@endif
|
||||
@auth
|
||||
<div class="relative" x-data="{ open: false }" @keydown.escape="open = false">
|
||||
<button type="button" class="flex min-h-11 items-center gap-2.5 rounded px-2 hover:bg-surface-hover" @click="open = !open" :aria-expanded="open" aria-haspopup="menu">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
{{--
|
||||
Zweizeilige Rückfrage, kein Eingabefeld — braucht nach R24 kein
|
||||
<x-ui.modal>. Live bekommt eine eigene, wärmere Warnfarbe und einen
|
||||
zweiten Satz: das ist der Moment, ab dem echtes Geld bewegt wird, und das
|
||||
darf nicht dieselbe beiläufige Bestätigung sein wie der Wechsel zurück in
|
||||
den Testbetrieb.
|
||||
--}}
|
||||
<div class="rounded-lg bg-surface p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="grid size-10 shrink-0 place-items-center rounded-lg {{ $mode === 'live' ? 'bg-warning-bg text-warning' : 'bg-accent-subtle text-accent-text' }}">
|
||||
<x-ui.icon name="{{ $mode === 'live' ? 'alert-triangle' : 'beaker' }}" class="size-5" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-base font-semibold text-ink">{{ __('integrations.mode_switch_title_'.$mode) }}</h3>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('integrations.mode_switch_body_'.$mode) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button>
|
||||
<x-ui.button variant="primary" wire:click="confirm" wire:loading.attr="disabled">
|
||||
{{ __('integrations.mode_switch_confirm_'.$mode) }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -62,6 +62,36 @@
|
|||
@endif
|
||||
|
||||
@if ($tab === 'services')
|
||||
{{-- The switch — above every vault entry, because it governs them: which
|
||||
slot of each credential below is in force. Same lock as the vault
|
||||
itself (secrets.manage + confirmed password): the mode a stranger
|
||||
could type into a form field is only ever applied through
|
||||
switchMode()'s own guard, but the button that offers it in the first
|
||||
place stays behind the same unlock as everything else on this page. --}}
|
||||
@if ($canSecrets)
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||
<div>
|
||||
<h2 class="font-semibold text-ink">{{ __('integrations.mode_title') }}</h2>
|
||||
<p class="mt-1 max-w-xl text-sm text-muted">{{ __('integrations.mode_body') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<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' }}">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||||
{{ __('readiness.mode.'.$mode->value) }}
|
||||
</span>
|
||||
|
||||
@if ($unlocked)
|
||||
<x-ui.button variant="secondary"
|
||||
x-on:click="$dispatch('openModal', { component: 'admin.confirm-switch-mode', arguments: { mode: '{{ $mode->isTest() ? 'live' : 'test' }}' } })">
|
||||
{{ $mode->isTest() ? __('integrations.mode_switch_to_live') : __('integrations.mode_switch_to_test') }}
|
||||
</x-ui.button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Two columns from lg up. Each card is a form of two or three fields —
|
||||
stacked, they left half the window empty and pushed monitoring below
|
||||
the fold. --}}
|
||||
|
|
@ -73,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
|
||||
|
|
@ -97,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
|
||||
|
|
@ -123,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
|
||||
|
|
@ -142,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
|
||||
|
|
@ -188,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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
<div class="space-y-5">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('readiness.page_title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('readiness.page_sub') }}</p>
|
||||
</div>
|
||||
|
||||
{{-- The one line that matters. Everything below is detail. --}}
|
||||
<div class="flex items-center gap-3 rounded-lg border p-5 shadow-xs animate-rise
|
||||
{{ $ready ? 'border-success-border bg-success-bg' : 'border-warning-border bg-warning-bg' }}">
|
||||
<x-ui.icon :name="$ready ? 'shield-check' : 'alert-triangle'"
|
||||
class="{{ $ready ? 'text-success' : 'text-warning' }}" />
|
||||
<p class="text-lg font-semibold {{ $ready ? 'text-success' : 'text-warning' }}">
|
||||
@if ($ready)
|
||||
{{ $mode->isTest() ? __('readiness.ready_test') : __('readiness.ready_live') }}
|
||||
@else
|
||||
{{ trans_choice('readiness.not_ready', $blockingCount, ['n' => $blockingCount]) }}
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@foreach ($groups as $groupKey => $checks)
|
||||
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise">
|
||||
<h2 class="font-semibold text-ink">{{ __('readiness.group.'.$groupKey) }}</h2>
|
||||
|
||||
<ul class="mt-3 divide-y divide-line">
|
||||
@foreach ($checks as $check)
|
||||
@php
|
||||
$badgeStatus = $check->satisfied ? 'active' : ($check->isBlocking() ? 'failed' : 'warning');
|
||||
$statusLabel = $check->satisfied
|
||||
? __('readiness.status.satisfied')
|
||||
: ($check->isBlocking() ? __('readiness.status.blocking') : __('readiness.status.warning'));
|
||||
$iconName = $check->satisfied ? 'check' : ($check->isBlocking() ? 'x' : 'alert-triangle');
|
||||
$iconTone = $check->satisfied ? 'text-success' : ($check->isBlocking() ? 'text-danger' : 'text-warning');
|
||||
$result = $results[$check->key] ?? null;
|
||||
$heartbeat = $heartbeats[$check->key] ?? null;
|
||||
@endphp
|
||||
<li class="flex flex-wrap items-start gap-3 py-3">
|
||||
<x-ui.icon :name="$iconName" class="mt-0.5 size-4 shrink-0 {{ $iconTone }}" />
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-ink">{{ $check->label }}</span>
|
||||
<x-ui.badge :status="$badgeStatus">{{ $statusLabel }}</x-ui.badge>
|
||||
</div>
|
||||
|
||||
@unless ($check->satisfied)
|
||||
<p class="mt-1 text-sm text-muted">{{ $check->breaks }}</p>
|
||||
@endunless
|
||||
|
||||
@if ($heartbeat !== null)
|
||||
<p class="mt-1 font-mono text-xs text-faint">
|
||||
{{ __('readiness.last_heartbeat', ['when' => $heartbeat]) }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if ($result !== null)
|
||||
<p class="mt-1 text-xs {{ $result['ok'] ? 'text-success' : 'text-danger' }}">
|
||||
{{ $result['ok'] ? __('readiness.check_ok') : __('readiness.check_failed') }}
|
||||
@if (($result['reason'] ?? null) !== null)
|
||||
— <span class="font-mono">{{ $result['reason'] }}</span>
|
||||
@endif
|
||||
</p>
|
||||
|
||||
{{-- StripeCheck rechnet die Kontoart des Schlüssels längst aus,
|
||||
und diese Seite hat sie bislang weggeworfen: wer „Prüfen"
|
||||
drückte, las „Geprüft: in Ordnung" über einen Live-Schlüssel
|
||||
im Testplatz. Die Prüfung selbst blockiert dafür jetzt
|
||||
(BillingChecks); hier steht, was der Knopf gerade gesehen
|
||||
hat, damit er ihr nicht widerspricht. --}}
|
||||
@if (array_key_exists('live', $result))
|
||||
@php $keyMode = $result['live'] ? 'live' : 'test'; @endphp
|
||||
<p class="mt-1 text-xs {{ $keyMode === $mode->value ? 'text-muted' : 'text-danger' }}">
|
||||
{{ __('readiness.check_key_mode', ['mode' => __('readiness.mode.'.$keyMode)]) }}
|
||||
@unless ($keyMode === $mode->value)
|
||||
{{ __('readiness.check_key_mode_conflict', ['mode' => __('readiness.mode.'.$mode->value)]) }}
|
||||
@endunless
|
||||
</p>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-3">
|
||||
{{-- mount() only requires hosts.manage OR secrets.manage, so an
|
||||
Admin (hosts.manage alone) can reach this page — but runCheck()
|
||||
itself demands secrets.manage and 403s otherwise. A button that
|
||||
is visible and throws when pressed is worse than no button. --}}
|
||||
@if (in_array($check->key, $runnable, true))
|
||||
@can('secrets.manage')
|
||||
<x-ui.button variant="secondary" size="sm"
|
||||
wire:click="runCheck('{{ $check->key }}')"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="runCheck('{{ $check->key }}')">
|
||||
<x-ui.icon name="refresh" class="size-4" />
|
||||
{{ __('readiness.run_check') }}
|
||||
</x-ui.button>
|
||||
@endcan
|
||||
@endif
|
||||
|
||||
<a href="{{ $this->checkUrl($check) }}" wire:navigate
|
||||
class="inline-flex items-center gap-1 text-xs font-semibold text-accent-text hover:underline">
|
||||
{{ __('readiness.fix_link') }}
|
||||
<x-ui.icon name="external-link" class="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
|
@ -2,8 +2,11 @@
|
|||
|
||||
use App\Http\Controllers\ImpersonationController;
|
||||
use App\Livewire\Admin;
|
||||
use App\Models\Invoice;
|
||||
use App\Services\Billing\InvoiceRenderer;
|
||||
use App\Services\Deployment\UpdateChannel;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
|
|
@ -59,12 +62,12 @@ Route::get('/invoices/new', Admin\NewInvoice::class)->name('invoices.new');
|
|||
// than a Livewire action: a download is a download, and routing one through a
|
||||
// component round-trip only adds a way for it to fail.
|
||||
Route::get('/invoices/{uuid}/pdf', function (string $uuid) {
|
||||
Illuminate\Support\Facades\Gate::authorize('site.manage');
|
||||
Gate::authorize('site.manage');
|
||||
|
||||
$invoice = App\Models\Invoice::query()->where('uuid', $uuid)->firstOrFail();
|
||||
$invoice = Invoice::query()->where('uuid', $uuid)->firstOrFail();
|
||||
|
||||
return response()->streamDownload(
|
||||
fn () => print (app(App\Services\Billing\InvoiceRenderer::class)->forInvoice($invoice)),
|
||||
fn () => print (app(InvoiceRenderer::class)->forInvoice($invoice)),
|
||||
$invoice->number.'.pdf',
|
||||
['Content-Type' => 'application/pdf'],
|
||||
);
|
||||
|
|
@ -79,6 +82,11 @@ Route::get('/integrations', Admin\Integrations::class)->name('integrations');
|
|||
// uses in routes/web.php.
|
||||
Route::get('/secrets', fn () => redirect()->route('admin.integrations', status: 301))->name('secrets');
|
||||
Route::get('/infrastructure', fn () => redirect()->route('admin.integrations', status: 301))->name('infrastructure');
|
||||
// Everything App\Support\Readiness knows how to check, in one place — gated
|
||||
// the same way as Integrations just above, since it is the page where both
|
||||
// halves that page configures (Settings and the vault) come back together as
|
||||
// one list of what is still missing.
|
||||
Route::get('/readiness', Admin\Readiness::class)->name('readiness');
|
||||
Route::get('/settings', Admin\Settings::class)->name('settings');
|
||||
// Its own route so RequireOperatorTwoFactor can exempt ENROLMENT without
|
||||
// exempting the rest of admin.settings — see that middleware for why.
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\Instances as AdminInstances;
|
||||
use App\Livewire\Admin\Overview;
|
||||
use App\Livewire\Admin\Revenue;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Host;
|
||||
use App\Models\Instance;
|
||||
use App\Models\ProvisioningRun;
|
||||
use App\Models\Subscription;
|
||||
use App\Livewire\Admin\Instances as AdminInstances;
|
||||
use App\Livewire\Admin\Overview;
|
||||
use App\Livewire\Admin\Revenue;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Livewire;
|
||||
|
||||
|
|
@ -19,7 +19,6 @@ use Livewire\Livewire;
|
|||
* twelve-month growth curve. It read like a running business and measured
|
||||
* nothing. These tests exist so that cannot come back quietly.
|
||||
*/
|
||||
|
||||
it('reports an empty estate as empty, not as a going concern', function () {
|
||||
// The strongest statement of the old bug: with nothing in the database the
|
||||
// console still claimed 42 customers, 39 instances, 4 hosts and €7,842 a
|
||||
|
|
@ -37,9 +36,17 @@ it('reports an empty estate as empty, not as a going concern', function () {
|
|||
})
|
||||
->assertViewHas('hostLoad', [])
|
||||
->assertViewHas('runs', [])
|
||||
->assertViewHas('notices', [])
|
||||
->assertSee(__('admin.no_open_runs'))
|
||||
->assertSee(__('admin.no_notices'));
|
||||
// Not notices === [] any more (Task 12): an installation with nothing
|
||||
// configured is not the same as an installation with nothing WRONG.
|
||||
// Readiness::blocking() reports a real, non-invented gap here (nine
|
||||
// unmet checks on a bare database — no Stripe key, no DNS token, no
|
||||
// scheduler heartbeat yet, and so on), and hiding that behind an
|
||||
// empty notices list would be exactly the kind of fiction this file
|
||||
// exists to catch. What is still true of a truly empty estate: no
|
||||
// INCIDENT-shaped notice invents itself out of nothing — the one
|
||||
// notice present is the readiness gap, and nothing else.
|
||||
->assertViewHas('notices', fn (array $notices) => collect($notices)->pluck('route')->all() === ['admin.readiness'])
|
||||
->assertSee(__('admin.no_open_runs'));
|
||||
});
|
||||
|
||||
it('counts what is actually there', function () {
|
||||
|
|
@ -106,16 +113,24 @@ it('states monthly revenue off the contracts, with a yearly term divided', funct
|
|||
->assertDontSee('999,00');
|
||||
});
|
||||
|
||||
it('raises a notice only when something is actually wrong', function () {
|
||||
/**
|
||||
* `admin.systems_ok` can no longer be asserted on a bare install (Task 12): a
|
||||
* brand new console always starts with at least one readiness gap, which is
|
||||
* exactly the point of Readiness::blocking() existing at all — a badge
|
||||
* claiming nothing is wrong would itself be the invented-data problem this
|
||||
* file is about. What this test is actually for still holds: an INCIDENT
|
||||
* notice (a failed run, a silent host…) appears only once one is real, on
|
||||
* top of whatever readiness gaps a fresh install already carries.
|
||||
*/
|
||||
it('raises an incident notice only when something is actually wrong', function () {
|
||||
$clean = Livewire::actingAs(operator('Owner'), 'operator')->test(Overview::class);
|
||||
$clean->assertSee(__('admin.systems_ok'));
|
||||
$clean->assertDontSee(__('admin.notice.failed_runs', ['n' => 1]));
|
||||
|
||||
ProvisioningRun::factory()->create(['status' => ProvisioningRun::STATUS_FAILED]);
|
||||
|
||||
Livewire::actingAs(operator('Owner'), 'operator')
|
||||
->test(Overview::class)
|
||||
->assertSee(__('admin.notice.failed_runs', ['n' => 1]))
|
||||
->assertDontSee(__('admin.systems_ok'));
|
||||
->assertSee(__('admin.notice.failed_runs', ['n' => 1]));
|
||||
});
|
||||
|
||||
it('gets the same committed storage whether or not the sum was preloaded', function () {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
<?php
|
||||
|
||||
use App\Models\PlanPrice;
|
||||
use App\Services\Stripe\FakeStripeClient;
|
||||
use App\Services\Stripe\StripeClient;
|
||||
use App\Support\OperatingMode;
|
||||
use App\Support\Settings;
|
||||
use App\Support\StripeCatalogueMode;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
|
||||
/**
|
||||
* Der Abgleich sagt, in wessen Konto er gearbeitet hat — und weigert sich, in
|
||||
* einen Katalog hineinzuarbeiten, der einem anderen gehört.
|
||||
*
|
||||
* Eine Stripe-Price-ID gehört dem Konto, das sie ausgestellt hat. Der Befehl
|
||||
* überspringt jede Zeile, die schon eine ID trägt (PlanPrices::inStep()), also
|
||||
* repariert ein zweiter Lauf nach dem Umschalten gar nichts — er meldete
|
||||
* „already in step" und ließe eine Zeile zurück, die behauptet, der Katalog
|
||||
* gehöre jetzt zum Livekonto. Genau diese Lüge macht die Weigerung unnötig.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
$this->stripe = new FakeStripeClient;
|
||||
app()->instance(StripeClient::class, $this->stripe);
|
||||
});
|
||||
|
||||
it('records the mode its objects were created in', function () {
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
|
||||
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
|
||||
|
||||
expect(StripeCatalogueMode::recorded())->toBe(OperatingMode::Test)
|
||||
->and(StripeCatalogueMode::hasStoredObjects())->toBeTrue();
|
||||
});
|
||||
|
||||
it('records nothing on a dry run, which creates nothing', function () {
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
|
||||
$this->artisan('stripe:sync-catalogue', ['--dry-run' => true])->assertSuccessful();
|
||||
|
||||
expect(StripeCatalogueMode::recorded())->toBeNull();
|
||||
});
|
||||
|
||||
it('refuses to work into a catalogue that belongs to the other account', function () {
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
|
||||
|
||||
OperatingMode::set(OperatingMode::Live);
|
||||
|
||||
$this->artisan('stripe:sync-catalogue')->assertFailed();
|
||||
|
||||
// Und die Zeile lügt danach nicht: der Katalog gehört weiterhin dem
|
||||
// Testkonto, weil dieser Lauf nichts angelegt hat.
|
||||
expect(StripeCatalogueMode::recorded())->toBe(OperatingMode::Test);
|
||||
});
|
||||
|
||||
/**
|
||||
* „Herkunft nie festgehalten" ist NICHT „fremdes Konto".
|
||||
*
|
||||
* Der Zustand entsteht ohne jeden Fehler: `CheckoutController` → `PlanPrices::
|
||||
* ensure()` und `BookAddon` → `SyncStripeAddonItems` → `AddonPrices::ensure()`
|
||||
* legen fehlende Preise selbst an, und keiner der beiden ruft `record()`. Er
|
||||
* entsteht auch nach einem Lauf, der mittendrin abgebrochen ist.
|
||||
*
|
||||
* Die Objekte stammen dann aus genau dem geltenden Konto. Diesen Zustand in die
|
||||
* Konto-Weigerung zu schicken, hieße einem Betreiber das Leeren eines Katalogs
|
||||
* aufzutragen, an dem laufende Verträge abgerechnet werden — für ein
|
||||
* Wiederaufsetzen, für das die Idempotenzschlüssel dieses Befehls ausdrücklich
|
||||
* gebaut sind.
|
||||
*/
|
||||
it('takes up a catalogue whose origin was never written down', function () {
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
|
||||
|
||||
Settings::forget(StripeCatalogueMode::SETTING);
|
||||
|
||||
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
|
||||
|
||||
expect(StripeCatalogueMode::recorded())->toBe(OperatingMode::Test);
|
||||
});
|
||||
|
||||
it('leaves no half-built catalogue that contradicts itself when a run dies', function () {
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
|
||||
// Stripe geht mitten im Lauf weg, nachdem das erste Produkt schon angelegt
|
||||
// und seine ID gespeichert ist. `createProduct()` und `ensure()` werfen
|
||||
// ungefangen — stünde record() am ENDE von handle(), bliebe genau der
|
||||
// Zustand zurück, den der Test darüber beschreibt: IDs da, Herkunft leer.
|
||||
app()->instance(StripeClient::class, new class extends FakeStripeClient
|
||||
{
|
||||
public function createPrice(
|
||||
string $productId,
|
||||
int $amountCents,
|
||||
string $currency,
|
||||
string $interval,
|
||||
array $metadata = [],
|
||||
?string $idempotencyKey = null,
|
||||
): string {
|
||||
throw new RuntimeException('Stripe went away mid-run');
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
app(Kernel::class)->call('stripe:sync-catalogue');
|
||||
} catch (RuntimeException) {
|
||||
// Erwartet — der Abbruch IST der Testfall.
|
||||
}
|
||||
|
||||
expect(StripeCatalogueMode::hasStoredObjects())->toBeTrue()
|
||||
->and(StripeCatalogueMode::recorded())->toBe(OperatingMode::Test);
|
||||
});
|
||||
|
||||
it('syncs into the new account once the stale ids are cleared', function () {
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
|
||||
|
||||
OperatingMode::set(OperatingMode::Live);
|
||||
|
||||
// Was der `breaks`-Satz und die Fehlermeldung dem Betreiber auftragen —
|
||||
// Zeiger UND Register, weil PlanPrices::inStep() für den Netto-Preis eines
|
||||
// Reverse-Charge-Kunden allein das Register fragt.
|
||||
DB::table('plan_families')->update(['stripe_product_id' => null]);
|
||||
DB::table('plan_prices')->update(['stripe_price_id' => null]);
|
||||
DB::table('stripe_plan_prices')->delete();
|
||||
DB::table('stripe_addon_prices')->delete();
|
||||
|
||||
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
|
||||
|
||||
expect(StripeCatalogueMode::recorded())->toBe(OperatingMode::Live)
|
||||
->and(PlanPrice::query()->whereNotNull('stripe_price_id')->exists())->toBeTrue();
|
||||
});
|
||||
|
|
@ -3,10 +3,13 @@
|
|||
use App\Exceptions\StripeNotConfigured;
|
||||
use App\Livewire\Billing;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Operator;
|
||||
use App\Models\Order;
|
||||
use App\Models\User;
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use App\Services\Stripe\HttpStripeClient;
|
||||
use App\Support\OperatingMode;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
|
|
@ -23,6 +26,43 @@ it('refuses to reach stripe without a key for the active mode', function () {
|
|||
->toThrow(StripeNotConfigured::class);
|
||||
});
|
||||
|
||||
/**
|
||||
* Die andere Hälfte derselben Entscheidung, und der Riegel vor dem Merge:
|
||||
* `secret()` WIRFT, `isConfigured()` WIRFT NICHT.
|
||||
*
|
||||
* Sechs Aufrufer stellen genau diese Frage, um KEINE Ausnahme zu bekommen —
|
||||
* CheckoutController:87 (der Kunde liest sonst eine 500 statt eines Satzes),
|
||||
* SyncStripeCatalogue, RepriceStripeSubscriptions, SyncStripeAddonItems,
|
||||
* MoveStripeSubscriptionPrice. `main` trägt an dieser Stelle noch
|
||||
* `return filled($this->secret());`, und der Trockenlauf des Merges führt die
|
||||
* Datei OHNE Konfliktmarkierung zusammen: niemand wird gezwungen hinzusehen.
|
||||
* Mit `main`s Rumpf und dem werfenden `secret()` dieses Zweigs wird aus der
|
||||
* Frage eine Ausnahme — und ohne diesen Test bleiben dabei 1795 Tests grün
|
||||
* (nachgewiesen im Schluss-Review durch genau diese Mutation).
|
||||
*/
|
||||
it('answers the configured question instead of throwing it', function () {
|
||||
Http::fake();
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
|
||||
// Nicht toBeFalse() allein: eine geworfene Ausnahme wäre kein „false",
|
||||
// sondern gar keine Antwort — und genau das ist der Rückschritt.
|
||||
expect(app(HttpStripeClient::class)->isConfigured())->toBeFalse();
|
||||
|
||||
// Die Frage darf auch nichts kosten: sie wird auf jedem Kassenaufruf
|
||||
// gestellt und liest nur den Tresor.
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('says configured once the slot of the active mode is filled', function () {
|
||||
Http::fake();
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
app(SecretVault::class)->put('stripe.secret', 'sk_test_x', Operator::factory()->create(), OperatingMode::Test);
|
||||
|
||||
expect(app(HttpStripeClient::class)->isConfigured())->toBeTrue();
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('does not create an order when the key is missing', function () {
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
$customer = Customer::factory()->create();
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use App\Support\OperatingMode;
|
|||
use App\Support\Readiness;
|
||||
use App\Support\Readiness\Check;
|
||||
use App\Support\Settings;
|
||||
use App\Support\StripeCatalogueMode;
|
||||
|
||||
/**
|
||||
* Die Bereitschaftsprüfung berichtet, was fehlt — und sagt dazu, was
|
||||
|
|
@ -43,6 +44,55 @@ it('does not accept a live key as proof while the test mode is active', function
|
|||
expect(checkFor('billing.stripe_secret')->satisfied)->toBeFalse();
|
||||
});
|
||||
|
||||
/**
|
||||
* Ein Schlüssel im falschen Platz ist ein blockierender Befund.
|
||||
*
|
||||
* Gemessen im Schluss-Review: `put('stripe.secret', 'sk_live_REALMONEY')` im
|
||||
* Testbetrieb wird angenommen, `get()` liefert ihn, die Bereitschaftsseite
|
||||
* meldete „erfüllt" — und oben auf derselben Seite steht die Plakette
|
||||
* „Testbetrieb". Das ist derselbe Ausgang, gegen den die strikte Ausnahme im
|
||||
* Tresor gebaut wurde („bucht echtes Geld ab, während die Konsole Testbetrieb
|
||||
* anzeigt"), nur über den eingetippten statt den automatischen Weg.
|
||||
*
|
||||
* Am Präfix ohne Netzverkehr entscheidbar — OperatingMode::ofStripeKey(),
|
||||
* dieselbe Stelle, die auch die Migration und StripeCheck fragen.
|
||||
*/
|
||||
it('refuses a live key sitting in the test slot', function () {
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
app(SecretVault::class)->put('stripe.secret', 'sk_live_REALMONEY', Operator::factory()->create(), OperatingMode::Test);
|
||||
|
||||
expect(checkFor('billing.stripe_secret')->satisfied)->toBeFalse();
|
||||
expect(checkFor('billing.stripe_secret')->severity)->toBe(Check::SEVERITY_BLOCKING);
|
||||
});
|
||||
|
||||
it('refuses a test key sitting in the live slot', function () {
|
||||
// Die andere Richtung kostet kein Geld, sondern nimmt keins ein: der Kunde
|
||||
// zahlt in Stripes Testkonto, und die Bestellung sieht bezahlt aus.
|
||||
OperatingMode::set(OperatingMode::Live);
|
||||
app(SecretVault::class)->put('stripe.secret', 'sk_test_notreal', Operator::factory()->create(), OperatingMode::Live);
|
||||
|
||||
expect(checkFor('billing.stripe_secret')->satisfied)->toBeFalse();
|
||||
});
|
||||
|
||||
it('accepts the restricted key of the active mode', function () {
|
||||
// rk_ ist ein echter Schlüssel, kein Sonderfall — die Erkennung darf ihn
|
||||
// nicht für einen Widerspruch halten, nur weil er nicht sk_ heißt.
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
app(SecretVault::class)->put('stripe.secret', 'rk_test_readonly', Operator::factory()->create(), OperatingMode::Test);
|
||||
|
||||
expect(checkFor('billing.stripe_secret')->satisfied)->toBeTrue();
|
||||
});
|
||||
|
||||
it('says what the wrong key does, not that a field is empty', function () {
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
app(SecretVault::class)->put('stripe.secret', 'sk_live_REALMONEY', Operator::factory()->create(), OperatingMode::Test);
|
||||
|
||||
// Der Satz für „gar kein Schlüssel" wäre hier eine Lüge: einer liegt da,
|
||||
// er ist der falsche, und die Folge ist eine ganz andere.
|
||||
expect(checkFor('billing.stripe_secret')->breaks)
|
||||
->not->toBe(__('readiness.billing.stripe_secret_breaks'));
|
||||
});
|
||||
|
||||
it('reports incomplete company details without repeating the list', function () {
|
||||
Settings::forget('company.name');
|
||||
|
||||
|
|
@ -88,5 +138,82 @@ it('does not raise a permanent alarm for a draft version stripe never touches',
|
|||
]);
|
||||
$draft->prices()->create(['term' => 'monthly', 'amount_cents' => 1000, 'currency' => 'EUR']);
|
||||
|
||||
// Der Abgleich hält seit dieser Runde fest, in welchem Konto er die IDs
|
||||
// angelegt hat; ohne diese Zeile wäre der Katalog oben von unbekannter
|
||||
// Herkunft und die Prüfung meldete zu Recht einen Widerspruch.
|
||||
StripeCatalogueMode::record();
|
||||
|
||||
expect(checkFor('billing.catalogue_synced')->satisfied)->toBeTrue();
|
||||
});
|
||||
|
||||
/**
|
||||
* Der Modus schaltet die Zugangsdaten um — nicht die Objekte, die aus ihnen
|
||||
* entstanden sind.
|
||||
*
|
||||
* Eine Stripe-Price-ID gehört dem Konto, das sie ausgestellt hat. Der geplante
|
||||
* Ablauf dieser Installation war: im Testbetrieb abgleichen, Live-Schlüssel
|
||||
* hinterlegen, umschalten — und diese Prüfung meldete weiter „erfüllt", weil
|
||||
* sie nur nachsah, ob die Spalte gefüllt ist. Die Seite sagte „Bereit für
|
||||
* Livebetrieb", und die erste echte Bestellung bekam von Stripe *No such
|
||||
* price*. Kein Auftrag, und die Kernzusage dieser Seite gebrochen in genau dem
|
||||
* Moment, für den sie existiert.
|
||||
*/
|
||||
it('does not call the catalogue synced when its ids belong to the other account', function () {
|
||||
PlanPrice::query()->update(['stripe_price_id' => 'price_from_the_test_account']);
|
||||
StripeCatalogueMode::record(OperatingMode::Test);
|
||||
|
||||
OperatingMode::set(OperatingMode::Live);
|
||||
|
||||
expect(checkFor('billing.catalogue_synced')->satisfied)->toBeFalse();
|
||||
expect(checkFor('billing.catalogue_synced')->severity)->toBe(Check::SEVERITY_BLOCKING);
|
||||
});
|
||||
|
||||
it('says the sale is refused and a fresh sync is needed, not that a column is empty', function () {
|
||||
PlanPrice::query()->update(['stripe_price_id' => 'price_from_the_test_account']);
|
||||
StripeCatalogueMode::record(OperatingMode::Test);
|
||||
|
||||
OperatingMode::set(OperatingMode::Live);
|
||||
|
||||
// Der Satz für „nie abgeglichen" wäre hier falsch: abgeglichen wurde, nur
|
||||
// in einem anderen Konto — und ein blosser neuer Lauf repariert das NICHT,
|
||||
// weil stripe:sync-catalogue jede Zeile überspringt, die schon eine ID
|
||||
// trägt. Das muss dastehen, sonst schickt die Seite den Betreiber auf einen
|
||||
// Befehl, der ihm „already in step" antwortet.
|
||||
expect(checkFor('billing.catalogue_synced')->breaks)
|
||||
->not->toBe(__('readiness.billing.catalogue_synced_breaks'))
|
||||
// Nur DIESER Fall trägt die Leeranweisung.
|
||||
->and(checkFor('billing.catalogue_synced')->breaks)->toContain('stripe_plan_prices');
|
||||
});
|
||||
|
||||
/**
|
||||
* Der dritte Zustand, und er darf nicht mit dem zweiten zusammenfallen.
|
||||
*
|
||||
* IDs liegen da, aber nichts sagt, aus welchem Konto — weil ein Abgleich
|
||||
* mittendrin abgebrochen ist, oder weil eine Bestellung sich ihren fehlenden
|
||||
* Preis selbst angelegt hat (CheckoutController → PlanPrices::ensure(),
|
||||
* BookAddon → AddonPrices::ensure(); keiner der beiden ruft record()). Sie
|
||||
* stammen aus genau dem geltenden Konto. Wer hier die Leeranweisung liest,
|
||||
* löscht einen Katalog, an dem laufende Verträge abgerechnet werden.
|
||||
*/
|
||||
it('tells an unrecorded origin apart from a foreign account', function () {
|
||||
PlanPrice::query()->update(['stripe_price_id' => 'price_minted_by_a_checkout']);
|
||||
Settings::forget(StripeCatalogueMode::SETTING);
|
||||
|
||||
// Blockierend bleibt es: beweisen lässt sich die Herkunft nicht, und die
|
||||
// Seite behauptet nichts, was sie nicht weiß.
|
||||
expect(checkFor('billing.catalogue_synced')->satisfied)->toBeFalse();
|
||||
|
||||
// Aber die Handlungsanweisung ist „lauf den Abgleich noch einmal", nicht
|
||||
// „lösche deinen Katalog" — kein Registername in diesem Satz.
|
||||
expect(checkFor('billing.catalogue_synced')->breaks)
|
||||
->not->toContain('stripe_plan_prices')
|
||||
->and(checkFor('billing.catalogue_synced')->breaks)->toContain('stripe:sync-catalogue');
|
||||
});
|
||||
|
||||
it('is satisfied again once the catalogue was synced in the mode that is running', function () {
|
||||
PlanPrice::query()->update(['stripe_price_id' => 'price_from_the_live_account']);
|
||||
OperatingMode::set(OperatingMode::Live);
|
||||
StripeCatalogueMode::record();
|
||||
|
||||
expect(checkFor('billing.catalogue_synced')->satisfied)->toBeTrue();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -92,12 +92,12 @@ it('treats missing reply templates as a warning, never as a blocker', function (
|
|||
it('treats a missing inbound mail password as a warning, never as a blocker', function () {
|
||||
OperatingMode::set(OperatingMode::Live);
|
||||
|
||||
expect(deliveryCheck('delivery.inbound_password')->satisfied)->toBeFalse();
|
||||
expect(deliveryCheck('delivery.inbound_password')->severity)->toBe(Check::SEVERITY_WARNING);
|
||||
expect(deliveryCheck('delivery.inbound_mail_password')->satisfied)->toBeFalse();
|
||||
expect(deliveryCheck('delivery.inbound_mail_password')->severity)->toBe(Check::SEVERITY_WARNING);
|
||||
|
||||
app(SecretVault::class)->put('inbound_mail.password', 'secret', Operator::factory()->create(), OperatingMode::Live);
|
||||
|
||||
expect(deliveryCheck('delivery.inbound_password')->satisfied)->toBeTrue();
|
||||
expect(deliveryCheck('delivery.inbound_mail_password')->satisfied)->toBeTrue();
|
||||
});
|
||||
|
||||
it('names what breaks for every delivery check, not just what is missing', function () {
|
||||
|
|
@ -105,7 +105,7 @@ it('names what breaks for every delivery check, not just what is missing', funct
|
|||
'delivery.mailer_not_log',
|
||||
'delivery.mailbox',
|
||||
'delivery.mail_templates',
|
||||
'delivery.inbound_password',
|
||||
'delivery.inbound_mail_password',
|
||||
] as $key) {
|
||||
expect(deliveryCheck($key)->breaks)->not->toBe('');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ it('needs an ssh private key', function () {
|
|||
// zurückgesetzt, damit der Test nicht vom Entwicklerrechner abhängt.
|
||||
config()->set('provisioning.ssh.private_key', '');
|
||||
|
||||
expect(onboardingCheck('onboarding.ssh_key')->satisfied)->toBeFalse();
|
||||
expect(onboardingCheck('onboarding.ssh_private_key')->satisfied)->toBeFalse();
|
||||
|
||||
app(SecretVault::class)->put(
|
||||
'ssh.private_key',
|
||||
|
|
@ -54,7 +54,7 @@ it('needs an ssh private key', function () {
|
|||
OperatingMode::Live,
|
||||
);
|
||||
|
||||
expect(onboardingCheck('onboarding.ssh_key')->satisfied)->toBeTrue();
|
||||
expect(onboardingCheck('onboarding.ssh_private_key')->satisfied)->toBeTrue();
|
||||
});
|
||||
|
||||
it('needs a usable vault key', function () {
|
||||
|
|
@ -101,7 +101,7 @@ it('needs all three wireguard values, not just some', function () {
|
|||
|
||||
it('names what breaks for every onboarding check, not just what is missing', function () {
|
||||
foreach ([
|
||||
'onboarding.ssh_key',
|
||||
'onboarding.ssh_private_key',
|
||||
'onboarding.secrets_key',
|
||||
'onboarding.vpn_config_key',
|
||||
'onboarding.wg_hub',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\Readiness as ReadinessPage;
|
||||
use App\Models\Operator;
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use App\Support\OperatingMode;
|
||||
use App\Support\Readiness;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('is closed to operators without either capability', function () {
|
||||
$this->actingAs(Operator::factory()->role('Read-only')->create(), 'operator')
|
||||
->get(route('admin.readiness'))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('lists every check, satisfied or not', function () {
|
||||
// Livewire::actingAs() needs the 'operator' guard spelled out — omitting it
|
||||
// signed the test in on the 'web' guard instead and cost Task 11 a failing
|
||||
// test for exactly this reason.
|
||||
Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
|
||||
->test(ReadinessPage::class)
|
||||
->assertSee(__('readiness.billing.company_details'))
|
||||
->assertSee(__('readiness.provisioning.usable_host'));
|
||||
});
|
||||
|
||||
it('says what breaks, not only that something is missing', function () {
|
||||
Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
|
||||
->test(ReadinessPage::class)
|
||||
->assertSee(__('readiness.delivery.mailer_not_log_breaks'));
|
||||
});
|
||||
|
||||
it('runs an active check on demand and shows its answer', function () {
|
||||
// Die drei Prüfungen aus Task 9 sind nur dann etwas wert, wenn jemand sie
|
||||
// auslösen kann. Sie laufen NICHT bei jedem Seitenaufruf mit: eine davon
|
||||
// schreibt einen TXT-Eintrag in die echte Zone.
|
||||
//
|
||||
// Http::fake() steht hier unbedingt, nicht weil dieser eine Testpfad das
|
||||
// Netzwerk erreicht (DnsTokenCheck bricht schon vorher an blank($token)
|
||||
// || blank($zone) ab), sondern weil JEDER Test, der runCheck() anfasst,
|
||||
// dieselbe Absicherung tragen soll — ein Testpfad, der heute früh
|
||||
// ausscheidet, ist keine Garantie, dass er es morgen noch tut.
|
||||
Http::fake();
|
||||
|
||||
Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
|
||||
->test(ReadinessPage::class)
|
||||
->call('runCheck', 'provisioning.dns_token')
|
||||
->assertSet('results.provisioning.dns_token.ok', false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Der „Prüfen"-Knopf darf der Prüfung nicht widersprechen.
|
||||
*
|
||||
* StripeCheck::run() rechnet die Kontoart des Schlüssels aus, die Seite hat
|
||||
* sie verworfen und nur `ok`/`reason` gerendert — ein Live-Schlüssel im
|
||||
* Testplatz antwortet bei Stripe einwandfrei, und die Seite las darüber
|
||||
* „Geprüft: in Ordnung", während die Plakette oben „Testbetrieb" sagte.
|
||||
*/
|
||||
it('names the account a checked key belongs to, and says when it is the wrong one', function () {
|
||||
Http::fake(['*' => Http::response(['id' => 'acct_1'], 200)]);
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
app(SecretVault::class)->put('stripe.secret', 'sk_live_REALMONEY', Operator::factory()->create(), OperatingMode::Test);
|
||||
|
||||
Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
|
||||
->test(ReadinessPage::class)
|
||||
->call('runCheck', 'billing.stripe_secret')
|
||||
->assertSee(__('readiness.check_key_mode', ['mode' => __('readiness.mode.live')]))
|
||||
->assertSee(__('readiness.check_key_mode_conflict', ['mode' => __('readiness.mode.test')]));
|
||||
});
|
||||
|
||||
it('refuses to run a check that is not on the list', function () {
|
||||
// Ein Schlüssel aus dem Browser. Ohne diese Prüfung wäre das ein Weg,
|
||||
// beliebige Klassen aufzurufen.
|
||||
Http::fake();
|
||||
|
||||
Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
|
||||
->test(ReadinessPage::class)
|
||||
->call('runCheck', 'etwas.erfundenes')
|
||||
->assertSet('results', []);
|
||||
});
|
||||
|
||||
/**
|
||||
* Der Wächter dieser Spec.
|
||||
*
|
||||
* Ein künftig hinzugefügtes Zugangsdatum darf nicht still an der Übersicht
|
||||
* vorbeigehen. Genau das war der Befund vom 2026-07-30: eine grüne Testsuite
|
||||
* bei einer Installation, die keinen Host durchinstallieren konnte, weil
|
||||
* niemand die Liste geführt hat.
|
||||
*/
|
||||
it('covers every entry in the secret registry', function () {
|
||||
$covered = collect(Readiness::all())->pluck('key')->implode(' ');
|
||||
|
||||
foreach (array_keys(SecretVault::REGISTRY) as $secret) {
|
||||
// `stripe.secret` → irgendein Prüfschlüssel, der `stripe_secret` enthält.
|
||||
$needle = str_replace('.', '_', $secret);
|
||||
|
||||
// NICHT expect($covered)->toContain($needle, $message) — Pest's
|
||||
// toContain() ist variadic, ein zweites Argument ist ein zweiter
|
||||
// GEFORDERTER Substring, keine eigene Fehlermeldung. Genau diese Falle
|
||||
// dokumentiert tests/Feature/ModalHeightTest.php bereits selbst
|
||||
// ("asserted that the footer contains its own filename, which is why
|
||||
// this failed on a file that was correct") — der Brief-Code für diesen
|
||||
// Test tappte hinein: er scheiterte auf JEDEM Eintrag, auch auf
|
||||
// 'stripe_secret', das längst abgedeckt ist. str_contains() + toBeTrue()
|
||||
// mit Nachricht liefert die Prüfung UND den lesbaren Hinweis.
|
||||
expect(str_contains($covered, $needle))->toBeTrue(
|
||||
"Der Tresor-Eintrag [{$secret}] taucht auf der Bereitschaftsseite nicht auf."
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Fix-Runde, Befund 1: der Auflöser (checkUrl()) ist nur so gut wie seine
|
||||
* Quelle. Neun Prüfschlüssel trugen `tab: 'integrations'` — kein Mitglied von
|
||||
* Integrations::TABS — und landeten deshalb alle auf dem Standard-Reiter
|
||||
* ('services'), unabhängig davon, wo ihr Feld tatsächlich liegt. Dieser Test
|
||||
* hält JEDEN Prüfschlüssel gegen die eine Stelle, an der sein Feld wirklich
|
||||
* steht — nicht nur die neun, die diesmal falsch waren — damit der nächste
|
||||
* falsche Wert genauso durchrutscht wie diese.
|
||||
*/
|
||||
it('sends every check to the tab or page where its field actually lives', function () {
|
||||
$expectedTab = [
|
||||
'billing.stripe_secret' => 'services',
|
||||
'billing.webhook_secret' => 'env',
|
||||
'billing.company_details' => 'company',
|
||||
'billing.invoice_series' => 'company',
|
||||
'billing.catalogue_synced' => 'services',
|
||||
'onboarding.ssh_private_key' => 'platform',
|
||||
'onboarding.secrets_key' => 'env',
|
||||
'onboarding.vpn_config_key' => 'env',
|
||||
'onboarding.wg_hub' => 'platform',
|
||||
'onboarding.datacenter' => 'datacenters',
|
||||
'provisioning.dns_token' => 'services',
|
||||
'provisioning.dns_zone' => 'services',
|
||||
'provisioning.traefik_path' => 'services',
|
||||
'provisioning.usable_host' => 'hosts',
|
||||
'provisioning.vm_template' => 'plans',
|
||||
'provisioning.monitoring_token' => 'services',
|
||||
'delivery.mailer_not_log' => 'mail',
|
||||
'delivery.mailbox' => 'mail',
|
||||
'delivery.mail_templates' => 'templates',
|
||||
'delivery.inbound_mail_password' => 'services',
|
||||
'operation.scheduler' => 'env',
|
||||
'operation.queue_provisioning' => 'env',
|
||||
];
|
||||
|
||||
foreach (Readiness::all() as $check) {
|
||||
// NICHT expect($expectedTab)->toHaveKey($check->key, $message) — genau
|
||||
// dieselbe Falle wie bei toContain() weiter oben: toHaveKey()s zweites
|
||||
// Argument ist der erwartete WERT des Schlüssels, keine Fehlermeldung.
|
||||
expect(array_key_exists($check->key, $expectedTab))->toBeTrue(
|
||||
"Neuer Prüfschlüssel [{$check->key}] hat keinen erwarteten tab-Wert in diesem Test — bitte nachtragen."
|
||||
);
|
||||
|
||||
expect($check->tab)->toBe(
|
||||
$expectedTab[$check->key],
|
||||
"{$check->key} zeigt auf '{$check->tab}', sein Feld liegt aber unter '{$expectedTab[$check->key]}'."
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Fix-Runde, Befund 2: mount() lässt hosts.manage ODER secrets.manage durch,
|
||||
* runCheck() verlangt aber secrets.manage allein. Ein Admin (nur
|
||||
* hosts.manage) erreicht die Seite und sah bislang auch den Knopf — der beim
|
||||
* Drücken 403 geworfen hätte. Ein sichtbarer Knopf, der beim Drücken
|
||||
* scheitert, ist schlimmer als gar keiner.
|
||||
*/
|
||||
it('hides the check button from an operator who could not press it anyway', function () {
|
||||
Livewire::actingAs(Operator::factory()->role('Admin')->create(), 'operator')
|
||||
->test(ReadinessPage::class)
|
||||
->assertDontSee(__('readiness.run_check'));
|
||||
});
|
||||
|
||||
/**
|
||||
* Fix-Runde, Befund 3: die eine Eigenschaft, wegen der diese ganze
|
||||
* Konstruktion existiert — die vier RUNNABLE-Prüfungen laufen NUR auf
|
||||
* Knopfdruck, weil DnsTokenCheck einen echten TXT-Eintrag in die
|
||||
* Produktivzone schreibt — war bislang ungeprüft.
|
||||
*/
|
||||
it('sends nothing over the network on a plain page load', function () {
|
||||
Http::fake();
|
||||
|
||||
Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
|
||||
->test(ReadinessPage::class);
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
<?php
|
||||
|
||||
use App\Models\PlanPrice;
|
||||
use App\Services\Secrets\SecretCipher;
|
||||
use App\Support\OperatingMode;
|
||||
use App\Support\Settings;
|
||||
use App\Support\StripeCatalogueMode;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
|
|
@ -54,6 +56,41 @@ it('files a stripe live key in the live slot and leaves the mode alone', functio
|
|||
expect(OperatingMode::current())->toBe(OperatingMode::Live);
|
||||
});
|
||||
|
||||
/**
|
||||
* Der Katalog, der schon bei Stripe liegt, bekommt seine Herkunft mit.
|
||||
*
|
||||
* Ein Stripe-Produkt und ein Stripe-Preis gehören dem Konto, das sie
|
||||
* ausgestellt hat. Auf dieser Installation kann das nur das Konto sein, das
|
||||
* der eine hinterlegte Schlüssel öffnet — ein zweiter war nie da. Ohne diesen
|
||||
* Nachtrag stünde ein längst abgeglichener Katalog nach der Migration als
|
||||
* „Herkunft unbekannt" da, und die Bereitschaftsseite verlangte einen neuen
|
||||
* Abgleich, den niemand braucht.
|
||||
*/
|
||||
it('files the already synced catalogue under the account that key opens', function () {
|
||||
DB::table('app_secrets')->insert([
|
||||
'key' => 'stripe.secret',
|
||||
'value' => app(SecretCipher::class)->encrypt('sk_test_abc'),
|
||||
'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
PlanPrice::query()->limit(1)->update(['stripe_price_id' => 'price_minted_with_that_key']);
|
||||
|
||||
runSlotMigration();
|
||||
|
||||
expect(StripeCatalogueMode::recorded())->toBe(OperatingMode::Test);
|
||||
});
|
||||
|
||||
it('claims no catalogue where none was ever synced', function () {
|
||||
DB::table('app_secrets')->insert([
|
||||
'key' => 'stripe.secret',
|
||||
'value' => app(SecretCipher::class)->encrypt('sk_test_abc'),
|
||||
'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
|
||||
runSlotMigration();
|
||||
|
||||
expect(StripeCatalogueMode::recorded())->toBeNull();
|
||||
});
|
||||
|
||||
it('files every other credential in the live slot', function () {
|
||||
DB::table('app_secrets')->insert([
|
||||
'key' => 'dns.token',
|
||||
|
|
|
|||
|
|
@ -61,7 +61,11 @@ it('reports the source per slot', function () {
|
|||
$this->vault->put('dns.token', 'live-token', $this->by, OperatingMode::Live);
|
||||
|
||||
expect($this->vault->source('dns.token', OperatingMode::Live))->toBe('stored');
|
||||
expect($this->vault->source('dns.token', OperatingMode::Test))->toBe('none');
|
||||
// NICHT 'none': im Testbetrieb ist genau diese Live-Zeile in Kraft (der
|
||||
// Rückfall zwei Tests weiter oben). 'none' hieße „nichts hinterlegt" über
|
||||
// ein Zugangsdatum, mit dem gerade gearbeitet wird — siehe den Test
|
||||
// „names the live slot as the source…" weiter unten.
|
||||
expect($this->vault->source('dns.token', OperatingMode::Test))->toBe('stored_live');
|
||||
});
|
||||
|
||||
it('forgets one slot without touching the other', function () {
|
||||
|
|
@ -70,6 +74,59 @@ it('forgets one slot without touching the other', function () {
|
|||
|
||||
$this->vault->forget('dns.token', OperatingMode::Test);
|
||||
|
||||
expect($this->vault->source('dns.token', OperatingMode::Test))->toBe('none');
|
||||
// 'stored_live' statt 'stored' ist der Beweis, dass die Testzeile weg ist:
|
||||
// was jetzt im Testplatz gilt, kommt aus dem Live-Platz.
|
||||
expect($this->vault->source('dns.token', OperatingMode::Test))->toBe('stored_live');
|
||||
expect($this->vault->source('dns.token', OperatingMode::Live))->toBe('stored');
|
||||
});
|
||||
|
||||
/**
|
||||
* Der Widerspruch, den das Schluss-Review gemessen hat — und warum er zählt.
|
||||
*
|
||||
* Modus test, `dns.token` nur im `:live`-Platz, `HETZNER_DNS_TOKEN` in der
|
||||
* `.env`: `get()` lieferte den gespeicherten Live-Wert (der Rückfall oben),
|
||||
* `source()` meldete dazu „environment" und `outline()` null. Die Karte in der
|
||||
* Konsole las sich damit als „Aus der Serverdatei" über ein Zugangsdatum, das
|
||||
* gerade aus der Datenbank in Kraft ist — ein Betreiber, der den Token wechseln
|
||||
* will, wird in die `.env` geschickt, wo eine Änderung wirkungslos bleibt.
|
||||
* Genau die Falle, die im Kopfkommentar von OperatingMode als Anlass dieses
|
||||
* ganzen Vorhabens steht, und R19s Attrappe wörtlich: eine Anzeige, die sich
|
||||
* wie eine Auskunft liest, falsch ist, und den Nächsten vom Nachsehen abhält.
|
||||
*
|
||||
* Ein DRITTER Rückgabewert, nicht 'stored': 'stored' blendet den
|
||||
* „Vergessen"-Knopf ein, und der zeigte dann auf den leeren Testplatz.
|
||||
*/
|
||||
it('names the live slot as the source when it is the one in force', function () {
|
||||
config()->set('provisioning.dns.token', 'from-env');
|
||||
$this->vault->put('dns.token', 'live-token-value', $this->by, OperatingMode::Live);
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
|
||||
expect($this->vault->get('dns.token'))->toBe('live-token-value')
|
||||
->and($this->vault->source('dns.token'))->toBe('stored_live');
|
||||
});
|
||||
|
||||
it('shows the outline and the date of the value that is actually in force', function () {
|
||||
// Die zweite Folge desselben Befunds: ohne Umriss fehlt der Block
|
||||
// „Hinterlegt / Geändert" ganz, und die Karte behauptet durch Weglassen,
|
||||
// es liege nichts vor.
|
||||
$this->vault->put('dns.token', 'live-token-value', $this->by, OperatingMode::Live);
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
|
||||
$outline = $this->vault->outline('dns.token');
|
||||
|
||||
expect($outline)->not->toBeNull()
|
||||
->and($outline)->toContain('alue')
|
||||
->and($this->vault->updatedAt('dns.token'))->not->toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the strict entry out of the fallback here too', function () {
|
||||
// Stripe hat keinen Rückfall, in keine Richtung — source() darf ihn
|
||||
// deshalb auch nicht als 'stored_live' anzeigen, sonst widerspräche die
|
||||
// Karte wieder der Kasse.
|
||||
$this->vault->put('stripe.secret', 'sk_live_real', $this->by, OperatingMode::Live);
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
|
||||
expect($this->vault->get('stripe.secret'))->toBeNull()
|
||||
->and($this->vault->source('stripe.secret'))->toBe('none')
|
||||
->and($this->vault->outline('stripe.secret'))->toBeNull();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,120 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\ConfirmSwitchMode;
|
||||
use App\Livewire\Admin\Integrations;
|
||||
use App\Models\Operator;
|
||||
use App\Support\OperatingMode;
|
||||
use Livewire\Livewire;
|
||||
|
||||
// Die Rollen heißen exakt so (Operator::OPERATOR_ROLES), und die Fabrik setzt
|
||||
// sie über ->role(), nicht über eine ->owner()-Abkürzung. Das Testpasswort ist
|
||||
// `passwort-fuer-tests` — OperatorFactory::definition().
|
||||
//
|
||||
// ZWEI PLAN-FEHLER (Brief/Plan wörtlich übernommen, hier korrigiert):
|
||||
//
|
||||
// 1. Livewire::actingAs() ohne den zweiten Parameter meldet nur den
|
||||
// 'web'-Guard an — genau die Falle, die tests/Pest.php oben bereits für
|
||||
// operator()/admin() dokumentiert (§"Shared account helpers").
|
||||
// confirmationGuard() dieser Seite ist 'operator'.
|
||||
// 2. ConfirmsPassword::confirmPassword() nimmt KEIN Argument entgegen — sie
|
||||
// liest die eigene Eigenschaft $confirmablePassword (so gesetzt in jedem
|
||||
// bestehenden Aufrufer, siehe tests/Feature/Admin/IntegrationsPageTest.php:
|
||||
// ->set('confirmablePassword', …)->call('confirmPassword')). Ein Aufruf
|
||||
// als ->call('confirmPassword', 'passwort-fuer-tests') kompiliert (PHP
|
||||
// verwirft überzählige Argumente stillschweigend), setzt die Eigenschaft
|
||||
// aber nie — die Prüfung vergleicht gegen einen leeren String und scheitert.
|
||||
//
|
||||
// Beide Fehler zusammen ließen den zweiten Test bei OperatingMode::Live
|
||||
// stehen, obwohl scheinbar alles aufgerufen wurde: confirmPassword()
|
||||
// scheiterte am Passwortvergleich, switchMode() brach danach in
|
||||
// guardSecrets() mit 403 ab, bevor es je etwas schrieb.
|
||||
it('refuses to switch without the secrets capability', function () {
|
||||
$admin = Operator::factory()->role('Admin')->create(); // hosts.manage, nicht secrets.manage
|
||||
|
||||
Livewire::actingAs($admin, 'operator')
|
||||
->test(Integrations::class)
|
||||
->call('switchMode', 'test')
|
||||
->assertForbidden();
|
||||
|
||||
expect(OperatingMode::current())->toBe(OperatingMode::Live);
|
||||
});
|
||||
|
||||
it('switches when the owner has confirmed their password', function () {
|
||||
$owner = Operator::factory()->role('Owner')->create();
|
||||
|
||||
Livewire::actingAs($owner, 'operator')
|
||||
->test(Integrations::class)
|
||||
->set('confirmablePassword', 'passwort-fuer-tests')
|
||||
->call('confirmPassword')
|
||||
->call('switchMode', 'test');
|
||||
|
||||
expect(OperatingMode::current())->toBe(OperatingMode::Test);
|
||||
});
|
||||
|
||||
it('refuses an unknown mode', function () {
|
||||
// Ein String aus dem Browser. `staging` gibt es nicht, und from() würde
|
||||
// hier eine Ausnahme werfen statt still nichts zu tun.
|
||||
$owner = Operator::factory()->role('Owner')->create();
|
||||
|
||||
Livewire::actingAs($owner, 'operator')
|
||||
->test(Integrations::class)
|
||||
->set('confirmablePassword', 'passwort-fuer-tests')
|
||||
->call('confirmPassword')
|
||||
->call('switchMode', 'staging');
|
||||
|
||||
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'));
|
||||
});
|
||||
Loading…
Reference in New Issue