Merge branch 'main' into claude/cool-sammet-368fee
commit
43d1d55909
|
|
@ -6,6 +6,7 @@ use App\Models\Customer;
|
||||||
use App\Models\InboundMail;
|
use App\Models\InboundMail;
|
||||||
use App\Services\Mail\IngestInboundMail;
|
use App\Services\Mail\IngestInboundMail;
|
||||||
use App\Services\Mail\InboundMailbox;
|
use App\Services\Mail\InboundMailbox;
|
||||||
|
use App\Services\Mail\InboundMailStatus;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
use Livewire\Attributes\Url;
|
use Livewire\Attributes\Url;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
@ -127,6 +128,11 @@ class Inbox extends Component
|
||||||
// explain: a mailbox that was never configured looks exactly like a
|
// explain: a mailbox that was never configured looks exactly like a
|
||||||
// mailbox with no mail in it.
|
// mailbox with no mail in it.
|
||||||
'configured' => app(InboundMailbox::class)->isConfigured(),
|
'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(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ use App\Livewire\Concerns\ConfirmsPassword;
|
||||||
use App\Services\Deployment\UpdateChannel;
|
use App\Services\Deployment\UpdateChannel;
|
||||||
use App\Services\Env\EnvFileEditor;
|
use App\Services\Env\EnvFileEditor;
|
||||||
use App\Services\Env\InvalidEnvContentException;
|
use App\Services\Env\InvalidEnvContentException;
|
||||||
|
use App\Services\Mail\InboundMailbox;
|
||||||
|
use App\Services\Mail\InboundMailStatus;
|
||||||
use App\Services\Secrets\SecretVault;
|
use App\Services\Secrets\SecretVault;
|
||||||
use App\Support\ProvisioningSettings;
|
use App\Support\ProvisioningSettings;
|
||||||
use App\Support\Settings;
|
use App\Support\Settings;
|
||||||
|
|
@ -400,6 +402,32 @@ class Integrations extends Component
|
||||||
abort_unless($this->passwordRecentlyConfirmed(), 403);
|
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()
|
public function render()
|
||||||
{
|
{
|
||||||
$vault = app(SecretVault::class);
|
$vault = app(SecretVault::class);
|
||||||
|
|
@ -458,6 +486,9 @@ class Integrations extends Component
|
||||||
self::TABS,
|
self::TABS,
|
||||||
fn (string $tab) => $tab !== 'env' || $canSecrets,
|
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,
|
'canSecrets' => $canSecrets,
|
||||||
'canInfra' => $canInfra,
|
'canInfra' => $canInfra,
|
||||||
'unlocked' => $unlocked,
|
'unlocked' => $unlocked,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ use App\Livewire\Concerns\BuildsRunSteps;
|
||||||
use App\Models\Customer;
|
use App\Models\Customer;
|
||||||
use App\Models\Host;
|
use App\Models\Host;
|
||||||
use App\Services\Provisioning\HostCapacity;
|
use App\Services\Provisioning\HostCapacity;
|
||||||
|
use App\Support\AdminArea;
|
||||||
|
use App\Support\Settings;
|
||||||
use App\Models\Instance;
|
use App\Models\Instance;
|
||||||
use App\Models\MonitoringTarget;
|
use App\Models\MonitoringTarget;
|
||||||
use App\Models\ProvisioningRun;
|
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;
|
return $notices;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,22 @@ class FakeMailbox implements InboundMailbox
|
||||||
return $this->configured;
|
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
|
public function fetch(int $limit = 50): array
|
||||||
{
|
{
|
||||||
return array_slice($this->mails, 0, $limit);
|
return array_slice($this->mails, 0, $limit);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Services\Mail;
|
namespace App\Services\Mail;
|
||||||
|
|
||||||
|
use App\Services\Secrets\SecretVault;
|
||||||
use App\Support\ProvisioningSettings;
|
use App\Support\ProvisioningSettings;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
@ -44,9 +45,20 @@ class ImapMailbox implements InboundMailbox
|
||||||
* Configured means: somebody entered a host, a user and a password.
|
* Configured means: somebody entered a host, a user and a password.
|
||||||
*
|
*
|
||||||
* The first two come from the console (ProvisioningSettings), the password
|
* The first two come from the console (ProvisioningSettings), the password
|
||||||
* from the vault — read through config, which SecretVault overrides at
|
* from the VAULT — read here, at the point of use. It used to read
|
||||||
* boot. No separate "enabled" switch to forget: a mailbox with credentials
|
* config('services.inbound_mail.password'), which is only ever the .env
|
||||||
* IS the switch, and one without them cannot be polled anyway.
|
* 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
|
public function isConfigured(): bool
|
||||||
{
|
{
|
||||||
|
|
@ -54,7 +66,63 @@ class ImapMailbox implements InboundMailbox
|
||||||
|
|
||||||
return filled($mailbox['host'])
|
return filled($mailbox['host'])
|
||||||
&& filled($mailbox['username'])
|
&& 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> */
|
/** @return array<int, FetchedMail> */
|
||||||
|
|
@ -147,7 +215,7 @@ class ImapMailbox implements InboundMailbox
|
||||||
$this->command(sprintf(
|
$this->command(sprintf(
|
||||||
'LOGIN %s %s',
|
'LOGIN %s %s',
|
||||||
$this->quote($mailbox['username']),
|
$this->quote($mailbox['username']),
|
||||||
$this->quote((string) config('services.inbound_mail.password')),
|
$this->quote($this->password()),
|
||||||
));
|
));
|
||||||
|
|
||||||
$this->command('SELECT '.$this->quote($mailbox['folder']));
|
$this->command('SELECT '.$this->quote($mailbox['folder']));
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,20 @@ interface InboundMailbox
|
||||||
/** Is a mailbox configured at all? */
|
/** Is a mailbox configured at all? */
|
||||||
public function isConfigured(): bool;
|
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.
|
* Messages that have arrived, oldest first.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,10 @@ use Illuminate\Support\Facades\Log;
|
||||||
*/
|
*/
|
||||||
class IngestInboundMail
|
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.
|
* Fetch, store, and flag what was stored.
|
||||||
|
|
@ -35,6 +38,18 @@ class IngestInboundMail
|
||||||
return 0;
|
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;
|
$taken = 0;
|
||||||
|
|
||||||
foreach ($this->mailbox->fetch($limit) as $mail) {
|
foreach ($this->mailbox->fetch($limit) as $mail) {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 A–D 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 1–2 ist der Teil, der falsch sein kann und Geld kostet. Schritt 3–6 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.
|
||||||
|
|
@ -88,6 +88,9 @@ return [
|
||||||
],
|
],
|
||||||
|
|
||||||
'notice' => [
|
'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.',
|
'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).',
|
'failed_runs' => ':n fehlgeschlagene Bereitstellung(en).',
|
||||||
'host_error' => 'Host :host meldet einen Fehler.',
|
'host_error' => 'Host :host meldet einen Fehler.',
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ return [
|
||||||
'fetch_now' => 'Jetzt abrufen',
|
'fetch_now' => 'Jetzt abrufen',
|
||||||
'fetched' => '{1} :count neue Nachricht|[2,*] :count neue Nachrichten',
|
'fetched' => '{1} :count neue Nachricht|[2,*] :count neue Nachrichten',
|
||||||
'nothing_new' => 'Nichts Neues im Postfach.',
|
'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_archived' => 'Erledigte zeigen',
|
||||||
'show_open' => 'Offene zeigen',
|
'show_open' => 'Offene zeigen',
|
||||||
|
|
|
||||||
|
|
@ -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_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_username' => 'Postfach',
|
||||||
'inbound_folder' => 'Ordner',
|
'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,
|
// Die Reiter. Getrennt nach dem, wofür ein Abschnitt da ist — nicht danach,
|
||||||
// welcher Speichermechanismus einen Wert zufällig hält.
|
// welcher Speichermechanismus einen Wert zufällig hält.
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,9 @@ return [
|
||||||
],
|
],
|
||||||
|
|
||||||
'notice' => [
|
'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.',
|
'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).',
|
'failed_runs' => ':n failed provisioning run(s).',
|
||||||
'host_error' => 'Host :host reports an error.',
|
'host_error' => 'Host :host reports an error.',
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ return [
|
||||||
'fetch_now' => 'Fetch now',
|
'fetch_now' => 'Fetch now',
|
||||||
'fetched' => '{1} :count new message|[2,*] :count new messages',
|
'fetched' => '{1} :count new message|[2,*] :count new messages',
|
||||||
'nothing_new' => 'Nothing new in the mailbox.',
|
'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_archived' => 'Show filed',
|
||||||
'show_open' => 'Show open',
|
'show_open' => 'Show open',
|
||||||
|
|
|
||||||
|
|
@ -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_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_username' => 'Mailbox',
|
||||||
'inbound_folder' => 'Folder',
|
'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
|
// The tabs. Split by what a section is for — not by which storage
|
||||||
// mechanism happens to hold a given value.
|
// mechanism happens to hold a given value.
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,15 @@
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-semibold text-ink">{{ $entry['label'] }}</h3>
|
<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>
|
</div>
|
||||||
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
|
<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'] === 'stored' ? 'border-success-border bg-success-bg text-success'
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,31 @@
|
||||||
</x-ui.alert>
|
</x-ui.alert>
|
||||||
@endif
|
@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())
|
@if ($mails->isEmpty())
|
||||||
<div class="rounded-lg border border-line bg-surface p-10 text-center shadow-xs animate-rise">
|
<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>
|
<p class="text-sm text-muted">{{ $archived ? __('inbox.empty_archived') : __('inbox.empty') }}</p>
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,35 @@
|
||||||
@if ($canInfra)<hr class="border-line" />@endif
|
@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" />
|
||||||
@endif
|
@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>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,19 @@
|
||||||
<p class="mt-1 text-sm text-muted">{{ __('mail_settings.subtitle') }}</p>
|
<p class="mt-1 text-sm text-muted">{{ __('mail_settings.subtitle') }}</p>
|
||||||
</div>
|
</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)
|
@if (! $usable)
|
||||||
<x-ui.alert variant="warning">{{ __('mail_settings.no_key') }}</x-ui.alert>
|
<x-ui.alert variant="warning">{{ __('mail_settings.no_key') }}</x-ui.alert>
|
||||||
@endif
|
@endif
|
||||||
|
|
|
||||||
|
|
@ -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
|
// 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
|
// month. The figures are asserted directly rather than searched for in the
|
||||||
// markup — "42" also occurs inside Livewire's own component ids.
|
// 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')
|
Livewire::actingAs(operator('Owner'), 'operator')
|
||||||
->test(Overview::class)
|
->test(Overview::class)
|
||||||
->assertViewHas('kpis', function (array $kpis) {
|
->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 () {
|
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 = Livewire::actingAs(operator('Owner'), 'operator')->test(Overview::class);
|
||||||
$clean->assertSee(__('admin.systems_ok'));
|
$clean->assertSee(__('admin.systems_ok'));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use App\Services\Mail\FetchedMail;
|
||||||
use App\Services\Mail\IngestInboundMail;
|
use App\Services\Mail\IngestInboundMail;
|
||||||
use App\Services\Mail\InboundMailbox;
|
use App\Services\Mail\InboundMailbox;
|
||||||
use App\Services\Mail\MailParser;
|
use App\Services\Mail\MailParser;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -332,3 +333,108 @@ it('keeps the inbox to operators who may see customers', function () {
|
||||||
->test(Inbox::class)
|
->test(Inbox::class)
|
||||||
->assertForbidden();
|
->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()]');
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -476,3 +476,31 @@ it('saves .env once the page receives the confirmed event', function () {
|
||||||
|
|
||||||
expect(File::get($path))->toBe("CONFIRMED=value\n");
|
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*</');
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -256,3 +256,94 @@ it('names the status in words rather than printing the key at it', function () {
|
||||||
->assertDontSee('customers.status.')
|
->assertDontSee('customers.status.')
|
||||||
->assertDontSee('support.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']));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 () {
|
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();
|
$s = fakeServices();
|
||||||
$host = Host::factory()->active()->create([
|
$host = Host::factory()->active()->create([
|
||||||
'datacenter' => 'fsn',
|
'datacenter' => 'fsn',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue