CluPilotCloud/tests/Feature/Admin/UpdateButtonTest.php

435 lines
17 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\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('says when a queued update will actually start', function () {
// The agent checks on a timer, so pressing the button visibly does nothing
// for minutes. Without a time that is indistinguishable from a broken
// button, which is precisely how it was read.
// Frozen: the assertion is about the arithmetic, and a clock that moves
// between writing the file and reading it turns 5 into 4.998.
Carbon::setTestNow(Carbon::parse('2026-07-27 12:34:00'));
writeStatus([
'state' => 'idle',
'checked_at' => now()->toIso8601String(),
'check_interval_minutes' => 5,
'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(5)->toIso8601String());
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
// Asserted against the operator's wall clock, not against whatever the
// view happens to do. The old version of this test built its expected
// value with the same call the view used, so it agreed with the bug:
// the console announced 15:21 for an update that ran at 17:21 and every
// test passed.
->assertSee(__('admin_settings.update_queued', [
'time' => $state['next_check_at']->local()->format('H:i'),
]))
->assertDontSee(__('admin_settings.update_queued', [
'time' => $state['next_check_at']->utc()->format('H:i'),
]));
});
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_queued', [
'time' => $state['next_check_at']->local()->format('H:i'),
]))
->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 every five minutes. 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();
});
it('offers the update button 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 every few minutes, 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_recheck'))
->call('requestUpdate');
expect(app(UpdateChannel::class)->pendingRequest())->not->toBeNull();
});
it('still refuses when there is no agent to pick the request up', function () {
// The one case where the 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_recheck'));
});
it('watches the deployment in a way that survives the restart', function () {
// wire:poll cannot see the run through to the end, because the run
// restarts the containers answering the poll.
writeStatus([
'checked_at' => now()->toIso8601String(),
'state' => 'idle',
'behind' => 2,
]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee('updateWatcher', escape: false);
});
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'])
->assertHeader('Cache-Control', 'no-store, private');
});
it('does not answer the watcher for somebody who is not an operator', function () {
$this->actingAs(\App\Models\User::factory()->create())
->get(route('admin.update.state'))
->assertForbidden();
});