Fill the hostname page from the installation, not from a blank form
The page was empty, and that was a design fault rather than a missing button. I built a register that starts blank — while the installation already serves half a dozen names whose certificates were exactly what the operator wanted to see. An overview you have to populate first does not answer "what do I have". So the names are derived now, from the same configuration routes/web.php builds its domain bindings from: SITE_HOSTS, APP_HOST, FILES_HOST, ADMIN_HOSTS. If a name is in the environment, the application answers on it, and then it belongs in this list without anybody typing it a second time. Opening the page syncs them; the list is never empty again. Syncing and measuring are deliberately separate. The sync costs nothing and runs on page load. The measurement goes out over the network and runs on the button or on a schedule — doing it on page load would mean waiting through half a dozen TLS handshakes, and one of them is always the name that currently does not resolve. Which is the other half of what was missing: there was no overview because nothing measured unless somebody pressed a button. A daily run at 04:17 fills it, because the question that matters is not "is it valid right now" but "is renewal running" — a certificate expiring in forty days is fine, the same one at twenty means something has been broken for a week. Only a measurement taken while nobody is looking can tell those apart. The page now opens with four counts — total, valid, expiring soon, without a certificate — and says when it last measured. A name that comes from the environment is marked as such and cannot be removed here: it would come back at the next sync, and a button that does nothing is worse than no button, because it gets believed once. 2040 tests pass, assets build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feature/host-bootstrap
parent
1e3199b72e
commit
cfaea7dc82
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Proxy\CertificateSweep;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Täglich: welche Namen bedient diese Installation, und wie lange gelten ihre
|
||||
* Zertifikate noch.
|
||||
*
|
||||
* Nach Zeitplan und nicht nur auf Knopfdruck, weil die Frage, auf die es
|
||||
* ankommt, nicht „ist es gerade gültig" lautet, sondern „läuft die Erneuerung".
|
||||
* Die beantwortet nur eine Messung, die auch dann läuft, wenn niemand hinsieht.
|
||||
*/
|
||||
class CheckCertificates extends Command
|
||||
{
|
||||
protected $signature = 'clupilot:check-certificates';
|
||||
|
||||
protected $description = 'Syncs the known hostnames and measures their certificates';
|
||||
|
||||
public function handle(CertificateSweep $sweep): int
|
||||
{
|
||||
$added = $sweep->sync();
|
||||
$result = $sweep->check();
|
||||
|
||||
$this->info(sprintf(
|
||||
'%d hostname(s) added, %d checked, %d without a valid certificate',
|
||||
$added,
|
||||
$result['checked'],
|
||||
$result['failing'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ namespace App\Livewire\Admin;
|
|||
use App\Models\Operator;
|
||||
use App\Models\ProxyHost;
|
||||
use App\Services\Deployment\UpdateChannel;
|
||||
use App\Services\Proxy\CertificateInspector;
|
||||
use App\Services\Proxy\CertificateSweep;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Attributes\Layout;
|
||||
|
|
@ -43,10 +43,22 @@ class ProxyHosts extends Component
|
|||
|
||||
public ?string $notice = null;
|
||||
|
||||
public function mount(): void
|
||||
public function mount(CertificateSweep $sweep): void
|
||||
{
|
||||
$this->authorize('site.manage');
|
||||
$this->acmeEmail = (string) Settings::get('proxy.acme_email', '');
|
||||
|
||||
// Die Namen aus der Umgebung übernehmen, damit die Seite NIE leer ist.
|
||||
//
|
||||
// Die erste Fassung war ein leeres Register: sie zeigte nichts, während
|
||||
// die Installation längst ein halbes Dutzend Namen bediente, deren
|
||||
// Zertifikate der Betreiber gerade sehen wollte. Eine Übersicht, die man
|
||||
// erst befüllen muss, beantwortet „was habe ich" nicht.
|
||||
//
|
||||
// Nur der Abgleich, NICHT die Messung: die geht ins Netz, und ein
|
||||
// halbes Dutzend TLS-Handschläge beim Öffnen einer Seite bedeutet
|
||||
// Warten — mit einem Namen darunter, der gerade nicht auflöst.
|
||||
$sweep->sync();
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
|
|
@ -80,7 +92,18 @@ class ProxyHosts extends Component
|
|||
{
|
||||
$this->authorize('site.manage');
|
||||
|
||||
ProxyHost::query()->whereKey($id)->delete();
|
||||
$host = ProxyHost::query()->whereKey($id)->first();
|
||||
|
||||
// Ein Name aus der Umgebung kommt beim nächsten Abgleich wieder. Ihn
|
||||
// löschen zu lassen wäre ein Knopf, der nichts bewirkt — schlimmer als
|
||||
// keiner, weil er einmal geglaubt wird.
|
||||
if ($host === null || $host->isFromConfig()) {
|
||||
$this->notice = __('proxy.remove_from_config');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$host->delete();
|
||||
|
||||
// Kein Anwenden von selbst. Einen Namen aus der Liste zu nehmen und ihn
|
||||
// im selben Atemzug aus dem laufenden Proxy zu entfernen wäre zwei
|
||||
|
|
@ -105,21 +128,14 @@ class ProxyHosts extends Component
|
|||
* ein Hostname, der nicht auflöst, darf die Seite nicht minutenlang
|
||||
* blockieren.
|
||||
*/
|
||||
public function checkCertificates(CertificateInspector $inspector): void
|
||||
public function checkCertificates(CertificateSweep $sweep): void
|
||||
{
|
||||
$this->authorize('site.manage');
|
||||
|
||||
foreach (ProxyHost::query()->get() as $host) {
|
||||
$result = $inspector->inspect($host->hostname);
|
||||
$sweep->sync();
|
||||
$result = $sweep->check();
|
||||
|
||||
$host->forceFill([
|
||||
'certificate_expires_at' => $result['expires_at'],
|
||||
'certificate_checked_at' => now(),
|
||||
'certificate_error' => $result['error'],
|
||||
])->save();
|
||||
}
|
||||
|
||||
$this->notice = __('proxy.checked');
|
||||
$this->notice = __('proxy.checked_count', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -142,8 +158,21 @@ class ProxyHosts extends Component
|
|||
|
||||
public function render()
|
||||
{
|
||||
$hosts = ProxyHost::query()->orderBy('purpose')->orderBy('hostname')->get();
|
||||
|
||||
return view('livewire.admin.proxy-hosts', [
|
||||
'hosts' => ProxyHost::query()->orderBy('purpose')->orderBy('hostname')->get(),
|
||||
'hosts' => $hosts,
|
||||
// Die Übersicht in drei Zahlen. Was ein Betreiber wissen will,
|
||||
// bevor er eine Liste liest: ist etwas kaputt, und läuft etwas bald
|
||||
// ab.
|
||||
'summary' => [
|
||||
'total' => $hosts->count(),
|
||||
'valid' => $hosts->filter(fn ($h) => $h->hasCertificate() && ! $h->isExpiringSoon())->count(),
|
||||
'soon' => $hosts->filter(fn ($h) => $h->hasCertificate() && $h->isExpiringSoon())->count(),
|
||||
'failing' => $hosts->filter(fn ($h) => $h->certificate_checked_at !== null && $h->certificate_error !== null)->count(),
|
||||
'unchecked' => $hosts->filter(fn ($h) => $h->certificate_checked_at === null)->count(),
|
||||
'checkedAt' => $hosts->max('certificate_checked_at'),
|
||||
],
|
||||
'purposes' => ProxyHost::PURPOSES,
|
||||
// Leer heißt: die Adresse des Inhabers. Eine Kontaktadresse, an die
|
||||
// die Ablaufwarnungen gehen, darf nicht leer bleiben, und die eine
|
||||
|
|
|
|||
|
|
@ -13,7 +13,23 @@ use Illuminate\Database\Eloquent\Model;
|
|||
*/
|
||||
class ProxyHost extends Model
|
||||
{
|
||||
protected $fillable = ['hostname', 'purpose', 'note'];
|
||||
protected $fillable = ['hostname', 'purpose', 'note', 'source'];
|
||||
|
||||
/**
|
||||
* Aus der Umgebung abgeleitet — nicht von Hand angelegt, also auch nicht
|
||||
* von Hand zu entfernen: beim nächsten Abgleich käme er wieder, und ein
|
||||
* Knopf, der nichts bewirkt, ist schlimmer als keiner.
|
||||
*/
|
||||
public function isFromConfig(): bool
|
||||
{
|
||||
return $this->source === 'config';
|
||||
}
|
||||
|
||||
/** Hat die Prüfung ein gültiges Zertifikat gefunden? */
|
||||
public function hasCertificate(): bool
|
||||
{
|
||||
return $this->certificate_expires_at !== null && $this->certificate_error === null;
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Proxy;
|
||||
|
||||
use App\Models\ProxyHost;
|
||||
use App\Support\KnownHostnames;
|
||||
|
||||
/**
|
||||
* Bringt die Liste auf den Stand der Installation und misst, was jeder Name
|
||||
* gerade wirklich ausliefert.
|
||||
*
|
||||
* Zwei getrennte Hälften, mit Absicht: `sync()` kostet nichts und darf beim
|
||||
* Öffnen der Seite laufen, `check()` geht ins Netz und läuft auf Knopfdruck
|
||||
* oder nach Zeitplan. Beim Seitenaufruf zu messen hieße, den Betreiber auf ein
|
||||
* halbes Dutzend TLS-Handschläge warten zu lassen — und einer davon ist immer
|
||||
* der Name, der gerade nicht auflöst.
|
||||
*/
|
||||
class CertificateSweep
|
||||
{
|
||||
public function __construct(private CertificateInspector $inspector) {}
|
||||
|
||||
/**
|
||||
* Die Namen aus der Umgebung in die Liste übernehmen.
|
||||
*
|
||||
* Anlegen und aktualisieren, nie löschen: ein Name, der aus der Umgebung
|
||||
* verschwindet, bleibt als Zeile stehen. Sein Zertifikat läuft ja weiter,
|
||||
* und ein Eintrag, der still verschwindet, ist genau das Gegenteil einer
|
||||
* Übersicht.
|
||||
*
|
||||
* @return int wie viele Zeilen neu hinzugekommen sind
|
||||
*/
|
||||
public function sync(): int
|
||||
{
|
||||
$added = 0;
|
||||
|
||||
foreach (KnownHostnames::all() as $hostname => $purpose) {
|
||||
$existing = ProxyHost::query()->where('hostname', $hostname)->first();
|
||||
|
||||
if ($existing === null) {
|
||||
ProxyHost::create([
|
||||
'hostname' => $hostname,
|
||||
'purpose' => $purpose,
|
||||
'source' => 'config',
|
||||
]);
|
||||
$added++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ein von Hand angelegter Name, der inzwischen auch in der Umgebung
|
||||
// steht, bleibt „manual" — sonst verlöre der Betreiber die
|
||||
// Möglichkeit, ihn wieder zu entfernen, ohne je etwas dafür getan
|
||||
// zu haben.
|
||||
if ($existing->isFromConfig() && $existing->purpose !== $purpose) {
|
||||
$existing->forceFill(['purpose' => $purpose])->save();
|
||||
}
|
||||
}
|
||||
|
||||
return $added;
|
||||
}
|
||||
|
||||
/** @return array{checked: int, failing: int} */
|
||||
public function check(): array
|
||||
{
|
||||
$checked = 0;
|
||||
$failing = 0;
|
||||
|
||||
foreach (ProxyHost::query()->get() as $host) {
|
||||
$result = $this->inspector->inspect($host->hostname);
|
||||
|
||||
$host->forceFill([
|
||||
'certificate_expires_at' => $result['expires_at'],
|
||||
'certificate_checked_at' => now(),
|
||||
'certificate_error' => $result['error'],
|
||||
])->save();
|
||||
|
||||
$checked++;
|
||||
if ($result['error'] !== null) {
|
||||
$failing++;
|
||||
}
|
||||
}
|
||||
|
||||
return ['checked' => $checked, 'failing' => $failing];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Die Hostnamen, die diese Installation ohnehin schon bedient.
|
||||
*
|
||||
* Abgeleitet, nie getippt. Genau das war der Fehler in der ersten Fassung der
|
||||
* Hostnamen-Seite: sie war ein leeres Register und zeigte deshalb nichts —
|
||||
* während die Installation längst ein halbes Dutzend Namen bediente, deren
|
||||
* Zertifikate der Betreiber gerade sehen wollte. Eine Übersicht, die erst
|
||||
* befüllt werden muss, beantwortet die Frage „was habe ich" nicht.
|
||||
*
|
||||
* Die Quellen sind dieselben, aus denen `routes/web.php` seine Domain-Bindungen
|
||||
* baut. Steht ein Name in der Umgebung, antwortet die Anwendung darauf — und
|
||||
* dann gehört er in diese Liste, ohne dass jemand ihn ein zweites Mal einträgt.
|
||||
*/
|
||||
final class KnownHostnames
|
||||
{
|
||||
/**
|
||||
* @return array<string, string> hostname => Zweck ('public' | 'console')
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
$found = [];
|
||||
|
||||
foreach ((array) config('admin_access.site_hosts', []) as $host) {
|
||||
self::add($found, $host, 'public');
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'admin_access.app_host',
|
||||
'admin_access.status_host',
|
||||
'admin_access.files_host',
|
||||
] as $key) {
|
||||
self::add($found, (string) config($key, ''), 'public');
|
||||
}
|
||||
|
||||
// Die Konsole und ihre Ausweichnamen. `console`, weil sie hinter der
|
||||
// Netzsperre liegen — ein Name mit dem falschen Zweck sähe hier aus wie
|
||||
// eine funktionierende Seite und wäre eine offene Konsole.
|
||||
foreach ((array) config('admin_access.hosts', []) as $host) {
|
||||
self::add($found, $host, 'console');
|
||||
}
|
||||
|
||||
ksort($found);
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
private static function add(array &$found, string $host, string $purpose): void
|
||||
{
|
||||
$host = strtolower(trim($host));
|
||||
|
||||
// Kein Schema, kein Pfad, kein Port — und mindestens ein Punkt. Ein
|
||||
// blanker Name wie `localhost` ist ein Entwicklungsrest und hat kein
|
||||
// Zertifikat, das jemand überwachen wollte.
|
||||
if ($host === '' || ! str_contains($host, '.') || preg_match('/[^a-z0-9.\-]/', $host)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Konsole gewinnt gegen öffentlich, wenn ein Name in beiden Listen
|
||||
// steht: die engere Aussage ist die richtige.
|
||||
if (($found[$host] ?? null) !== 'console') {
|
||||
$found[$host] = $purpose;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Woher ein Hostname kommt — und damit, ob er sich entfernen lässt.
|
||||
*
|
||||
* `config` — aus der Umgebung dieser Installation abgeleitet (APP_HOST,
|
||||
* SITE_HOSTS, FILES_HOST, ADMIN_HOSTS …). Er steht in der Liste,
|
||||
* damit sein Zertifikat überwacht wird, und lässt sich hier nicht
|
||||
* löschen: gelöscht würde er beim nächsten Abgleich wiederkommen,
|
||||
* und ein Knopf, der nichts bewirkt, ist schlimmer als keiner.
|
||||
* `manual` — vom Betreiber angelegt. Der geht auch wieder weg.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('proxy_hosts', function (Blueprint $table) {
|
||||
$table->string('source')->default('manual')->after('purpose');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('proxy_hosts', function (Blueprint $table) {
|
||||
$table->dropColumn('source');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -50,4 +50,16 @@ return [
|
|||
'acme_email_hint' => 'Leer lassen: dann wird :email genommen, die Adresse des Inhabers.',
|
||||
'acme_email_hint_none' => 'Leer lassen ist hier keine gute Wahl — es gibt kein Inhaberkonto, aus dem eine Adresse käme.',
|
||||
],
|
||||
|
||||
'stat' => [
|
||||
'total' => 'Hostnamen',
|
||||
'valid' => 'Zertifikat gültig',
|
||||
'soon' => 'Läuft bald ab',
|
||||
'failing' => 'Ohne Zertifikat',
|
||||
],
|
||||
'from_config' => 'aus der Konfiguration',
|
||||
'remove_from_config' => 'Dieser Name kommt aus der Umgebung dieser Installation und käme beim nächsten Abgleich wieder. Entfernen lässt er sich nur dort, wo er gesetzt ist.',
|
||||
'never_checked' => ':count Hostname(n) wurden noch nie geprüft. Die Messung läuft täglich um 04:17 — oder jetzt, über „Zertifikate prüfen".',
|
||||
'last_checked' => 'Zuletzt geprüft: :when.',
|
||||
'checked_count' => ':checked geprüft, :failing ohne gültiges Zertifikat.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -50,4 +50,16 @@ return [
|
|||
'acme_email_hint' => 'Leave empty to use :email, the owner\'s address.',
|
||||
'acme_email_hint_none' => 'Leaving this empty is a poor choice here — there is no owner account to take an address from.',
|
||||
],
|
||||
|
||||
'stat' => [
|
||||
'total' => 'Hostnames',
|
||||
'valid' => 'Certificate valid',
|
||||
'soon' => 'Expiring soon',
|
||||
'failing' => 'No certificate',
|
||||
],
|
||||
'from_config' => 'from the configuration',
|
||||
'remove_from_config' => 'This name comes from the installation\'s environment and would return at the next sync. It can only be removed where it is set.',
|
||||
'never_checked' => ':count hostname(s) have never been checked. The measurement runs daily at 04:17 — or now, via "Check certificates".',
|
||||
'last_checked' => 'Last checked: :when.',
|
||||
'checked_count' => ':checked checked, :failing without a valid certificate.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -9,6 +9,23 @@
|
|||
<x-ui.alert variant="info" class="animate-rise">{{ $notice }}</x-ui.alert>
|
||||
@endif
|
||||
|
||||
{{-- Die Übersicht in Zahlen, vor der Liste. Was ein Betreiber wissen will,
|
||||
bevor er zwölf Zeilen liest: ist etwas kaputt, läuft etwas bald ab. --}}
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 animate-rise [animation-delay:20ms]">
|
||||
<x-ui.stat-tile :label="__('proxy.stat.total')" :value="$summary['total']" />
|
||||
<x-ui.stat-tile :label="__('proxy.stat.valid')" :value="$summary['valid']" />
|
||||
<x-ui.stat-tile :label="__('proxy.stat.soon')" :value="$summary['soon']"
|
||||
:tone="$summary['soon'] > 0 ? 'warning' : null" />
|
||||
<x-ui.stat-tile :label="__('proxy.stat.failing')" :value="$summary['failing']"
|
||||
:tone="$summary['failing'] > 0 ? 'danger' : null" />
|
||||
</div>
|
||||
|
||||
@if ($summary['unchecked'] > 0)
|
||||
<x-ui.alert variant="info" class="animate-rise [animation-delay:30ms]">
|
||||
{{ __('proxy.never_checked', ['count' => $summary['unchecked']]) }}
|
||||
</x-ui.alert>
|
||||
@endif
|
||||
|
||||
<section class="animate-rise [animation-delay:40ms]">
|
||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
|
|
@ -36,6 +53,9 @@
|
|||
@if ($host->isConsole())
|
||||
<x-ui.badge status="warning">{{ __('proxy.purpose.console') }}</x-ui.badge>
|
||||
@endif
|
||||
@if ($host->isFromConfig())
|
||||
<span class="text-xs text-muted">{{ __('proxy.from_config') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Der Zustand steht als Satz, nicht als Farbe: ein
|
||||
|
|
@ -59,17 +79,24 @@
|
|||
</p>
|
||||
</div>
|
||||
|
||||
@unless ($host->isFromConfig())
|
||||
<button type="button" wire:click="remove({{ $host->id }})"
|
||||
class="justify-self-start text-xs font-medium text-muted transition hover:text-danger sm:justify-self-end">
|
||||
{{ __('proxy.remove') }}
|
||||
</button>
|
||||
@endunless
|
||||
</div>
|
||||
@empty
|
||||
<div class="px-6 py-8 text-center text-sm text-muted">{{ __('proxy.empty') }}</div>
|
||||
@endforelse
|
||||
</x-ui.panel>
|
||||
|
||||
<p class="mt-2 text-xs leading-relaxed text-muted">{{ __('proxy.apply_hint') }}</p>
|
||||
<p class="mt-2 text-xs leading-relaxed text-muted">
|
||||
@if ($summary['checkedAt'])
|
||||
{{ __('proxy.last_checked', ['when' => $summary['checkedAt']->local()->isoFormat('D. MMM YYYY, HH:mm')]) }}
|
||||
@endif
|
||||
{{ __('proxy.apply_hint') }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<form wire:submit="add" class="animate-rise [animation-delay:60ms]">
|
||||
|
|
|
|||
|
|
@ -225,3 +225,19 @@ Schedule::command('clupilot:verify-vat-ids')
|
|||
Schedule::command('clupilot:end-due-services')
|
||||
->hourly()
|
||||
->withoutOverlapping();
|
||||
|
||||
// Zertifikate: welche Namen bedient diese Installation, und wie lange gelten
|
||||
// sie noch.
|
||||
//
|
||||
// Nach Zeitplan und nicht nur auf Knopfdruck. Die Frage, auf die es ankommt,
|
||||
// lautet nicht „ist es gerade gültig" — sondern „läuft die Erneuerung". Ein
|
||||
// Zertifikat, das in vierzig Tagen abläuft, ist in Ordnung; dasselbe in
|
||||
// zwanzig Tagen heißt, dass seit einer Woche etwas nicht funktioniert. Das
|
||||
// sieht nur, wer misst, wenn niemand hinsieht.
|
||||
//
|
||||
// Früh, aber nicht zur vollen Stunde: Let's Encrypt bittet ausdrücklich darum,
|
||||
// nicht auf den Punkt zu prüfen.
|
||||
Schedule::command('clupilot:check-certificates')
|
||||
->dailyAt('04:17')
|
||||
->withoutOverlapping()
|
||||
->name('check-certificates');
|
||||
|
|
|
|||
|
|
@ -40,7 +40,9 @@ it('refuses anything that is not a hostname', function () {
|
|||
proxyPage()->set('hostname', $attempt)->call('add')->assertHasErrors('hostname');
|
||||
}
|
||||
|
||||
expect(ProxyHost::query()->count())->toBe(0);
|
||||
// Nur die von Hand angelegten zählen: die Seite gleicht beim Öffnen die
|
||||
// Namen aus der Umgebung ein, also ist die Tabelle nie leer.
|
||||
expect(ProxyHost::query()->where('source', 'manual')->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('refuses the same hostname twice', function () {
|
||||
|
|
@ -58,7 +60,7 @@ it('removes from the list without touching the running proxy', function () {
|
|||
|
||||
proxyPage()->call('remove', $host->id)->assertSee(__('proxy.removed'));
|
||||
|
||||
expect(ProxyHost::query()->count())->toBe(0);
|
||||
expect(ProxyHost::query()->where('source', 'manual')->count())->toBe(0);
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -139,3 +141,60 @@ it('is closed to an operator without the permission', function () {
|
|||
->test(ProxyHosts::class)
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
/**
|
||||
* Der Fund, der diese Seite zweimal falsch gemacht hat.
|
||||
*
|
||||
* Die erste Fassung war ein LEERES Register: sie zeigte nichts, während die
|
||||
* Installation längst ein halbes Dutzend Namen bediente, deren Zertifikate der
|
||||
* Betreiber gerade sehen wollte. Eine Übersicht, die man erst befüllen muss,
|
||||
* beantwortet „was habe ich" nicht.
|
||||
*/
|
||||
it('shows the hostnames this installation already serves, without being told', function () {
|
||||
expect(ProxyHost::query()->count())->toBe(0);
|
||||
|
||||
proxyPage()->assertSee('files.clupilot.test');
|
||||
|
||||
$entdeckt = ProxyHost::query()->where('hostname', 'files.clupilot.test')->first();
|
||||
|
||||
expect($entdeckt)->not->toBeNull()
|
||||
->and($entdeckt->isFromConfig())->toBeTrue();
|
||||
});
|
||||
|
||||
/**
|
||||
* Ein Name aus der Umgebung käme beim nächsten Abgleich wieder. Ein Knopf, der
|
||||
* nichts bewirkt, ist schlimmer als keiner — er wird einmal geglaubt.
|
||||
*/
|
||||
it('will not let go of a hostname that comes from the environment', function () {
|
||||
proxyPage();
|
||||
$ausDerKonfiguration = ProxyHost::query()->where('source', 'config')->firstOrFail();
|
||||
|
||||
proxyPage()->call('remove', $ausDerKonfiguration->id)
|
||||
->assertSee(__('proxy.remove_from_config'));
|
||||
|
||||
expect(ProxyHost::query()->whereKey($ausDerKonfiguration->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
/**
|
||||
* Die Übersicht in Zahlen — das, was ein Betreiber wissen will, bevor er eine
|
||||
* Liste liest.
|
||||
*/
|
||||
it('counts what is valid, what expires soon and what is broken', function () {
|
||||
proxyPage();
|
||||
ProxyHost::query()->delete();
|
||||
|
||||
ProxyHost::create(['hostname' => 'gut.clupilot.com', 'purpose' => 'public'])
|
||||
->forceFill(['certificate_expires_at' => now()->addDays(70), 'certificate_checked_at' => now()])->save();
|
||||
ProxyHost::create(['hostname' => 'knapp.clupilot.com', 'purpose' => 'public'])
|
||||
->forceFill(['certificate_expires_at' => now()->addDays(9), 'certificate_checked_at' => now()])->save();
|
||||
ProxyHost::create(['hostname' => 'kaputt.clupilot.com', 'purpose' => 'public'])
|
||||
->forceFill(['certificate_checked_at' => now(), 'certificate_error' => 'tlsv1 alert internal error'])->save();
|
||||
|
||||
$summary = Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
|
||||
->test(ProxyHosts::class)
|
||||
->viewData('summary');
|
||||
|
||||
expect($summary['valid'])->toBe(1)
|
||||
->and($summary['soon'])->toBe(1)
|
||||
->and($summary['failing'])->toBe(1);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue