'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')); });