188 lines
7.5 KiB
PHP
188 lines
7.5 KiB
PHP
<?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 agent’s failure in the operator’s 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();
|
||
});
|