Merge branch 'main' into claude/cool-sammet-368fee

feature/betriebsmodus^2
nexxo 2026-07-30 14:04:06 +02:00
commit 43d1d55909
26 changed files with 3372 additions and 8 deletions

View File

@ -1 +1 @@
1.3.44
1.3.48

View File

@ -6,6 +6,7 @@ use App\Models\Customer;
use App\Models\InboundMail;
use App\Services\Mail\IngestInboundMail;
use App\Services\Mail\InboundMailbox;
use App\Services\Mail\InboundMailStatus;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
@ -127,6 +128,11 @@ class Inbox extends Component
// explain: a mailbox that was never configured looks exactly like a
// mailbox with no mail in it.
'configured' => app(InboundMailbox::class)->isConfigured(),
// The same line the settings page shows. This is where somebody
// notices nothing is arriving, so this is where "last reached" has
// to be readable — otherwise an empty inbox and a broken mailbox
// look identical.
'status' => app(InboundMailStatus::class)->last(),
]);
}
}

View File

@ -6,6 +6,8 @@ use App\Livewire\Concerns\ConfirmsPassword;
use App\Services\Deployment\UpdateChannel;
use App\Services\Env\EnvFileEditor;
use App\Services\Env\InvalidEnvContentException;
use App\Services\Mail\InboundMailbox;
use App\Services\Mail\InboundMailStatus;
use App\Services\Secrets\SecretVault;
use App\Support\ProvisioningSettings;
use App\Support\Settings;
@ -400,6 +402,32 @@ class Integrations extends Component
abort_unless($this->passwordRecentlyConfirmed(), 403);
}
/**
* Reach the support mailbox now and say what came back.
*
* The one thing an operator wants after typing a host, a user and a
* password: did any of it work. Waiting for the next scheduled run to find
* out is not an answer, and "der Posteingang ist leer" is not one either.
*
* Saved first, deliberately: testing what is on screen while the server
* still holds the previous values would report on a mailbox nobody
* configured. The unsaved form is the question being asked.
*/
public function testInbound(): void
{
$this->guardInfra();
$this->saveInfra();
$check = app(InboundMailbox::class)->check();
app(InboundMailStatus::class)->record($check['ok'], $check['message'], $check['unseen']);
$this->dispatch('notify', message: $check['ok']
? ($check['unseen'] === null
? __('integrations.inbound_ok')
: trans_choice('integrations.inbound_ok_waiting', $check['unseen'], ['count' => $check['unseen']]))
: __('integrations.inbound_failed_'.$check['message']));
}
public function render()
{
$vault = app(SecretVault::class);
@ -458,6 +486,9 @@ class Integrations extends Component
self::TABS,
fn (string $tab) => $tab !== 'env' || $canSecrets,
)),
// When the mailbox was last reached, and what came back. Null until
// somebody — or the scheduler — has asked once.
'inboundStatus' => app(InboundMailStatus::class)->last(),
'canSecrets' => $canSecrets,
'canInfra' => $canInfra,
'unlocked' => $unlocked,

View File

@ -6,6 +6,8 @@ use App\Livewire\Concerns\BuildsRunSteps;
use App\Models\Customer;
use App\Models\Host;
use App\Services\Provisioning\HostCapacity;
use App\Support\AdminArea;
use App\Support\Settings;
use App\Models\Instance;
use App\Models\MonitoringTarget;
use App\Models\ProvisioningRun;
@ -367,6 +369,57 @@ class Overview extends Component
])];
}
// The site being switched off. It is a deliberate state, not a fault —
// but it answers 503 to every visitor who is not on the VPN, and the
// only place that says so is the switch itself, three clicks away on
// another page. Asked as "ich komme nicht auf www.dev", which is exactly
// what it looks like from outside.
if (! Settings::bool('site.public', true)) {
$notices[] = [
'level' => 'warning',
'text' => __('admin.notice.site_hidden'),
'route' => 'admin.settings',
];
}
// MAIL_MAILER=log. Every purpose mailer short-circuits to the log
// transport then — see MailboxTransport::NON_DELIVERING — however well
// the mailboxes are configured, and nothing is delivered to anybody.
//
// Worth its own notice because the failure is SILENT in every direction:
// the queue reports success, no job fails, the console's own mailbox test
// reports success (MailboxTester builds its own transport on purpose), and
// the mail sits in storage/logs. Somebody registering and waiting for a
// verification mail has no way to find that out. Asked exactly that way.
if (in_array(config('mail.default'), ['log', 'array', null], true)) {
$notices[] = [
'level' => 'warning',
'text' => __('admin.notice.mail_not_delivering', ['mailer' => (string) (config('mail.default') ?? 'null')]),
'route' => 'admin.mail',
];
}
// No SITE_HOST. The marketing site is then registered without a
// hostname, so it answers on the PORTAL's host as well — and every
// route() it generates points at APP_URL, which is why a link on the
// site lands on the portal's domain.
//
// Only where the installation is host-separated at all. On a fresh
// checkout nothing is bound to a hostname by design (see
// RestrictAdminHost: "upgrading must not lock anyone out of a system
// that was working"), and a warning there would be furniture — the same
// mistake the capacity notice made before it learned to ask whether
// there is a host at all.
if (AdminArea::isHostBound() && config('admin_access.site_hosts') === []) {
$notices[] = [
'level' => 'warning',
'text' => __('admin.notice.no_site_host', [
'app' => (string) parse_url((string) config('app.url'), PHP_URL_HOST),
]),
'route' => 'admin.integrations',
];
}
return $notices;
}

View File

@ -25,6 +25,22 @@ class FakeMailbox implements InboundMailbox
return $this->configured;
}
/**
* What a test wants the check to answer, if not the obvious.
*
* @var array{ok: bool, message: string, unseen: int|null}|null
*/
public array|null $checkAnswer = null;
public function check(): array
{
return $this->checkAnswer ?? [
'ok' => $this->configured,
'message' => $this->configured ? '' : 'not configured',
'unseen' => $this->configured ? count($this->mails) : null,
];
}
public function fetch(int $limit = 50): array
{
return array_slice($this->mails, 0, $limit);

View File

@ -2,6 +2,7 @@
namespace App\Services\Mail;
use App\Services\Secrets\SecretVault;
use App\Support\ProvisioningSettings;
use Illuminate\Support\Facades\Log;
use RuntimeException;
@ -44,9 +45,20 @@ class ImapMailbox implements InboundMailbox
* Configured means: somebody entered a host, a user and a password.
*
* The first two come from the console (ProvisioningSettings), the password
* from the vault read through config, which SecretVault overrides at
* boot. No separate "enabled" switch to forget: a mailbox with credentials
* IS the switch, and one without them cannot be polled anyway.
* from the VAULT read here, at the point of use. It used to read
* config('services.inbound_mail.password'), which is only ever the .env
* value: this project deliberately does not overlay stored secrets onto
* config at boot (SecretVault's docblock, rule 3). So a password saved in
* the console counted for nothing, isConfigured() stayed false, and the
* inbox went on saying no mailbox was set up while the password sat right
* there on the settings page. Reported exactly that way.
*
* SecretVault::get() falls back to the configured value when nothing is
* stored, so an installation carrying INBOUND_MAIL_PASSWORD in .env keeps
* working unchanged.
*
* No separate "enabled" switch to forget: a mailbox with credentials IS the
* switch, and one without them cannot be polled anyway.
*/
public function isConfigured(): bool
{
@ -54,7 +66,63 @@ class ImapMailbox implements InboundMailbox
return filled($mailbox['host'])
&& filled($mailbox['username'])
&& filled(config('services.inbound_mail.password'));
&& filled($this->password());
}
/** The mailbox password, from the vault, falling back to .env. */
private function password(): string
{
return (string) app(SecretVault::class)->get('inbound_mail.password');
}
/**
* Reach the mailbox and count what is waiting.
*
* Deliberately the same four verbs the fetch uses LOGIN, SELECT, SEARCH
* so a green check means the thing that actually runs would work. A check
* that proves something easier than the real job is worse than none: it was
* exactly that mistake (a test computing its expectation with the code under
* test) the timezone rule was written about.
*
* @return array{ok: bool, message: string, unseen: int|null}
*/
public function check(): array
{
if (! $this->isConfigured()) {
return ['ok' => false, 'message' => 'incomplete', 'unseen' => null];
}
try {
$this->connect();
$unseen = count($this->search());
return ['ok' => true, 'message' => '', 'unseen' => $unseen];
} catch (Throwable $e) {
// The message, not the trace, and never the command: LOGIN carries
// the password and this string is shown on a page.
return ['ok' => false, 'message' => $this->reason($e), 'unseen' => null];
} finally {
$this->disconnect();
}
}
/**
* One line an operator can act on, with nothing secret in it.
*
* IMAP servers answer a failed LOGIN with their own prose, and this class
* puts the command in the exception message which for LOGIN would be the
* password. So the text is matched, never passed through.
*/
private function reason(Throwable $e): string
{
$text = strtolower($e->getMessage());
return match (true) {
str_contains($text, 'could not reach') => 'unreachable',
str_contains($text, 'authenticationfailed') || str_contains($text, 'login') => 'credentials',
str_contains($text, 'nonexistent') || str_contains($text, 'select') => 'folder',
default => 'failed',
};
}
/** @return array<int, FetchedMail> */
@ -147,7 +215,7 @@ class ImapMailbox implements InboundMailbox
$this->command(sprintf(
'LOGIN %s %s',
$this->quote($mailbox['username']),
$this->quote((string) config('services.inbound_mail.password')),
$this->quote($this->password()),
));
$this->command('SELECT '.$this->quote($mailbox['folder']));

View File

@ -0,0 +1,59 @@
<?php
namespace App\Services\Mail;
use App\Support\Settings;
use Illuminate\Support\Carbon;
/**
* When the support mailbox was last reached, and what came back.
*
* A settings row rather than a table: there is exactly one mailbox and exactly
* one last answer, and a table of one row is a table somebody has to prune.
*
* Written by both callers on purpose the operator's test button and the
* scheduled fetch. Otherwise "last checked" would mean "last time somebody
* pressed the button", which is the least interesting of the two: a mailbox
* that stopped answering at three in the morning is what an operator needs to
* see, and nobody presses a button at three in the morning.
*/
class InboundMailStatus
{
private const KEY = 'inbound_mail.last_check';
/** @param int|null $unseen how many unread messages were waiting, where that was asked */
public function record(bool $ok, string $message = '', ?int $unseen = null): void
{
Settings::set(self::KEY, [
'at' => Carbon::now()->toIso8601String(),
'ok' => $ok,
// Never the password, never the whole exception: this ends up on a
// page, and an IMAP error can quote the command it failed on.
'message' => mb_substr($message, 0, 300),
'unseen' => $unseen,
]);
}
/**
* The last answer, or null if nobody has ever asked.
*
* @return array{at: Carbon, ok: bool, message: string, unseen: int|null}|null
*/
public function last(): ?array
{
$row = Settings::get(self::KEY);
if (! is_array($row) || ! isset($row['at'])) {
return null;
}
return [
// Parsed back into a Carbon so the view can put it on the wall clock
// (R19) rather than printing an ISO string at somebody.
'at' => Carbon::parse((string) $row['at']),
'ok' => (bool) ($row['ok'] ?? false),
'message' => (string) ($row['message'] ?? ''),
'unseen' => isset($row['unseen']) ? (int) $row['unseen'] : null,
];
}
}

View File

@ -15,6 +15,20 @@ interface InboundMailbox
/** Is a mailbox configured at all? */
public function isConfigured(): bool;
/**
* Reach the mailbox now and say what happened.
*
* For the button in the console: an operator who has just typed a host, a
* user and a password should not have to wait for the next scheduled run to
* find out whether any of it was right and "the inbox is empty" is not an
* answer to that question.
*
* Never throws. A failed check IS the answer.
*
* @return array{ok: bool, message: string, unseen: int|null}
*/
public function check(): array;
/**
* Messages that have arrived, oldest first.
*

View File

@ -22,7 +22,10 @@ use Illuminate\Support\Facades\Log;
*/
class IngestInboundMail
{
public function __construct(private readonly InboundMailbox $mailbox) {}
public function __construct(
private readonly InboundMailbox $mailbox,
private readonly InboundMailStatus $status,
) {}
/**
* Fetch, store, and flag what was stored.
@ -35,6 +38,18 @@ class IngestInboundMail
return 0;
}
// The scheduled run records its own outcome, so the "last checked" line
// in the console means the last time the mailbox was actually reached —
// not the last time somebody pressed a button. A mailbox that stopped
// answering at three in the morning is the one worth seeing, and nobody
// presses a button at three in the morning.
$check = $this->mailbox->check();
$this->status->record($check['ok'], $check['message'], $check['unseen']);
if (! $check['ok']) {
return 0;
}
$taken = 0;
foreach ($this->mailbox->fetch($limit) as $mail) {

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,382 @@
# Spec — Betriebsmodus (Test/Live) und Betriebsbereitschaft
**Datum:** 2026-07-30
**Status:** entworfen, noch nicht umgesetzt
**Vorgänger-Kontext:** `docs/handoffs/2026-07-30-real-run-handoff.md` §4
---
## 1. Ziel
Zwei Dinge, die zusammengehören:
**A — Betriebsmodus.** Ein Schalter in der Konsole, `test` oder `live`. Jedes
Zugangsdatum im Tresor bekommt zwei Plätze; der aktive Modus entscheidet,
welcher gilt. Ist der Testplatz leer, gilt der Live-Platz — mit **einer**
Ausnahme, siehe §4.
**B — Betriebsbereitschaft.** Eine Seite, die jedes Pflichtfeld dieser
Installation aufführt, seinen Zustand zeigt und sagt, **was kaputtgeht, wenn
es fehlt**. Der Zweck ist genau einer: den Liveserver vollständig befüllen zu
können, bevor der erste Host onboardet wird — statt es nach der Zahlung eines
Kunden zu erfahren.
### Nicht Teil dieses Vorhabens
- **Testdaten-Trennung** (eigene Belegserie, Markierung, Ausschluss aus dem
Umsatz, Aufräumen inkl. VM und DNS). Eigener Entwurf, hängt an diesem hier.
Beschlossen, aber getrennt — dort wird auf einem Host eine VM zerstört, und
das braucht eigene Sorgfalt. Siehe §9.
- Zwei Plätze für die **einfachen Einstellungen** (DNS-Zone, WireGuard-Endpunkt,
Traefik-Pfad). Das sind keine Zugangsdaten, und eine getrennte Testzone wurde
bewusst nicht gewählt.
- Blöcke AD aus dem Handoff (goldene Vorlage, `vmbr0`, Traefik als Systemdienst).
---
## 2. Ausgangsbefund — gemessen, nicht gelesen
Alles hier am 2026-07-30 auf dem Entwicklungsserver nachgemessen.
| Befund | Beleg |
|---|---|
| Der Tresor hat **einen** Platz je Zugangsdatum | `SecretVault::REGISTRY`, fünf Einträge, je ein `config`-Schlüssel |
| Der hinterlegte Stripe-Schlüssel ist ein **Testschlüssel** | Präfix `sk_test_` |
| `STRIPE_WEBHOOK_SECRET` liegt bewusst **nicht** im Tresor | `SecretVault` Kopfkommentar; `StripeWebhookController:23` liest `config()` |
| Es gibt **keine** Bereitschaftsprüfung | Einziges Vorbild: `CompanyProfile::missingForInvoicing()`, benutzt in `Finance` und `NewInvoice` |
| `mail.default` steht auf `log` | Zugangsdaten-Mail landet in der Datei, nicht beim Kunden |
| Zwei Hosts stehen `active` **ohne** `api_token_ref` | `public_ip` `203.0.113.11`/`.21` — RFC 5737, Dokumentationsbereich |
| `CLUPILOT_WG_ENDPOINT` war eine private Adresse | `10.10.90.185:51820` |
| Kein Worker meldet, dass er lebt | Weder Zeitplaner noch `queue-provisioning` hinterlassen eine Spur |
Die letzten vier sind der Grund für diese Spec. Jeder davon ist in der Konsole
heute **unsichtbar**, und jeder bricht die Kette an einer anderen Stelle —
zwei davon erst nach der Zahlung.
---
## 3. Der Schalter
**Speicherort:** `App\Support\Settings`, Schlüssel `platform.mode`, Werte `test`
oder `live`.
Nicht die `.env`. `Settings` ist zwischengespeichert und verwirft den
Zwischenspeicher beim Schreiben — ein Umlegen wirkt sofort, auch in den
Warteschlangen-Prozessen, die seit Stunden laufen. Eine `.env`-Variable bräuchte
einen Containerneustart; genau diese Falle hat am 2026-07-30 die korrigierte
DNS-Zone acht Stunden lang wirkungslos gelassen.
**Gelesen über** `App\Support\OperatingMode::current(): Mode`, damit nirgends
sonst im Code ein String verglichen wird. `Mode` ist ein Enum mit `Test` und
`Live`.
**Vorgabe:** `live`. Eine Installation, die nichts gespeichert hat, soll nicht
in einem Modus stehen, der stillschweigend andere Zugangsdaten benutzt.
**Wer darf umlegen:** `secrets.manage` (Inhaber) **und** bestätigtes Passwort —
dieselbe Sperre wie der Tresor, weil der Wechsel auf Live der Moment ist, ab dem
echtes Geld fließt. Bestätigung im Modal nach **R23**, nie `wire:confirm`.
**Wo:** Integrationsseite, Reiter `services`, **über** allen Zugangsdaten — er
regiert sie. Zusätzlich eine Plakette im Kopf der Konsole (`layouts.admin`),
solange der Modus `test` ist. „Bin ich im Test?" darf keine Frage sein, die man
durch Navigieren beantwortet.
---
## 4. Zwei Plätze je Zugangsdatum
Die kuratierte Liste bleibt, wie sie ist — der Tresor bleibt ein Tresor und kein
Schlüssel/Wert-Speicher. Nur die **Zeile** wird zweigeteilt:
```
stripe.secret:test stripe.secret:live
dns.token:test dns.token:live
monitoring.token:test monitoring.token:live
inbound_mail.password:test inbound_mail.password:live
ssh.private_key:test ssh.private_key:live
```
### Auflösungsregel
`SecretVault::get($key)` löst in dieser Reihenfolge auf:
1. Zeile des **aktiven Modus**
2. leer **und** der Eintrag erlaubt Rückfall → Zeile `live`
3. immer noch leer → `config()`, also `.env` — wie heute
Schritt 2 ist die Regel des Inhabers: für DNS, Monitoring, Postfach und SSH gibt
es nur ein Konto, und zwei Felder mit zwingend gleichem Inhalt wären eine
Attrappe.
Schritt 3 bleibt **einwertig und modusfrei**. Die `.env` ist der Weg für eine
Installation, in der noch gar nichts gespeichert ist; ein zweiter Satz
Variablen dort würde den Anfangszustand verdoppeln, den es nur einmal gibt.
### Stripe ist ausgenommen — in beide Richtungen
Ein Merkmal `'strict' => true` in `REGISTRY`, das **nur** `stripe.secret` trägt.
Bedeutung: kein Rückfall, weder von Test auf Live noch umgekehrt. Fehlt der
Schlüssel des aktiven Modus, liefert `get()` `null`.
Ohne diese Ausnahme führte die Rückfallregel dazu, dass ein Testkauf bei
fehlendem Testschlüssel still den **Live-Schlüssel** benutzt und **echtes Geld**
abbucht, während die Konsole „Testbetrieb aktiv" anzeigt. Das ist genau die
Klasse von stillem Fehler, gegen die der ganze erste Durchlauf gebaut ist.
**Folge für die Aufrufer:** `HttpStripeClient::secret()` wirft eine sprechende
Ausnahme statt einen Aufruf ohne Token abzusetzen. Die Kasse fragt vorher die
Bereitschaftsprüfung und weist die Bestellung mit lesbarem Text ab (§6).
### Der Webhook-Schlüssel
Bleibt in der `.env`. Seine Begründung gilt unverändert: er wird bei **jedem**
eingehenden Zahlungsereignis gelesen, und ein Datenbankproblem würde die
Signaturprüfung still fehlschlagen lassen.
Zwei Variablen, ausgewählt nach Modus:
| Modus | Variable |
|---|---|
| `live` | `STRIPE_WEBHOOK_SECRET` |
| `test` | `STRIPE_WEBHOOK_SECRET_TEST` |
**Der Sonderfall ist bedacht.** Die Auswahl liest den Modus aus der Datenbank —
also aus genau der Quelle, die hier eigentlich vermieden werden sollte. Ist sie
nicht erreichbar, liefert `Settings::get()` seinen Vorgabewert `live` und
schreibt eine Warnung. Die Auswahl fällt damit auf den **Live**-Schlüssel, und
das ist die sichere Richtung: ein echtes Zahlungsereignis wird weiter korrekt
geprüft, ein Testereignis scheitert laut. Der umgekehrte Vorgabewert wäre der
gefährliche.
### Migration — sie rät nicht
- Jede bestehende Tresor-Zeile wandert auf `:live`.
- **Ausnahme `stripe.secret`:** einsortiert am Präfix. `sk_test_` oder
`rk_test_``:test`, alles andere → `:live`.
- **Der Anfangsmodus folgt daraus**, statt aus der Vorgabe: landet der
vorhandene Stripe-Schlüssel im Testplatz, war die Installation erkennbar im
Testbetrieb, und `platform.mode` wird auf `test` gesetzt. Ist gar kein
Stripe-Schlüssel da, bleibt es bei `live`.
Auf dem Entwicklungsserver heißt das: der laufende Aufbau bleibt heil, ohne dass
jemand von Hand nachsortiert.
---
## 5. Betriebsbereitschaft
`App\Support\Readiness` — nach dem Muster von `CompanyProfile::missingForInvoicing()`,
nur über alle Bereiche und mit mehr als einem Feldnamen je Eintrag.
Jede Prüfung liefert: Schlüssel, Beschriftung, **Gruppe** (was sie blockiert),
Schweregrad (`blocking` oder `warning`), einen Satz dazu, was kaputtgeht, und
den Reiter, der sie behebt.
Gruppiert nach dem, was sie blockiert — **nicht** danach, wo der Wert
gespeichert ist. Ein Betreiber, der eine Installation befüllt, denkt in „was
kann ich noch nicht", nicht in „liegt das im Tresor oder in den Einstellungen".
### 5.1 Abrechnung
| Prüfung | Grad | Bricht |
|---|---|---|
| Stripe-Schlüssel des aktiven Modus | blocking | Kein Auftrag entsteht |
| Webhook-Schlüssel des aktiven Modus | blocking | Zahlung wird nie verbucht |
| Firmendaten (`missingForInvoicing()` aufrufen, nicht verdoppeln) | blocking | `IssueInvoice` verweigert, Kunde bekommt keinen Beleg |
| Rechnungsserie je Belegart vorhanden | blocking | Beleg kann keine Nummer ziehen |
| Jede veröffentlichte Paketversion hat beide Stripe-Preise | blocking | Geprüfter EU-Firmenkunde kann nicht bestellen |
Der **Steuersatz** ist bewusst keine Prüfung: `CompanyProfile::taxRate()` hat
einen `.env`-Rückfall und ist nie leer. Die **Einrichtungsgebühr** ebenso nicht:
`0` ist ein gültiger Zustand, bei dem der Satz von der Preisseite verschwindet.
### 5.2 Host-Onboarding
| Prüfung | Grad | Bricht |
|---|---|---|
| `ssh.private_key` | blocking | `EstablishSshTrust`, phpseclib-Parserfehler |
| `SECRETS_KEY` | blocking | Jeder Proxmox-Token unlesbar, keine Bereitstellung |
| `VPN_CONFIG_KEY` | blocking | WireGuard-Konfiguration nicht lesbar |
| `CLUPILOT_WG_HUB_PUBKEY`, `_ENDPOINT`, `_SUBNET` | blocking | Onboarding scheitert am Handshake |
| Mindestens ein Rechenzentrum | blocking | `ValidateHostInput` kommt nicht durch |
### 5.3 Bereitstellung
| Prüfung | Grad | Bricht |
|---|---|---|
| `dns.token` | blocking | A-Record entsteht nicht, Kunde hat keine Adresse |
| DNS-Zone gesetzt | blocking | Name wird in der falschen Zone gesucht |
| Traefik-Pfad, dnsmasq-Verzeichnis | warning | Vorgabe greift, aber ungeprüft |
| **Mindestens ein aktiver Host mit lesbarem `api_token_ref`** | blocking | Auftrag reserviert einen Host, den es nicht gibt — **nach** der Zahlung |
| Jede veröffentlichte Version zeigt auf eine Vorlage, die auf mindestens einem aktiven Host existiert | blocking | `CloneVirtualMachine` stirbt nach der Zahlung |
| `monitoring.token` | warning | Überwachung fehlt, Bereitstellung läuft |
Die vierte Zeile fängt genau die zwei Phantom-Hosts aus §2: `active`, aber ohne
Token. Sie sind heute in der Konsole von einem echten Host nicht zu
unterscheiden.
### 5.4 Zustellung
| Prüfung | Grad | Bricht |
|---|---|---|
| Mailversand geht **nicht** an `log` | blocking | VM läuft, Kunde erfährt nie davon |
| Mindestens ein Postfach | blocking | Kein Absender |
| Mail-Vorlagen vorhanden | warning | Fallback-Text statt gestalteter Mail |
| `inbound_mail.password` | warning | Nur eingehende Post betroffen |
### 5.5 Betrieb — Herzschläge
Zwei Prüfungen, die es heute nirgends gibt und die neu entstehen müssen:
- **Zeitplaner:** eine Aufgabe je Minute schreibt `Settings`-Schlüssel
`heartbeat.scheduler` mit Zeitstempel.
- **Bereitstellungs-Worker:** der Zeitplaner stellt einen winzigen Auftrag in
die `provisioning`-Warteschlange, der `Settings`-Schlüssel
`heartbeat.queue_provisioning` schreibt. Das beweist den **Worker**, nicht nur
den Zeitplaner — die beiden fallen getrennt aus.
Beide in `Settings`, nicht im Zwischenspeicher: ein Herzschlag, der mit dem
Redis-Neustart verschwindet, meldet einen Ausfall, den es nicht gab.
Die Seite meldet einen Herzschlag als veraltet, wenn er älter als fünf Minuten
ist. Damit wird „ohne Worker passiert schlicht nichts, ohne Fehlermeldung"
sichtbar.
### 5.6 Prüfungen mit Knopf
Drei Einträge sind keine Feldabfragen, sondern echte Prüfungen — nach dem
Vorbild von `StripeCheck`, das schon existiert und in `REGISTRY` als `check`
eingetragen ist:
- **`DnsTokenCheck`** — fragt die Hetzner-API, ob der Token die konfigurierte
Zone sieht **und beschreiben darf**. Ein Leserecht-Token sieht in der Konsole
identisch aus wie ein Schreibrecht-Token; das ist der Unterschied zwischen
„gesetzt" und „funktioniert".
- **`WireguardEndpointCheck`** — ein UDP-Port lässt sich von außen nicht sauber
anklopfen, aber ob dort eine private Adresse (RFC 1918) oder eine
Dokumentationsadresse (RFC 5737) steht, ist entscheidbar. Fängt
`10.10.90.185:51820`.
- **`VmTemplateCheck`** — dieselbe Frage, die `VerifyVmTemplate` stellt, nur vor
dem Kauf statt danach.
### 5.7 Darstellung
Eigene Seite `App\Livewire\Admin\Readiness`, Route `/admin/readiness`, in der
Navigation. Auf der Übersicht ein Hinweis, sobald etwas Blockierendes offen ist.
Oben die eine Zeile, auf die es ankommt: **Bereit für Testbetrieb** bzw.
**Bereit für Livebetrieb** — welche Schlüssel Pflicht sind, hängt am Modus.
Jeder Eintrag verlinkt auf den Reiter, der ihn behebt. Icons nach **R18**
(`size-4` in Tabellen, einzeilig).
### 5.8 Die Seite ist kein Tor
Sie berichtet. Die harten Sperren bleiben dort, wo sie hingehören und heute
schon stehen:
- `IssueInvoice` verweigert bei unvollständigen Firmendaten
- `VerifyVmTemplate` verweigert einen Host ohne Vorlage
Dazu kommt **genau eine neue**: die Kasse nimmt keine Bestellung an, wenn dem
aktiven Modus der Stripe-Schlüssel fehlt.
Eine Bereitschaftsseite, die selbst sperrt, wäre eine zweite Wahrheit neben den
Prüfungen im Ablauf — und zwei Quellen für eine Frage sind der Weg, auf dem sie
auseinanderlaufen.
---
## 6. Betroffene Stellen
| Datei | Änderung |
|---|---|
| `app/Support/OperatingMode.php` | **neu** — Enum + `current()` |
| `app/Support/Readiness.php` | **neu** — die Prüfungsliste |
| `app/Services/Secrets/SecretVault.php` | Zeilenschlüssel je Modus, `strict`-Merkmal, Auflösungsregel |
| `app/Services/Stripe/HttpStripeClient.php` | `secret()` wirft bei fehlendem Schlüssel des aktiven Modus |
| `app/Http/Controllers/StripeWebhookController.php` | Webhook-Schlüssel nach Modus |
| `app/Livewire/Admin/Integrations.php` | Umschalter, zwei Felder je Eintrag |
| `app/Livewire/Admin/Readiness.php` | **neu** |
| `app/Livewire/Admin/ConfirmSwitchMode.php` | **neu** — Bestätigung nach R23 |
| `app/Livewire/Admin/Overview.php` | Hinweis bei blockierenden Lücken |
| `app/Services/Dns/DnsTokenCheck.php` | **neu** |
| `app/Services/Vpn/WireguardEndpointCheck.php` | **neu** |
| `app/Services/Proxmox/VmTemplateCheck.php` | **neu** |
| `routes/`, `layouts.admin` | Route, Navigationseintrag, Modus-Plakette |
| Migration | Zeilenschlüssel umstellen, Stripe am Präfix einsortieren, Anfangsmodus setzen |
| Zeitplaner | zwei Herzschläge |
---
## 7. Tests
Nach dem Muster, das dieses Repo für seine Regeln benutzt (`IconLayoutTest`,
`ConfirmInModalTest`, `DisplayTimezoneTest`): die Regel wird **per Test
erzwungen**, nicht per Merksatz.
1. **Je Prüfung ein Test in beide Richtungen** — meldet fehlend, wenn es fehlt;
meldet erfüllt, wenn es da ist.
2. **Jeder `REGISTRY`-Eintrag muss auf der Bereitschaftsseite auftauchen.**
Damit kann ein künftig hinzugefügtes Zugangsdatum nicht still an der
Übersicht vorbeigehen.
3. **Rückfall Test → Live** greift für die nicht-strikten Einträge.
4. **Stripe fällt nicht zurück**, in beide Richtungen geprüft — der wichtigste
Test dieser Spec, weil sein Ausfall echtes Geld kostet.
5. **Die Kasse weist ab**, wenn dem aktiven Modus der Stripe-Schlüssel fehlt.
6. **Migration:** ein `sk_test_` landet im Testplatz und setzt den Modus auf
`test`; ein `sk_live_` landet im Live-Platz und lässt `live` stehen.
7. **Webhook-Auswahl:** bei nicht erreichbaren Einstellungen wird der
Live-Schlüssel benutzt, nicht der Test-Schlüssel.
8. **Kein `wire:confirm`** im neuen Modal (R23 gilt schon repoweit).
---
## 8. Reihenfolge
1. `OperatingMode` + Migration + `SecretVault`-Auflösung (Kern, ohne Oberfläche)
2. Stripe-Ausnahme + Kassensperre + Webhook-Auswahl
3. `Readiness` mit den reinen Feldprüfungen
4. Die drei Prüfungen mit Knopf
5. Herzschläge
6. Oberfläche: Umschalter, Bereitschaftsseite, Plakette, Übersichts-Hinweis
Schritt 12 ist der Teil, der falsch sein kann und Geld kostet. Schritt 36 ist
Fleißarbeit.
---
## 9. Risiken
- **Der Rückfall ist bequem und gefährlich.** Für vier von fünf Einträgen
richtig, für Stripe tödlich. Die `strict`-Ausnahme ist die einzige Zeile
dieser Spec, deren Ausfall echtes Geld kostet — sie gehört in den Test, nicht
in einen Kommentar.
- **Der Modus in der Datenbank** ist eine neue Abhängigkeit für die
Webhook-Prüfung, die bisher bewusst keine hatte. Entschärft durch den
Vorgabewert `live` bei Nichterreichbarkeit (§4), aber es bleibt eine
Abhängigkeit, die vorher nicht da war.
- **Die Bereitschaftsseite kann Vollständigkeit vortäuschen.** Sie prüft, was
jemand aufgeschrieben hat. Was niemand aufschreibt, bleibt unsichtbar — und
genau das war der Befund vom 2026-07-30: eine grüne Testsuite bei einer
Installation, die keinen Host durchinstallieren konnte. Deshalb Test 2, der
wenigstens für Zugangsdaten erzwingt, dass nichts vergessen wird.
---
## 10. Folgepunkte
- **Testdaten-Trennung** (Teil C, beschlossen): Markierung an Auftrag, Vertrag,
Beleg, Instanz und Kunde; eigene Rechnungsserie je Belegart für den
Testbetrieb, damit die lückenlose `RE`-Serie sauber bleibt und Testbelege
löschbar sind; Ausschluss aus Umsatz, Dashboard und Finanzansicht; ein
Aufräumen, das die ganze Kette entfernt — **einschließlich der VMs auf dem
Host und ihrer DNS-Einträge**. `EndInstanceService` nimmt Route und Eintrag
weg, rührt die VM aber ausdrücklich nicht an; `ProxmoxClient::deleteVm()`
existiert, der Aufrufer fehlt.
- Die zwei Phantom-Hosts (`pve-fsn-1`, `pve-hel-1`) aus dem Pool nehmen. Die
Bereitschaftsseite macht sie sichtbar, entfernt sie aber nicht.
- Idempotenz-Schlüssel im Stripe-Abgleich deckt die Metadaten nicht ab —
eigener Folgepunkt, am 2026-07-30 als Aufgabe abgelegt.

View File

@ -88,6 +88,9 @@ return [
],
'notice' => [
'mail_not_delivering' => 'MAIL_MAILER steht auf „:mailer“ — es wird keine E-Mail zugestellt, alles landet in storage/logs. Auch der Postfach-Test meldet trotzdem Erfolg, weil er seinen eigenen Weg nimmt.',
'site_hidden' => 'Website und Kundenbereich sind ausgeblendet — Besucher außerhalb des VPN bekommen die Platzhalterseite (503). Umschalten unter Einstellungen → Installation.',
'no_site_host' => 'SITE_HOST ist nicht gesetzt. Die Website antwortet dadurch auch auf :app, und Links auf ihr zeigen dorthin statt auf den eigenen Namen.',
'no_capacity' => 'Kein Host hat noch Platz für „:plan“ — das Paket braucht :needs GB, der freieste Host hat :largest GB. Eine bezahlte Bestellung dafür würde bei der Reservierung scheitern.',
'failed_runs' => ':n fehlgeschlagene Bereitstellung(en).',
'host_error' => 'Host :host meldet einen Fehler.',

View File

@ -8,6 +8,9 @@ return [
'fetch_now' => 'Jetzt abrufen',
'fetched' => '{1} :count neue Nachricht|[2,*] :count neue Nachrichten',
'nothing_new' => 'Nichts Neues im Postfach.',
'never_checked' => 'Das Postfach wurde noch nie abgerufen — der nächste Lauf ist in wenigen Minuten.',
'last_ok' => 'Postfach zuletzt erreicht: :when (:ago)',
'last_failed' => 'Postfach zuletzt nicht erreichbar: :when (:ago).',
'show_archived' => 'Erledigte zeigen',
'show_open' => 'Offene zeigen',

View File

@ -88,6 +88,17 @@ return [
'inbound_port_hint' => '993 ist IMAP über TLS. Alles andere läuft unverschlüsselt — dieser Zugang trägt ein Passwort und die Worte Ihrer Kunden.',
'inbound_username' => 'Postfach',
'inbound_folder' => 'Ordner',
'inbound_test' => 'Verbindung prüfen',
'inbound_never' => 'Noch nie geprüft.',
'inbound_last_ok' => 'Zuletzt erreichbar: :when (:ago)',
'inbound_last_failed' => 'Zuletzt fehlgeschlagen: :when (:ago)',
'inbound_ok' => 'Postfach erreichbar.',
'inbound_ok_waiting' => '{0} Postfach erreichbar, nichts Ungelesenes.|{1} Postfach erreichbar, :count ungelesene Nachricht.|[2,*] Postfach erreichbar, :count ungelesene Nachrichten.',
'inbound_failed_incomplete' => 'Es fehlen noch Angaben: Server, Postfach oder Passwort.',
'inbound_failed_unreachable' => 'Der Server ist nicht erreichbar. Stimmen Adresse und Port?',
'inbound_failed_credentials' => 'Der Server hat die Anmeldung abgelehnt. Stimmen Postfach und Passwort?',
'inbound_failed_folder' => 'Anmeldung hat funktioniert, den Ordner gibt es aber nicht.',
'inbound_failed_failed' => 'Die Verbindung ist fehlgeschlagen. Näheres steht im Log.',
// Die Reiter. Getrennt nach dem, wofür ein Abschnitt da ist — nicht danach,
// welcher Speichermechanismus einen Wert zufällig hält.

View File

@ -88,6 +88,9 @@ return [
],
'notice' => [
'mail_not_delivering' => 'MAIL_MAILER is set to ":mailer" — no mail is delivered to anybody, it all goes to storage/logs. The mailbox test still reports success, because it builds its own transport.',
'site_hidden' => 'The website and the customer portal are hidden — a visitor outside the VPN gets the placeholder page (503). The switch is under Settings → Installation.',
'no_site_host' => 'SITE_HOST is not set. The website therefore also answers on :app, and links on it point there instead of at its own name.',
'no_capacity' => 'No host has room for “:plan” any more — the package needs :needs GB and the roomiest host has :largest GB. A paid order for it would fail at reservation.',
'failed_runs' => ':n failed provisioning run(s).',
'host_error' => 'Host :host reports an error.',

View File

@ -8,6 +8,9 @@ return [
'fetch_now' => 'Fetch now',
'fetched' => '{1} :count new message|[2,*] :count new messages',
'nothing_new' => 'Nothing new in the mailbox.',
'never_checked' => 'The mailbox has never been fetched yet — the next run is a few minutes away.',
'last_ok' => 'Mailbox last reached: :when (:ago)',
'last_failed' => 'Mailbox last unreachable: :when (:ago).',
'show_archived' => 'Show filed',
'show_open' => 'Show open',

View File

@ -88,6 +88,17 @@ return [
'inbound_port_hint' => '993 is IMAP over TLS. Anything else is unencrypted — and this connection carries a password and your customers own words.',
'inbound_username' => 'Mailbox',
'inbound_folder' => 'Folder',
'inbound_test' => 'Test the connection',
'inbound_never' => 'Never checked.',
'inbound_last_ok' => 'Last reached: :when (:ago)',
'inbound_last_failed' => 'Last failed: :when (:ago)',
'inbound_ok' => 'Mailbox reachable.',
'inbound_ok_waiting' => '{0} Mailbox reachable, nothing unread.|{1} Mailbox reachable, :count unread message.|[2,*] Mailbox reachable, :count unread messages.',
'inbound_failed_incomplete' => 'Something is still missing: server, mailbox or password.',
'inbound_failed_unreachable' => 'The server cannot be reached. Are the address and port right?',
'inbound_failed_credentials' => 'The server refused the sign-in. Are the mailbox and password right?',
'inbound_failed_folder' => 'The sign-in worked, but that folder does not exist.',
'inbound_failed_failed' => 'The connection failed. There is more in the log.',
// The tabs. Split by what a section is for — not by which storage
// mechanism happens to hold a given value.

View File

@ -15,7 +15,15 @@
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<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>
{{-- The NAME OF THE SERVER VARIABLE, not the internal registry key.
It used to print `stripe.secret` an identifier from
SecretVault::REGISTRY that means nothing on this page and is not
something an operator can type anywhere. The env name is: it is
what stands in the server file, it is what the .env tab of this
same page lists, and when the badge beside this says "Aus der
Serverdatei" it is the answer to "which line?". --}}
<p class="mt-0.5 font-mono text-xs text-muted">{{ $entry['envKey'] }}</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'

View File

@ -24,6 +24,31 @@
</x-ui.alert>
@endif
{{-- When the mailbox was last reached. Shown whether or not anything came of
it: an empty inbox and a mailbox that stopped answering look exactly the
same, and the difference is this line. --}}
@if ($configured)
<p @class([
'flex flex-wrap items-center gap-1.5 text-xs',
'text-muted' => $status === null || $status['ok'],
'text-danger' => $status !== null && ! $status['ok'],
])>
@if ($status === null)
{{ __('inbox.never_checked') }}
@else
<x-ui.icon :name="$status['ok'] ? 'check' : 'alert-triangle'" class="size-3.5" />
{{-- R19: stored in UTC, read on the wall clock. --}}
{{ __($status['ok'] ? 'inbox.last_ok' : 'inbox.last_failed', [
'when' => $status['at']->local()->isoFormat('D. MMM YYYY, HH:mm'),
'ago' => $status['at']->diffForHumans(),
]) }}
@if (! $status['ok'])
<a href="{{ route('admin.integrations') }}" class="font-semibold underline">{{ __('inbox.to_integrations') }}</a>
@endif
@endif
</p>
@endif
@if ($mails->isEmpty())
<div class="rounded-lg border border-line bg-surface p-10 text-center shadow-xs animate-rise">
<p class="text-sm text-muted">{{ $archived ? __('inbox.empty_archived') : __('inbox.empty') }}</p>

View File

@ -125,6 +125,35 @@
@if ($canInfra)<hr class="border-line" />@endif
<x-admin.secret-field :entry="$entries['inbound_mail.password']" :unlocked="$unlocked" />
@endif
{{-- The one thing an operator wants after typing all of it: did any
of it work. The button saves first, because testing what is on
screen while the server still holds the old values would report
on a mailbox nobody configured. --}}
@if ($canInfra)
<div class="flex flex-wrap items-center gap-3 border-t border-line pt-4">
<x-ui.button wire:click="testInbound" variant="secondary" size="sm"
wire:loading.attr="disabled" wire:target="testInbound">
<x-ui.icon name="plug" class="size-4" />{{ __('integrations.inbound_test') }}
</x-ui.button>
{{-- Always said, including "never": a line that appears only
after a success leaves an operator unable to tell a
mailbox that has not been checked from one that is fine.
R19: stored in UTC, read on the wall clock. --}}
@if ($inboundStatus === null)
<p class="text-xs text-muted">{{ __('integrations.inbound_never') }}</p>
@else
<p @class(['flex items-center gap-1.5 text-xs', 'text-success' => $inboundStatus['ok'], 'text-danger' => ! $inboundStatus['ok']])>
<x-ui.icon :name="$inboundStatus['ok'] ? 'check' : 'alert-triangle'" class="size-3.5" />
{{ __($inboundStatus['ok'] ? 'integrations.inbound_last_ok' : 'integrations.inbound_last_failed', [
'when' => $inboundStatus['at']->local()->isoFormat('D. MMM YYYY, HH:mm'),
'ago' => $inboundStatus['at']->diffForHumans(),
]) }}
</p>
@endif
</div>
@endif
</div>
@endif

View File

@ -4,6 +4,19 @@
<p class="mt-1 text-sm text-muted">{{ __('mail_settings.subtitle') }}</p>
</div>
{{-- The one thing that makes every mailbox on this page decorative. Said
here as well as on the front page, because this is where somebody comes
to find out why no mail arrived and the test button below reports
success regardless, since MailboxTester builds its own transport on
purpose (a check that honoured MAIL_MAILER=log would report success
while writing to a file). --}}
@if (in_array(config('mail.default'), ['log', 'array', null], true))
<x-ui.alert variant="warning">
{{ __('admin.notice.mail_not_delivering', ['mailer' => (string) (config('mail.default') ?? 'null')]) }}
</x-ui.alert>
@endif
@if (! $usable)
<x-ui.alert variant="warning">{{ __('mail_settings.no_key') }}</x-ui.alert>
@endif

View File

@ -25,6 +25,12 @@ it('reports an empty estate as empty, not as a going concern', function () {
// console still claimed 42 customers, 39 instances, 4 hosts and €7,842 a
// month. The figures are asserted directly rather than searched for in the
// markup — "42" also occurs inside Livewire's own component ids.
//
// The mailer is pinned to a delivering one: phpunit.xml forces
// MAIL_MAILER=array so a test suite cannot send mail, and the console
// rightly warns that nothing is being delivered. That warning is about the
// ENVIRONMENT, not about the estate, and this test is about an empty estate.
config()->set('mail.default', 'smtp');
Livewire::actingAs(operator('Owner'), 'operator')
->test(Overview::class)
->assertViewHas('kpis', function (array $kpis) {
@ -107,6 +113,10 @@ it('states monthly revenue off the contracts, with a yearly term divided', funct
});
it('raises a notice only when something is actually wrong', function () {
// See above: the test suite deliberately delivers no mail, and the console
// says so. Pinned here so "clean" means an estate with nothing wrong in it.
config()->set('mail.default', 'smtp');
$clean = Livewire::actingAs(operator('Owner'), 'operator')->test(Overview::class);
$clean->assertSee(__('admin.systems_ok'));

View File

@ -9,6 +9,7 @@ use App\Services\Mail\FetchedMail;
use App\Services\Mail\IngestInboundMail;
use App\Services\Mail\InboundMailbox;
use App\Services\Mail\MailParser;
use Illuminate\Support\Facades\File;
use Livewire\Livewire;
/**
@ -332,3 +333,108 @@ it('keeps the inbox to operators who may see customers', function () {
->test(Inbox::class)
->assertForbidden();
});
// ---- Configured means configured, and the check that says so ----
it('counts a password from the vault as configured', function () {
// The bug this closes: isConfigured() read config('services.inbound_mail
// .password'), which is only ever the .env value — this project deliberately
// does not overlay stored secrets onto config at boot. So a password saved in
// the console counted for nothing, and the inbox went on saying no mailbox
// was set up while the password sat right there on the settings page.
App\Support\Settings::set('inbound_mail.host', 'mail.example.test');
App\Support\Settings::set('inbound_mail.username', 'support@example.test');
config()->set('services.inbound_mail.password', null);
$mailbox = new App\Services\Mail\ImapMailbox;
expect($mailbox->isConfigured())->toBeFalse();
app(App\Services\Secrets\SecretVault::class)->put('inbound_mail.password', 'geheim', operator('Owner'));
expect($mailbox->isConfigured())->toBeTrue();
});
it('still takes the password out of the environment where that is where it is', function () {
// An installation carrying INBOUND_MAIL_PASSWORD keeps working: the vault
// falls back to the configured value when nothing is stored.
App\Support\Settings::set('inbound_mail.host', 'mail.example.test');
App\Support\Settings::set('inbound_mail.username', 'support@example.test');
config()->set('services.inbound_mail.password', 'aus-der-env');
expect((new App\Services\Mail\ImapMailbox)->isConfigured())->toBeTrue();
});
it('records when the mailbox was last reached, and what came back', function () {
$status = app(App\Services\Mail\InboundMailStatus::class);
expect($status->last())->toBeNull();
$status->record(true, '', 3);
$last = $status->last();
expect($last['ok'])->toBeTrue()
->and($last['unseen'])->toBe(3)
// A Carbon, so the view can put it on the wall clock rather than
// printing an ISO string at somebody (R19).
->and($last['at'])->toBeInstanceOf(Illuminate\Support\Carbon::class);
});
it('records the scheduled run too, not only the button', function () {
// "Last checked" has to mean the last time the mailbox was actually reached.
// A mailbox that stopped answering at three in the morning is the one worth
// seeing, and nobody presses a button at three in the morning.
$this->mailbox->mails = [fetched()];
app(IngestInboundMail::class)();
expect(app(App\Services\Mail\InboundMailStatus::class)->last())->not->toBeNull();
});
it('takes nothing in when the mailbox cannot be reached, and says so', function () {
// Fetching against a mailbox that just refused the login would report
// "nothing new", which is true and useless.
$this->mailbox->mails = [fetched()];
$this->mailbox->checkAnswer = ['ok' => false, 'message' => 'credentials', 'unseen' => null];
expect(app(IngestInboundMail::class)())->toBe(0)
->and(InboundMail::query()->count())->toBe(0);
$last = app(App\Services\Mail\InboundMailStatus::class)->last();
expect($last['ok'])->toBeFalse()
->and($last['message'])->toBe('credentials');
});
it('tests the connection from the settings page and shows when it last answered', function () {
$page = Livewire::actingAs(operator('Owner'), 'operator')
->test(App\Livewire\Admin\Integrations::class)
->set('inboundHost', 'mail.example.test')
->set('inboundUsername', 'support@example.test')
->call('testInbound');
$page->assertDispatched('notify');
// And the page now carries the line, whichever way it went.
Livewire::actingAs(operator('Owner'), 'operator')
->test(App\Livewire\Admin\Integrations::class)
->assertViewHas('inboundStatus', fn ($status) => $status !== null);
});
it('never puts the password or the command into the message it shows', function () {
// IMAP servers answer a failed LOGIN with their own prose, and this class
// puts the failing command in the exception — which for LOGIN is the
// password. The reasons are matched, never passed through.
$reasons = ['incomplete', 'unreachable', 'credentials', 'folder', 'failed'];
foreach ($reasons as $reason) {
expect(__('integrations.inbound_failed_'.$reason))->not->toBe('integrations.inbound_failed_'.$reason);
}
$code = File::get(app_path('Services/Mail/ImapMailbox.php'));
// The reason() method returns one of the fixed words above, and nothing
// derived from the exception text.
expect($code)->toContain("return match (true) {")
->and($code)->not->toContain('$e->getMessage()]');
});

View File

@ -476,3 +476,31 @@ it('saves .env once the page receives the confirmed event', function () {
expect(File::get($path))->toBe("CONFIRMED=value\n");
});
it('names the server variable under each credential, not the internal key', function () {
// It printed `stripe.secret` — an identifier out of SecretVault::REGISTRY
// that means nothing on a settings page and is not something an operator can
// type anywhere. The env name is what stands in the server file, what the
// .env tab of this same page lists, and the answer to "which line?" when the
// badge says the value comes from there.
$page = Livewire::actingAs(operator('Owner'), 'operator')->test(Integrations::class);
$page->assertSee('STRIPE_SECRET')
->assertSee('INBOUND_MAIL_PASSWORD')
->assertSee('HETZNER_DNS_TOKEN')
->assertSee('MONITORING_API_TOKEN');
// Not asserted by absence from the HTML: the key legitimately stays in
// wire:key and in the modal arguments, where it is an identifier nobody
// reads. What must not happen is printing it AS TEXT under the label.
$component = Illuminate\Support\Facades\File::get(
resource_path('views/components/admin/secret-field.blade.php')
);
$withoutComments = (string) preg_replace('/\{\{--.*?--\}\}/s', '', $component);
// Between tags, i.e. as a text node — the key stays inside attributes
// (wire:key, wire:click, the modal arguments), where it is an identifier
// nobody reads and removing it would break the page.
expect($withoutComments)->toMatch('/>\s*\{\{ \$entry\[.envKey.\] \}\}\s*</')
->and($withoutComments)->not->toMatch('/>\s*\{\{ \$entry\[.key.\] \}\}\s*</');
});

View File

@ -256,3 +256,94 @@ it('names the status in words rather than printing the key at it', function () {
->assertDontSee('customers.status.')
->assertDontSee('support.status.');
});
// ---- Two things the console owed the operator ----
it('says on the front page that the site is switched off', function () {
// Asked as "ich komme nicht auf www.dev". The site answers 503 to every
// visitor who is not on the VPN, which is exactly what a broken deployment
// looks like from outside — and the only place that said so was the switch
// itself, on another page.
App\Support\Settings::set('site.public', false);
Livewire::actingAs(operator('Owner'), 'operator')
->test(Overview::class)
->assertSee(__('admin.notice.site_hidden'))
// And it leads to the switch.
->assertSee(route('admin.settings'), false);
App\Support\Settings::set('site.public', true);
Livewire::actingAs(operator('Owner'), 'operator')
->test(Overview::class)
->assertDontSee(__('admin.notice.site_hidden'));
});
it('says when the website has no hostname of its own', function () {
// Without SITE_HOST the marketing site is registered without a hostname, so
// it answers on the PORTAL's host too — and every route() it generates
// points at APP_URL. That is why a link on the site lands on app.…, which
// was the other half of the same report.
// Only fires where the installation is host-separated at all: on a fresh
// checkout nothing is bound, and a warning there would be furniture.
config()->set('admin_access.hosts', ['admin.dev.example.test']);
config()->set('admin_access.site_hosts', []);
config()->set('app.url', 'https://app.dev.example.test');
Livewire::actingAs(operator('Owner'), 'operator')
->test(Overview::class)
->assertSee('app.dev.example.test');
config()->set('admin_access.site_hosts', ['www.dev.example.test']);
Livewire::actingAs(operator('Owner'), 'operator')
->test(Overview::class)
->assertDontSee(__('admin.notice.no_site_host', ['app' => 'app.dev.example.test']));
});
it('says when no mail is being delivered at all', function () {
// The report: "ich versuche mich zu registrieren, erhalte aber keine Verify-
// Mail, auch nicht beim erneuten Senden". MAIL_MAILER=log short-circuits
// every purpose mailer to the log transport (MailboxTransport::
// NON_DELIVERING) however well the mailboxes are configured — and the
// failure is silent in every direction: the queue reports success, no job
// fails, and the console's own mailbox test reports success too because it
// builds its own transport on purpose. Nothing on any page said so.
config()->set('mail.default', 'log');
Livewire::actingAs(operator('Owner'), 'operator')
->test(Overview::class)
->assertSee(__('admin.notice.mail_not_delivering', ['mailer' => 'log']))
->assertSee(route('admin.mail'), false);
// And on the page somebody actually opens to find out why.
Livewire::actingAs(operator('Owner'), 'operator')
->test(App\Livewire\Admin\Mail::class)
->assertSee(__('admin.notice.mail_not_delivering', ['mailer' => 'log']));
});
it('stays quiet once mail really goes out', function () {
config()->set('mail.default', 'smtp');
Livewire::actingAs(operator('Owner'), 'operator')
->test(Overview::class)
->assertDontSee(__('admin.notice.mail_not_delivering', ['mailer' => 'log']));
});
it('names every mailer that swallows mail, not only the log one', function () {
// array and an unset default do the same thing, and the transport already
// treats all three alike. A notice that only knew about 'log' would be
// silent in the two cases nobody thinks to check.
$swallowing = (new ReflectionClass(App\Mail\Transport\MailboxTransport::class))
->getConstant('NON_DELIVERING');
expect($swallowing)->toBe(['log', 'array', null]);
foreach (['array', null] as $mailer) {
config()->set('mail.default', $mailer);
Livewire::actingAs(operator('Owner'), 'operator')
->test(Overview::class)
->assertSee(__('admin.notice.mail_not_delivering', ['mailer' => $mailer ?? 'null']));
}
});

View File

@ -1044,6 +1044,14 @@ it('marks the host active on completion', function () {
});
it('gives the host a name that points at the tunnel, not at its public address', function () {
// The zone is pinned here rather than read from the machine's own .env. It
// used to assert the literal clupilot.com, which was true until somebody
// set CLUPILOT_DNS_ZONE to clupilot.cloud on their installation — and then
// this test failed on a correct configuration. What it is about is the
// SHAPE of the name and which address it carries, not which domain this
// particular box is configured for.
config()->set('provisioning.dns.zone', 'clupilot.com');
$s = fakeServices();
$host = Host::factory()->active()->create([
'datacenter' => 'fsn',