CluPilotCloud/tests/Feature/Admin/UpdateButtonTest.php

625 lines
25 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
use App\Livewire\Admin\Settings as AdminSettings;
use App\Models\User;
use App\Services\Deployment\UpdateChannel;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\File;
use Livewire\Livewire;
/**
* Updating the installation from the console.
*
* The panel cannot run the update — it is www-data inside the container the
* update restarts. It leaves a request for the host-side agent instead. These
* tests cover the panel's half: what it reports, what it refuses, and what it
* must never claim.
*/
beforeEach(function () {
File::deleteDirectory(storage_path('app/deploy'));
});
it('does not offer to update when the agent has never reported in', function () {
// Without an agent the button would do nothing at all, and a button that
// does nothing is worse than a missing one.
$state = app(UpdateChannel::class)->state();
expect($state['agent_seen'])->toBeFalse()
->and($state['behind'])->toBeNull()
->and($state['available'])->toBeFalse();
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_no_agent'));
});
it('never reports "up to date" when it simply does not know', function () {
// Null and zero are different answers. Showing the first as the second is
// how an installation sits three versions behind looking healthy.
expect(app(UpdateChannel::class)->state()['available'])->toBeFalse();
// Asserted on the state, not on the markup: "Aktuell" is also a substring
// of "Aktuelles Passwort" elsewhere on the same page.
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_unknown'))
->assertViewHas('update', fn (array $u) => $u['behind'] === null && $u['available'] === false);
});
it('reports an available update once the agent has looked', function () {
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 3]);
$state = app(UpdateChannel::class)->state();
expect($state['available'])->toBeTrue()->and($state['behind'])->toBe(3);
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_available', ['n' => 3]));
});
it('leaves a request the agent can find', function () {
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->call('requestUpdate')
->assertHasNoErrors();
$request = json_decode(File::get(storage_path('app/deploy/update-request.json')), true);
expect($request['requested_by'])->toBeString()->not->toBeEmpty()
->and($request['requested_at'])->toBeString();
});
it('does not queue a second run when the button is pressed twice', function () {
$channel = app(UpdateChannel::class);
expect($channel->request('someone@example.com'))->toBeTrue()
// Impatience, not intent — and two overlapping runs of update.sh fight
// over the checkout.
->and($channel->request('someone@example.com'))->toBeFalse();
});
it('forgets a request nobody ever collected', function () {
// Written while the agent was down. Firing it days later, whenever the
// agent happens to come back, is not what anyone asked for.
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(storage_path('app/deploy/update-request.json'), json_encode([
'requested_at' => Carbon::now()->subHours(3)->toIso8601String(),
'requested_by' => 'someone@example.com',
]));
$channel = app(UpdateChannel::class);
expect($channel->pendingRequest())->toBeNull()
->and($channel->state()['running'])->toBeFalse()
// And a fresh request is accepted again rather than blocked forever.
->and($channel->request('someone@example.com'))->toBeTrue();
});
it('needs the capability, not merely an operator account', function () {
Livewire::actingAs(operator('Support'), 'operator')
->test(AdminSettings::class)
->call('requestUpdate')
->assertForbidden();
});
it('survives a status file that is corrupt', function () {
// This is the page an operator opens when something is already wrong. It
// must not be the second thing that is broken.
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(storage_path('app/deploy/update-status.json'), '{ this is not json');
$state = app(UpdateChannel::class)->state();
expect($state['agent_seen'])->toBeFalse();
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertOk();
});
it('carries a live countdown target for a queued update, not a server-formatted clock time', function () {
// The agent checks on a timer, so pressing the button visibly does
// nothing until the next tick. Silence there reads as a broken button —
// reported exactly that way — so the card now counts down live in the
// browser (resources/js/app.js) instead of naming a clock time.
//
// Frozen: the assertion is about the arithmetic, and a clock that moves
// between writing the file and reading it turns 1 into 0.998.
Carbon::setTestNow(Carbon::parse('2026-07-27 12:34:00'));
writeStatus([
'state' => 'idle',
'checked_at' => now()->toIso8601String(),
'check_interval_minutes' => 1,
'behind' => 1,
]);
app(UpdateChannel::class)->request('owner@example.com');
$state = app(UpdateChannel::class)->state();
expect($state['running'])->toBeTrue()
->and($state['next_check_at']?->toIso8601String())->toBe(now()->addMinutes(1)->toIso8601String());
// The raw instant, not a clock time formatted server-side: there is no
// local()-vs-utc conversion left here for R19 to catch a regression of —
// the browser owns the countdown and localises it itself by construction
// (new Date() on an ISO string with an explicit offset). What matters is
// that the RIGHT instant reaches the page.
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_starts_in'))
->assertSee($state['next_check_at']->toIso8601String(), escape: false);
});
it('names the step the deployment is on, in the operators language', function () {
// The step comes from the file update.sh rewrites as it goes, NOT from the
// status document: the agent writes that once and then blocks for the whole
// run, so a phase taken from there is frozen at the first step forever.
writeStatus([
'state' => 'running',
'checked_at' => now()->toIso8601String(),
'started_at' => now()->subMinutes(2)->toIso8601String(),
'behind' => 1,
]);
writePhase('migrate');
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_step', [
'step' => __('admin_settings.update_phase.migrate'),
]));
});
it('follows the step file as the deployment moves on', function () {
writeStatus(['state' => 'running', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);
writePhase('composer');
expect(app(UpdateChannel::class)->state()['phase'])->toBe(__('admin_settings.update_phase.composer'));
writePhase('restart');
expect(app(UpdateChannel::class)->state()['phase'])->toBe(__('admin_settings.update_phase.restart'));
});
it('does not print a raw phase key it has no translation for', function () {
// A newer deployment script against older translations. Better to say
// nothing than to put `update_phase.something` in front of an operator.
writeStatus([
'state' => 'running',
'checked_at' => now()->toIso8601String(),
'behind' => 1,
]);
writePhase('something_new');
expect(app(UpdateChannel::class)->state()['phase'])->toBeNull();
});
it('does not present the last failures step as this updates progress', function () {
// A failed run leaves its phase file in place on purpose — that is how the
// failing step gets named. Queueing the next update must not then show it
// as already mid-migration, hiding the only thing that is actually known:
// when it will start.
Carbon::setTestNow(Carbon::parse('2026-07-27 12:34:00'));
writeStatus([
'state' => 'idle',
'checked_at' => now()->toIso8601String(),
'check_interval_minutes' => 5,
'behind' => 1,
]);
writePhase('migrate');
app(UpdateChannel::class)->request('owner@example.com');
$state = app(UpdateChannel::class)->state();
expect($state['running'])->toBeTrue()
->and($state['phase'])->toBeNull();
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_starts_in'))
->assertDontSee(__('admin_settings.update_phase.migrate'));
});
it('ignores a step written before the run that is going on now', function () {
// A manual `bash deploy/update.sh` on the shell leaves a phase file the
// agent never cleared.
Carbon::setTestNow(Carbon::parse('2026-07-27 12:34:00'));
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(
storage_path('app/deploy/update-phase'),
"assets\t".now()->subHour()->toIso8601String()."\n",
);
writeStatus([
'state' => 'running',
'checked_at' => now()->toIso8601String(),
'started_at' => now()->subMinute()->toIso8601String(),
'behind' => 1,
]);
expect(app(UpdateChannel::class)->state()['phase'])->toBeNull();
});
it('never promises a start time that has already gone by', function () {
// A systemd timer that ran late leaves the last check older than its own
// interval. checked_at + interval is then in the past, and the page would
// promise a start that demonstrably did not happen.
Carbon::setTestNow(Carbon::parse('2026-07-27 12:34:00'));
writeStatus([
'state' => 'idle',
'checked_at' => now()->subMinutes(12)->toIso8601String(),
'check_interval_minutes' => 5,
'behind' => 1,
]);
app(UpdateChannel::class)->request('owner@example.com');
$next = app(UpdateChannel::class)->state()['next_check_at'];
expect($next)->not->toBeNull()
->and($next->isFuture())->toBeTrue()
// One interval from now is the only honest upper bound once the timer
// is already overdue.
->and($next->toIso8601String())->toBe(now()->addMinutes(5)->toIso8601String());
});
it('names the step a failed update stopped at', function () {
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);
writeLastRun([
'state' => 'failed',
'started_at' => now()->subMinutes(3)->toIso8601String(),
'finished_at' => now()->toIso8601String(),
'phase' => 'migrate',
'error' => 'update_failed',
'exit_code' => 1,
]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_failed_at', [
'step' => __('admin_settings.update_phase.migrate'),
]));
});
function writeStatus(array $status): void
{
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(storage_path('app/deploy/update-status.json'), json_encode($status));
}
/** What deploy/update.sh writes as it moves from step to step. */
function writePhase(string $key): void
{
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(storage_path('app/deploy/update-phase'), $key."\t".now()->toIso8601String()."\n");
}
function writeLastRun(array $run): void
{
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(storage_path('app/deploy/update-last-run.json'), json_encode($run));
}
it('stops trusting an agent that has gone quiet', function () {
// A status file written once by an agent since stopped would otherwise
// leave the button enabled forever, writing requests nobody collects.
writeStatus(['state' => 'idle', 'checked_at' => now()->subHour()->toIso8601String(), 'behind' => 2]);
$state = app(UpdateChannel::class)->state();
expect($state['agent_seen'])->toBeFalse()
->and($state['behind'])->toBeNull()
->and($state['available'])->toBeFalse();
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_no_agent'));
});
it('shows the agents failure in the operators language', function () {
// The agent is a shell script and is not translated; it reports a code.
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);
writeLastRun(['state' => 'failed', 'finished_at' => now()->toIso8601String(), 'error' => 'update_failed', 'exit_code' => 3]);
expect(app(UpdateChannel::class)->state()['last_error'])
->toBe(__('admin_settings.update_error.update_failed', ['code' => '3']));
// An unknown code is passed through rather than swallowed: a message
// nobody has worded yet still beats silence while an update is failing.
writeLastRun(['state' => 'failed', 'finished_at' => now()->toIso8601String(), 'error' => 'something_new']);
expect(app(UpdateChannel::class)->state()['last_error'])->toBe('something_new');
});
it('still shows a failed update after the next routine check', function () {
// The check runs on a timer. Writing both into one file meant a failure
// was usually gone before anyone looked at it.
writeLastRun(['state' => 'failed', 'finished_at' => now()->toIso8601String(), 'error' => 'update_failed', 'exit_code' => 2]);
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 0]);
$state = app(UpdateChannel::class)->state();
expect($state['last_state'])->toBe('failed')
->and($state['last_error'])->toBe(__('admin_settings.update_error.update_failed', ['code' => '2']));
});
it('refuses a second request while the agent is mid-run', function () {
// The agent deletes the request before it starts, so between that moment
// and the end of the run there is nothing "pending" to block on.
writeStatus(['state' => 'running', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);
$channel = app(UpdateChannel::class);
expect($channel->isRunning())->toBeTrue()
->and($channel->request('someone@example.com'))->toBeFalse();
expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeFalse();
});
/**
* "Nach Aktualisierungen suchen" — check only, never apply.
*
* The request carries a `kind` so the agent can tell the two apart. These
* cover the panel's half: a check must never read as "running" (no
* maintenance mode, no full-screen overlay for a question that was only "is
* there something new"), and the one request slot is still shared, so
* pressing either button while the other is outstanding is refused the same
* way pressing the same one twice already was.
*/
it('writes a distinct request kind for a check, not a flag on the existing one', function () {
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 0]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->call('requestCheck')
->assertHasNoErrors();
$request = json_decode(File::get(storage_path('app/deploy/update-request.json')), true);
expect($request['kind'])->toBe('check')
->and($request['requested_by'])->toBeString()->not->toBeEmpty();
});
it('does not treat a pending check as "running" — no maintenance overlay for a question', function () {
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 0]);
$channel = app(UpdateChannel::class);
expect($channel->requestCheck('owner@example.com'))->toBeTrue();
$state = $channel->state();
expect($state['checking'])->toBeTrue()
->and($state['running'])->toBeFalse();
});
it('does treat a pending run as "running", not "checking"', function () {
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);
$channel = app(UpdateChannel::class);
expect($channel->request('owner@example.com'))->toBeTrue();
$state = $channel->state();
expect($state['running'])->toBeTrue()
->and($state['checking'])->toBeFalse();
});
it('does not queue a second check when the button is pressed twice', function () {
$channel = app(UpdateChannel::class);
expect($channel->requestCheck('someone@example.com'))->toBeTrue()
->and($channel->requestCheck('someone@example.com'))->toBeFalse();
});
it('blocks a check while a run is pending, and a run while a check is pending', function () {
$channel = app(UpdateChannel::class);
expect($channel->request('someone@example.com'))->toBeTrue()
->and($channel->requestCheck('someone@example.com'))->toBeFalse();
File::delete(storage_path('app/deploy/update-request.json'));
expect($channel->requestCheck('someone@example.com'))->toBeTrue()
->and($channel->request('someone@example.com'))->toBeFalse();
});
it('needs the capability to check, not merely an operator account', function () {
Livewire::actingAs(operator('Support'), 'operator')
->test(AdminSettings::class)
->call('requestCheck')
->assertForbidden();
});
it('confirms a check was requested, worded for a check rather than an update', function () {
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 0]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->call('requestCheck')
->assertDispatched('notify', message: __('admin_settings.update_check_requested'));
});
it('offers "Jetzt aktualisieren" even when the last check found nothing', function () {
// Reported as "I cannot run an update, it says everything is current".
// `behind` is a READING taken up to a minute ago, not the state of the
// world: push a commit and the console insisted it was up to date and
// refused to act on it. The agent fetches before deciding, so asking
// against a stale reading costs one fetch — being locked out costs the
// deployment.
writeStatus([
'checked_at' => now()->toIso8601String(),
'state' => 'idle',
'behind' => 0,
]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_now'))
->call('requestUpdate');
expect(app(UpdateChannel::class)->pendingRequest())->not->toBeNull();
});
it('offers both actions together whenever the agent is reachable and idle', function () {
// Two separate buttons now, not one whose label changed with the
// reading: looking and applying are different intentions.
writeStatus([
'checked_at' => now()->toIso8601String(),
'state' => 'idle',
'behind' => 0,
]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_check'))
->assertSee(__('admin_settings.update_now'));
});
it('still refuses when there is no agent to pick the request up', function () {
// The one case where either button really would go nowhere: nothing is
// running that would ever read the request file.
writeStatus([
'checked_at' => now()->subDay()->toIso8601String(),
'state' => 'idle',
'behind' => 0,
]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_unknown'))
->assertDontSee(__('admin_settings.update_check'))
->assertDontSee(__('admin_settings.update_now'));
});
it('carries the watcher onto every console page, not only settings', function () {
// wire:poll cannot see the run through to the end, because the run
// restarts the containers answering the poll — so the watcher (and the
// maintenance overlay it drives) lives in the layout, not this one page,
// and has to be there however an operator reaches the console.
$this->actingAs(operator('Owner'), 'operator')
->get(route('admin.overview'))
->assertOk()
->assertSee('updateWatcher(', escape: false);
});
it('binds the watcher exactly once when the settings page is open', function () {
// The settings card used to bind its own copy as well as the layout now
// doing so for every page — two pollers racing each other against the
// same endpoint.
writeStatus([
'checked_at' => now()->toIso8601String(),
'state' => 'idle',
'behind' => 2,
]);
$html = $this->actingAs(operator('Owner'), 'operator')
->get(route('admin.settings'))
->assertOk()
->getContent();
expect(substr_count($html, 'updateWatcher('))->toBe(1);
});
it('answers the watcher without Livewire in the way', function () {
writeStatus(['checked_at' => now()->toIso8601String(), 'state' => 'idle', 'behind' => 0]);
$this->actingAs(operator('Owner'), 'operator')
->get(route('admin.update.state'))
->assertOk()
->assertJsonStructure(['running', 'commit', 'behind', 'last_finished_at', 'step', 'next_check_at', 'running_since', 'log'])
->assertHeader('Cache-Control', 'no-store, private');
});
it('carries the current step in the watcher JSON, already in the operators language', function () {
// The overlay only ever displays this string — it must arrive ready to
// print, the same sentence the settings card itself renders.
writeStatus([
'state' => 'running',
'checked_at' => now()->toIso8601String(),
'started_at' => now()->subMinute()->toIso8601String(),
'behind' => 1,
]);
writePhase('migrate');
$this->actingAs(operator('Owner'), 'operator')
->get(route('admin.update.state'))
->assertOk()
->assertJson(['step' => __('admin_settings.update_step', [
'step' => __('admin_settings.update_phase.migrate'),
])]);
});
it('carries no step in the watcher JSON before the deployment has announced one', function () {
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 0]);
$this->actingAs(operator('Owner'), 'operator')
->get(route('admin.update.state'))
->assertOk()
->assertJson(['step' => null]);
});
it('carries how long a run has been going in the watcher JSON, already worded', function () {
// Ready to print, the same sentence update_since renders on the settings
// card — the overlay never assembles this from a raw timestamp either.
writeStatus([
'state' => 'running',
'checked_at' => now()->toIso8601String(),
'started_at' => now()->subMinutes(2)->toIso8601String(),
'behind' => 1,
]);
writePhase('migrate');
$this->actingAs(operator('Owner'), 'operator')
->get(route('admin.update.state'))
->assertOk()
->assertJson(['running_since' => __('admin_settings.update_since', [
'when' => now()->subMinutes(2)->diffForHumans(),
])]);
});
it('carries the tail of the run log in the watcher JSON once a run has actually started', function () {
writeStatus([
'state' => 'running',
'checked_at' => now()->toIso8601String(),
'started_at' => now()->subMinute()->toIso8601String(),
'behind' => 1,
]);
writePhase('migrate');
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(storage_path('app/deploy/update-last-run.log'), "line one\nline two\n");
$this->actingAs(operator('Owner'), 'operator')
->get(route('admin.update.state'))
->assertOk()
->assertJson(['log' => "line one\nline two"]);
});
it('carries no running-since or log while only queued, not yet actually running', function () {
// Otherwise this would be the PREVIOUS run's leftovers, arriving under a
// countdown that says the run has not started yet.
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(storage_path('app/deploy/update-last-run.log'), "an earlier run\n");
app(UpdateChannel::class)->request('owner@example.com');
$this->actingAs(operator('Owner'), 'operator')
->get(route('admin.update.state'))
->assertOk()
->assertJson(['running_since' => null, 'log' => null])
->assertJsonStructure(['next_check_at']);
});
it('does not answer the watcher for somebody who is not an operator', function () {
$this->actingAs(User::factory()->create())
->get(route('admin.update.state'))
->assertForbidden();
});