CluPilotCloud/tests/Feature/ReleaseCeilingConsoleTest.php

340 lines
14 KiB
PHP

<?php
use App\Livewire\Admin\ConfirmPinRelease;
use App\Livewire\Admin\Settings;
use App\Models\Operator;
use App\Services\Deployment\UpdateChannel;
use App\Services\Deployment\UpdateWindow;
use App\Support\Settings as AppSettings;
use Illuminate\Support\Facades\File;
use Livewire\Livewire;
beforeEach(function () {
File::ensureDirectoryExists(storage_path('app/deploy'));
File::delete(File::glob(storage_path('app/deploy/*')));
});
afterEach(function () {
File::deleteDirectory(storage_path('app/deploy'));
});
it('writes and removes the ceiling', function () {
$channel = app(UpdateChannel::class);
$path = storage_path('app/deploy/release-ceiling');
expect($channel->setCeiling('chef@example.com', 'v1.8.0'))->toBeTrue()
->and(trim(File::get($path)))->toBe('v1.8.0');
expect($channel->setCeiling('chef@example.com', null))->toBeTrue()
->and(File::exists($path))->toBeFalse();
});
it('refuses a ceiling that is not a version', function () {
// Die Form wird auf BEIDEN Seiten geprueft. Der Agent kann sich auf nichts
// verlassen, was aus einer Datei kommt — und die Konsole soll gar nicht
// erst etwas hinlegen, das er ablehnen muss.
$channel = app(UpdateChannel::class);
expect($channel->setCeiling('chef@example.com', 'neueste'))->toBeFalse()
->and(File::exists(storage_path('app/deploy/release-ceiling')))->toBeFalse();
});
it('leaves no temporary file behind', function () {
// Atomar heisst: der Agent sieht die Datei ganz oder gar nicht. Bliebe die
// .tmp liegen, waere das der Beleg fuer ein File::put statt eines rename.
app(UpdateChannel::class)->setCeiling('chef@example.com', 'v1.8.0');
expect(File::exists(storage_path('app/deploy/release-ceiling.tmp')))->toBeFalse();
});
it('reports the ceiling from the file, not from the agent', function () {
// Die Decke ist der Konsole eigener Zustand. Sie muss sofort stehen, statt
// zu warten, bis der Agent sie zurueckmeldet.
app(UpdateChannel::class)->setCeiling('chef@example.com', 'v1.8.0');
expect(app(UpdateChannel::class)->state()['ceiling'])->toBe('v1.8.0');
});
it('passes the ceiling complaint from the agent through', function () {
File::put(storage_path('app/deploy/update-status.json'), json_encode([
'state' => 'idle',
'ceiling_error' => 'ceiling_missing',
'releases' => ['v1.8.1', 'v1.8.0'],
]));
$state = app(UpdateChannel::class)->state();
expect($state['ceiling_error'])->toBe('ceiling_missing')
->and($state['releases'])->toBe(['v1.8.1', 'v1.8.0']);
});
it('marks a ceiling that sits below what is deployed', function () {
// Über das Auswahlfeld nicht erreichbar, über die Kommandozeile schon:
// Decke auf v1.8.0, jemand fährt `RELEASE=v1.8.1 update.sh`. Die Lage
// muss lesbar sein statt als „aktuell" durchzugehen — es steht ja eine
// Decke, sie ist nur überholt.
File::put(storage_path('app/deploy/release-ceiling'), 'v1.8.0');
$state = app(UpdateChannel::class)->state();
// `version` kommt aus Release::current(); in der Testumgebung ist das die
// VERSION-Datei des Checkouts, die über 1.8.0 liegt.
expect($state['ceiling_passed'])->toBeTrue();
});
it('does not mark a ceiling that is still ahead', function () {
File::put(storage_path('app/deploy/release-ceiling'), 'v99.0.0');
expect(app(UpdateChannel::class)->state()['ceiling_passed'])->toBeFalse();
});
it('does not throw when the ceiling file exists but cannot be read as a file', function () {
// Kein echtes Wettrennen im Test moeglich, aber derselbe Codepfad wie ein
// Verschwinden zwischen File::exists() und File::get(): ein Verzeichnis
// existiert (File::exists() prueft file_exists(), das ist fuer
// Verzeichnisse wahr), ist aber keine Datei (File::get() prueft
// isFile() und wirft eine FileNotFoundException). Ohne Absicherung
// wuerfe state() genau hier — die Vorgabe, die es unter keinen
// Umstaenden darf.
//
// Bewusst kein expect(fn () => ...)->not->toThrow(Throwable::class):
// Throwable ist ein Interface, class_exists() verneint es, und die
// Pruefung faellt intern auf einen Substring-Vergleich zurueck, der
// nichts mehr ueber ein tatsaechliches Werfen aussagt. Ein direkter
// Aufruf laesst eine durchschlagende Exception den Test regulaer als
// fehlgeschlagen melden — das ist der verlaessliche Nachweis.
File::ensureDirectoryExists(storage_path('app/deploy/release-ceiling'));
$state = app(UpdateChannel::class)->state();
expect($state['ceiling'])->toBeNull();
});
it('does not claim success when writing the ceiling fails', function () {
// Kein File-Facade-Mock: eine echte Rechteverweigerung auf einem
// Verzeichnis, das dieser (nicht-root) Testbenutzer selbst besitzt.
// File::put() auf die .tmp darin schlaegt wirklich fehl, nicht nur dem
// Namen nach.
$deployDir = storage_path('app/deploy');
chmod($deployDir, 0500);
try {
$result = app(UpdateChannel::class)->setCeiling('chef@example.com', 'v1.8.0');
} finally {
chmod($deployDir, 0755);
}
expect($result)->toBeFalse()
->and(File::exists($deployDir.'/release-ceiling.tmp'))->toBeFalse()
->and(File::exists($deployDir.'/release-ceiling'))->toBeFalse();
});
it('makes the automatic window do nothing while the ceiling is reached', function () {
// Der wichtigste Test des ganzen Entwurfs. Er belegt, dass Knopf und
// Wartungsfenster denselben Zustand lesen — die Behauptung, auf der alles
// ruht. Ohne ihn ist „es gibt keinen zweiten Weg in eine Auslieferung"
// eine Absichtserklaerung im Kommentar von AutoUpdate.php, kein Beleg.
//
// Damit ALLEIN die Decke den Ausschlag gibt, muessen alle anderen
// Vorbedingungen aus AutoUpdate::handle() erfuellt sein: das Fenster ist
// eingeschaltet (--force uebergeht nur isOpen(), nicht enabled() — ein
// ausgeschaltetes Fenster wuerde den Test aus dem falschen Grund gruen
// machen), der Agent gilt als lebendig, und es laeuft nichts. Der Agent
// hat gemeldet: nichts verfuegbar, weil die Decke erreicht ist.
AppSettings::set(UpdateWindow::KEY_ENABLED, true);
File::put(storage_path('app/deploy/update-status.json'), json_encode([
'state' => 'idle',
'behind' => 0,
'ceiling_error' => '',
'checked_at' => now()->toIso8601String(),
]));
File::put(storage_path('app/deploy/agent-alive.json'), json_encode([
'at' => now()->toIso8601String(),
'state' => 'running',
'skips' => 0,
]));
// Der Zustand, den Knopf und Wartungsfenster BEIDE lesen: schon hier
// steht `available` auf false, bevor der Befehl ueberhaupt laeuft.
expect(app(UpdateChannel::class)->state()['available'])->toBeFalse();
$this->artisan('clupilot:auto-update', ['--force' => true])
->assertSuccessful();
// Nichts abgelegt: keine Anfrage, kein Wartungsfenster, kein Lauf.
expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeFalse();
});
it('takes the same state without a ceiling, and does request an update', function () {
// Der Gegenbeweis zum Test oben: exakt derselbe Aufbau — Fenster an, Agent
// lebendig, nichts laeuft — nur `behind` steht auf 1 statt 0, so wie der
// Agent es meldet, wenn keine Decke ihn bremst. Erst beide Tests
// zusammen belegen, dass die Decke — und keine der anderen
// Vorbedingungen — den Ausschlag gibt: ohne sie legt dieselbe Automatik
// sehr wohl eine Anfrage ab.
AppSettings::set(UpdateWindow::KEY_ENABLED, true);
File::put(storage_path('app/deploy/update-status.json'), json_encode([
'state' => 'idle',
'behind' => 1,
'ceiling_error' => '',
'checked_at' => now()->toIso8601String(),
]));
File::put(storage_path('app/deploy/agent-alive.json'), json_encode([
'at' => now()->toIso8601String(),
'state' => 'running',
'skips' => 0,
]));
expect(app(UpdateChannel::class)->state()['available'])->toBeTrue();
$this->artisan('clupilot:auto-update', ['--force' => true])
->assertSuccessful();
expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeTrue();
});
it('pins from the console', function () {
// Rolle, nicht Einzelberechtigung: die siebzehn Konsolen-Berechtigungen
// hängen am `operator`-Guard über Rollen (R21). `->role('Owner')` ist das
// Muster der übrigen Tests, z. B. SwitchOperatingModeTest.
$owner = Operator::factory()->role('Owner')->create();
File::put(storage_path('app/deploy/update-status.json'), json_encode([
'state' => 'idle',
'releases' => ['v1.8.1', 'v1.8.0'],
]));
Livewire::actingAs($owner, 'operator')
->test(Settings::class)
->set('ceilingChoice', 'v1.8.0')
->call('pinRelease', 'v1.8.0');
expect(trim(File::get(storage_path('app/deploy/release-ceiling'))))->toBe('v1.8.0');
});
it('takes the ceiling off again', function () {
$owner = Operator::factory()->role('Owner')->create();
File::put(storage_path('app/deploy/release-ceiling'), 'v1.8.0');
Livewire::actingAs($owner, 'operator')
->test(Settings::class)
->call('unpinRelease');
expect(File::exists(storage_path('app/deploy/release-ceiling')))->toBeFalse();
});
it('refuses to pin without site.manage', function () {
// Eine Livewire-Aktion ist ein oeffentlicher Endpunkt. Die eine Stelle, an
// der eine Regel NICHT allein stehen darf, ist das disabled-Attribut eines
// Knopfes.
//
// Angemeldet, aber ohne die Rolle, die `site.manage` traegt: `site.manage`
// liegt am operator-Guard bei Owner und Admin (siehe die Migration
// 2026_07_25_220001 und die Begruendung in 2026_08_04_160000). Geprueft
// wird also die Berechtigung, nicht die Anmeldung.
$staff = Operator::factory()->create();
Livewire::actingAs($staff, 'operator')
->test(Settings::class)
->set('ceilingChoice', 'v1.8.0')
->call('pinRelease', 'v1.8.0')
->assertForbidden();
});
it('does not let an empty confirmation take the ceiling off while reporting success', function () {
// BEFUND 1: `setCeiling($operator->email, $this->ceilingChoice ?: null)`
// liess eine leere Auswahl als "Decke abnehmen" durchgehen — und meldete
// dabei `release_pinned`, die Erfolgsmeldung der GEGENHANDLUNG. Erreichbar
// war das, weil das Auswahlfeld `<option value="">` traegt und einzig ein
// Alpine-`x-bind:disabled` den Klick verhinderte — kein Ersatz fuer eine
// serverseitige Pruefung, siehe requestUpdate() im selben Bauteil.
//
// Die Decke steht bereits auf v1.8.0. Eine leere Bestaetigung darf sie
// weder loeschen noch die Festnageln-Erfolgsmeldung zeigen.
$owner = Operator::factory()->role('Owner')->create();
File::put(storage_path('app/deploy/release-ceiling'), 'v1.8.0');
Livewire::actingAs($owner, 'operator')
->test(Settings::class)
->call('pinRelease', '')
->assertDispatched('notify', message: __('admin_settings.release_pin_empty'));
expect(trim(File::get(storage_path('app/deploy/release-ceiling'))))->toBe('v1.8.0');
});
it('prefills the select with the ceiling that is actually set', function () {
// BEFUND 5: `ceilingChoice` wurde nie aus dem gesetzten Zustand
// vorbelegt. Auf einem festgenagelten Server las das Feld "Neueste
// Version (nicht festgenagelt)", waehrend die Zeile darunter
// "Festgenagelt auf 1.8.0" sagte — zwei Bedienelemente derselben Karte,
// die sich widersprachen.
File::put(storage_path('app/deploy/release-ceiling'), 'v1.8.0');
$owner = Operator::factory()->role('Owner')->create();
Livewire::actingAs($owner, 'operator')
->test(Settings::class)
->assertSet('ceilingChoice', 'v1.8.0');
});
it('leaves the select empty when nothing is pinned', function () {
$owner = Operator::factory()->role('Owner')->create();
Livewire::actingAs($owner, 'operator')
->test(Settings::class)
->assertSet('ceilingChoice', '');
});
it('keeps the "remove ceiling" button reachable when the file reads back empty', function () {
// BEFUND 6: Bash wertet eine leere Deckendatei als `ceiling_invalid`;
// PHP (UpdateChannel::ceiling()) liefert dafuer `null` — leerer Inhalt
// wird als "keine Decke" gelesen. Die Konsole zeigte dann zwar den
// Warnkasten, aber der `@if ($update['ceiling'])`-Block — der einzige
// Ort mit dem Knopf "Decke abnehmen" — wurde uebersprungen. Nur eine
// Hand am Wirt kann diesen Zustand erzeugen (eine leere Datei), aber die
// Konsole muss trotzdem einen Weg zurueck bieten, ohne Kommandozeile.
File::put(storage_path('app/deploy/release-ceiling'), '');
File::put(storage_path('app/deploy/update-status.json'), json_encode([
'state' => 'idle',
'ceiling_error' => 'ceiling_invalid',
'releases' => ['v1.8.1', 'v1.8.0'],
]));
$owner = Operator::factory()->role('Owner')->create();
// Die Konsole selbst liest die leere Datei als "keine Decke" — der Bug
// sass genau in diesem Widerspruch zwischen den beiden Seiten.
expect(app(UpdateChannel::class)->state()['ceiling'])->toBeNull();
Livewire::actingAs($owner, 'operator')
->test(Settings::class)
->assertSeeHtml('wire:click="unpinRelease"')
->call('unpinRelease');
expect(File::exists(storage_path('app/deploy/release-ceiling')))->toBeFalse();
});
it('does not open the pin confirmation without the capability', function () {
// Nach dem Vorbild von IntegrationsPageTest::"does not open the
// save/forget confirmation without the capability" fuer ConfirmSaveSecret
// — ConfirmPinRelease trug dieselbe authorize('site.manage') in mount(),
// ohne dass irgendein Test sie je geprueft hat (BEFUND 4).
$staff = Operator::factory()->create();
Livewire::actingAs($staff, 'operator')
->test(ConfirmPinRelease::class, ['version' => 'v1.8.0'])
->assertForbidden();
});
it('confirms a pin through the modal without writing anything itself', function () {
$owner = Operator::factory()->role('Owner')->create();
Livewire::actingAs($owner, 'operator')
->test(ConfirmPinRelease::class, ['version' => 'v1.8.0'])
->assertSee('1.8.0')
->call('confirm')
->assertDispatched('pin-release-confirmed', version: 'v1.8.0');
expect(File::exists(storage_path('app/deploy/release-ceiling')))->toBeFalse();
});