CluPilotCloud/tests/Feature/Admin/UpdateButtonTest.php

188 lines
7.5 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'))
->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();
Livewire::actingAs(operator('Owner'))
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_unknown'))
->assertDontSee(__('admin_settings.update_current'));
});
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'))
->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'))
->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'))
->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'))
->test(AdminSettings::class)
->assertOk();
});
function writeStatus(array $status): void
{
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(storage_path('app/deploy/update-status.json'), json_encode($status));
}
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'))
->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();
});