435 lines
17 KiB
PHP
435 lines
17 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();
|
||
|
||
// 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'))
|
||
->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'))
|
||
->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();
|
||
});
|
||
|
||
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'))
|
||
->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 operator’s 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'))
|
||
->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 failure’s step as this update’s 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'))
|
||
->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'))
|
||
->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'))
|
||
->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();
|
||
});
|
||
|
||
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'))
|
||
->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'))
|
||
->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'))
|
||
->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'))
|
||
->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();
|
||
});
|