CluPilotCloud/tests/Feature/Admin/UpdateLockReleaseTest.php

176 lines
6.9 KiB
PHP

<?php
use App\Livewire\Admin\ConfirmReleaseUpdateLock;
use App\Livewire\Admin\Settings as AdminSettings;
use App\Services\Deployment\UpdateChannel;
use Illuminate\Support\Facades\File;
use Livewire\Livewire;
/**
* Eine hängende Sperre aus der Konsole lösen.
*
* Am 4. August 2026 hielt ein einziger Aufruf — `timeout 45 docker compose
* exec` ohne `--kill-after`, der nach vier Minuten noch stand — die Sperre
* stundenlang, und jeder folgende Lauf wurde übersprungen. Der Griff dagegen
* lag per SSH auf dem Wirt. Er liegt jetzt in der Konsole.
*
* Die Konsole kann ihn nicht selbst ausführen: sie ist www-data in einem
* Behälter, der Sperrenhalter ist ein Prozess auf dem WIRT. Sie legt deshalb
* eine Bitte ab, wie für alles andere hier auch — nur in einer EIGENEN Datei.
* Der Postkasten `update-request.json` taugt dafür nicht: er nimmt eine Bitte
* zur Zeit an, und genau die dreißig Minuten, in denen dort noch eine wartende
* Update-Anfrage liegt, sind die, in denen jemand entsperren will.
*/
beforeEach(function () {
File::deleteDirectory(storage_path('app/deploy'));
});
/** Ein Lebenszeichen, das eine echte Blockade meldet (zwei übersprungene Läufe). */
function blockedHeartbeat(string $heldBy = '4242 02:03 docker compose exec -T app'): void
{
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(storage_path('app/deploy/update-status.json'), json_encode([
'state' => 'idle',
'checked_at' => now()->subMinutes(5)->toIso8601String(),
'behind' => 0,
]));
File::put(storage_path('app/deploy/agent-alive.json'), json_encode([
'at' => now()->toIso8601String(),
'state' => 'blocked',
'since' => now()->subMinutes(5)->toIso8601String(),
'held_by' => $heldBy,
'skips' => 5,
]));
}
it('offers to release the lock to an operator who may', function () {
blockedHeartbeat();
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_release_lock'))
->assertSee("component: 'admin.confirm-release-update-lock'", escape: false);
});
it('offers nothing of the sort to an operator who may not', function () {
// Die Warnung sieht jeder, der die Seite sieht. Den Griff nicht.
blockedHeartbeat();
Livewire::actingAs(operator('Support'), 'operator')
->test(AdminSettings::class)
->assertDontSee(__('admin_settings.update_release_lock'));
});
it('does not offer to release a lock nobody is holding', function () {
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(storage_path('app/deploy/update-status.json'), json_encode([
'state' => 'idle',
'checked_at' => now()->toIso8601String(),
'behind' => 0,
]));
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertDontSee(__('admin_settings.update_release_lock'));
});
it('names the process it is about to end, before it ends it', function () {
// Der Sinn der ganzen Rückfrage. PID und Kommandozeile stehen im
// Lebenszeichen; sie erst NACH dem Beenden zu zeigen wäre eine Meldung,
// keine Rückfrage.
blockedHeartbeat('4242 02:03 docker compose exec -T app');
Livewire::actingAs(operator('Owner'), 'operator')
->test(ConfirmReleaseUpdateLock::class)
->assertSee('4242 02:03 docker compose exec -T app');
});
it('leaves the deed to the page component, and only dispatches', function () {
// R23: das Modal mutiert nichts. Die Berechtigungsprüfung bleibt an der
// einen Stelle, an der sie schon steht.
blockedHeartbeat();
Livewire::actingAs(operator('Owner'), 'operator')
->test(ConfirmReleaseUpdateLock::class)
->call('confirm')
->assertDispatched('update-lock-release-confirmed');
expect(File::exists(storage_path('app/deploy/unblock-request.json')))->toBeFalse();
});
it('leaves the request for the agent once the confirmation comes back', function () {
blockedHeartbeat();
$owner = operator('Owner');
Livewire::actingAs($owner, 'operator')
->test(AdminSettings::class)
->dispatch('update-lock-release-confirmed');
$request = json_decode(File::get(storage_path('app/deploy/unblock-request.json')), true);
expect($request['requested_by'])->toBe($owner->email)
->and($request['requested_at'])->not->toBeEmpty();
});
it('refuses to release the lock for an operator without the capability', function () {
blockedHeartbeat();
Livewire::actingAs(operator('Support'), 'operator')
->test(AdminSettings::class)
->dispatch('update-lock-release-confirmed')
->assertForbidden();
expect(File::exists(storage_path('app/deploy/unblock-request.json')))->toBeFalse();
});
it('is not held up by an update request already waiting in the mailbox', function () {
// Der Grund für die eigene Datei. Der Postkasten nimmt eine Bitte zur
// Zeit an — und ein blockierter Agent holt sie nie ab, also läge dort
// dreißig Minuten lang eine, die das Entsperren aussperrt.
blockedHeartbeat();
File::put(storage_path('app/deploy/update-request.json'), json_encode([
'requested_at' => now()->toIso8601String(),
'requested_by' => 'someone@example.com',
'kind' => 'run',
]));
expect(app(UpdateChannel::class)->requestUnblock('owner@example.com'))->toBeTrue()
->and(File::exists(storage_path('app/deploy/unblock-request.json')))->toBeTrue();
});
it('does not queue a second release while the first is still waiting', function () {
blockedHeartbeat();
$channel = app(UpdateChannel::class);
expect($channel->requestUnblock('owner@example.com'))->toBeTrue()
->and($channel->requestUnblock('owner@example.com'))->toBeFalse();
});
it('says the release is on its way while the agent has not collected it', function () {
blockedHeartbeat();
app(UpdateChannel::class)->requestUnblock('owner@example.com');
expect(app(UpdateChannel::class)->state()['unblock_requested'])->toBeTrue();
});
it('reports back when the host helper was too old to do it', function () {
// Der stille Fehlschlag, den dieses Repo nicht duldet: ein Wirt, auf dem
// install-agent.sh seit dem neuen Schritt nicht mehr lief, kann nicht
// entsperren — und ein Knopf, der nichts tut und nichts sagt, schickt den
// Betreiber genau dorthin zurück, wo er ohne die Konsole schon war.
blockedHeartbeat();
File::put(storage_path('app/deploy/unblock-last-run.json'), json_encode([
'state' => 'failed',
'finished_at' => now()->toIso8601String(),
'error' => 'unblock_helper_old',
]));
$state = app(UpdateChannel::class)->state();
expect($state['unblock_error'])->toBe(__('admin_settings.update_error.unblock_helper_old'));
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_error.unblock_helper_old'));
});