1008 lines
44 KiB
PHP
1008 lines
44 KiB
PHP
<?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('says a queued update is starting, and never counts down to it', function () {
|
||
// There was a live countdown here to the agent's next tick. It ran
|
||
// backwards a few seconds and snapped back to a full minute, over and
|
||
// over, and it did so on every server whose systemd timer is longer than
|
||
// the interval the agent reports — the target was recomputed on each poll
|
||
// and had already gone by, so the "honest upper bound" fallback moved it
|
||
// forward again every time. Reported exactly that way.
|
||
//
|
||
// Nothing counts down now. The agent is woken when the request lands.
|
||
writeStatus([
|
||
'state' => 'idle',
|
||
'checked_at' => now()->toIso8601String(),
|
||
'check_interval_minutes' => 1,
|
||
'behind' => 1,
|
||
'request_watch' => true,
|
||
]);
|
||
|
||
app(UpdateChannel::class)->request('owner@example.com');
|
||
|
||
$state = app(UpdateChannel::class)->state();
|
||
|
||
expect($state['running'])->toBeTrue()
|
||
->and($state)->not->toHaveKey('next_check_at')
|
||
// `instant` is what selects the sentence. Asserted here rather than on
|
||
// the settings component: the sentence moved into the full-page overlay
|
||
// in layouts/admin, because it was being shown in BOTH and an operator
|
||
// saw one panel appear and a second cover it seconds later.
|
||
->and($state['instant'])->toBeTrue();
|
||
});
|
||
|
||
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');
|
||
|
||
// Through the endpoint the overlay actually reads. That is the whole path
|
||
// now: no Livewire, no component state, no assets — because the thing being
|
||
// reported on restarts the containers serving the page.
|
||
$this->actingAs(operator('Owner'), 'operator')
|
||
->getJson(route('admin.update.state'))
|
||
->assertOk()
|
||
->assertJsonPath('step', __('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()
|
||
// Queued, not instant — which is the sentence the overlay picks.
|
||
->and($state['instant'])->toBeFalse();
|
||
|
||
// And the endpoint the overlay reads says nothing about a step either.
|
||
$this->actingAs(operator('Owner'), 'operator')
|
||
->getJson(route('admin.update.state'))
|
||
->assertJsonPath('step', null);
|
||
});
|
||
|
||
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('promises no start time at all, on the exact configuration that used to loop', function () {
|
||
// The reported case, reproduced: the agent reports a one-minute interval
|
||
// (the value in the repo) while the host's systemd timer is still five, so
|
||
// checked_at + interval is in the past for four minutes out of every five.
|
||
// The old code answered "now + interval" afresh on every poll, which is a
|
||
// countdown that resets forever instead of reaching zero.
|
||
//
|
||
// There is nothing left to get wrong: no time is promised anywhere.
|
||
Carbon::setTestNow(Carbon::parse('2026-07-27 12:34:00'));
|
||
|
||
writeStatus([
|
||
'state' => 'idle',
|
||
'checked_at' => now()->subMinutes(4)->toIso8601String(),
|
||
'check_interval_minutes' => 1,
|
||
'behind' => 1,
|
||
]);
|
||
|
||
app(UpdateChannel::class)->request('owner@example.com');
|
||
|
||
expect(app(UpdateChannel::class)->state())->not->toHaveKey('next_check_at');
|
||
|
||
Livewire::actingAs(operator('Owner'), 'operator')
|
||
->test(AdminSettings::class)
|
||
// No clock, and — the part that actually broke — no element for a
|
||
// browser-side timer to keep resetting.
|
||
->assertDontSee('x-data="countdown', escape: false);
|
||
});
|
||
|
||
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 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 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'));
|
||
});
|
||
|
||
/**
|
||
* A check is a question, and a question does not get a countdown.
|
||
*
|
||
* Reported verbatim: "ich suche nur nach neuen Updates, was ist das da für ein
|
||
* Timer, den ich nicht gestartet habe?" The check had been routed through the
|
||
* same timer as a real update, so the card showed the only thing it had —
|
||
* "Startet in 3:44" — for an operator who had asked for nothing to start.
|
||
*
|
||
* The agent is now woken the moment the request lands (the path unit in
|
||
* deploy/install-agent.sh) and reports whether that trigger exists, because a
|
||
* server that has not re-run the installer still waits for the timer and must
|
||
* not be told otherwise.
|
||
*/
|
||
it('shows a pending check its badge and nothing else — no time, no clock', function () {
|
||
// Even on a host with no path unit, where the answer really does wait for
|
||
// the timer. A check starts nothing, so there is nothing to announce; the
|
||
// badge saying it is looking is the whole of what can honestly be said.
|
||
writeStatus([
|
||
'state' => 'idle',
|
||
'checked_at' => now()->toIso8601String(),
|
||
'check_interval_minutes' => 5,
|
||
'behind' => 0,
|
||
'request_watch' => false,
|
||
]);
|
||
|
||
app(UpdateChannel::class)->requestCheck('owner@example.com');
|
||
|
||
Livewire::actingAs(operator('Owner'), 'operator')
|
||
->test(AdminSettings::class)
|
||
->assertSee(__('admin_settings.update_checking'))
|
||
->assertDontSee(__('admin_settings.update_starting'))
|
||
->assertDontSee(__('admin_settings.update_starting_queued'));
|
||
});
|
||
|
||
it('tells a queued update apart from an immediate one, without either naming a time', function () {
|
||
// The one thing 'instant' still decides: whether the console may say the
|
||
// update starts NOW, or has to admit it starts when the agent gets to it.
|
||
// Neither answer contains a number.
|
||
writeStatus([
|
||
'state' => 'idle',
|
||
'checked_at' => now()->toIso8601String(),
|
||
'check_interval_minutes' => 5,
|
||
'behind' => 1,
|
||
'request_watch' => false,
|
||
]);
|
||
|
||
app(UpdateChannel::class)->request('owner@example.com');
|
||
|
||
// On the state, which is what the overlay in layouts/admin picks its
|
||
// sentence from. The settings card no longer says any of this — it said it
|
||
// at the same time as the overlay, in a smaller box, and an operator saw
|
||
// the page arrange itself twice.
|
||
expect(app(UpdateChannel::class)->state()['instant'])->toBeFalse();
|
||
});
|
||
|
||
it('does not believe a stale agent about being woken on demand', function () {
|
||
// The field says what was installed when the agent last ran. From an agent
|
||
// that has since stopped it is not a promise anybody can keep.
|
||
writeStatus([
|
||
'state' => 'idle',
|
||
'checked_at' => now()->subHour()->toIso8601String(),
|
||
'behind' => 0,
|
||
'request_watch' => true,
|
||
]);
|
||
|
||
expect(app(UpdateChannel::class)->state()['instant'])->toBeFalse();
|
||
});
|
||
|
||
it('keeps the panel, the agent and the installer in step about the on-demand trigger', function () {
|
||
// Three files, one contract: the installer creates the path unit, the agent
|
||
// reports whether it is active, the panel decides from that whether it may
|
||
// say "now". If they drift, the console promises an instant answer on a
|
||
// server that still waits five minutes for it — which is the exact failure
|
||
// this replaced.
|
||
$installer = File::get(base_path('deploy/install-agent.sh'));
|
||
$agent = File::get(base_path('deploy/update-agent.sh'));
|
||
|
||
expect($installer)->toContain('/etc/systemd/system/clupilot-update-agent.path')
|
||
->and($installer)->toContain('PathChanged=')
|
||
->and($installer)->toContain('systemctl enable --now clupilot-update-agent.path')
|
||
->and($agent)->toContain('is-active --quiet clupilot-update-agent.path')
|
||
->and($agent)->toContain('"request_watch"');
|
||
});
|
||
|
||
/**
|
||
* An update is a released version, never the head of a branch.
|
||
*
|
||
* The console used to offer "Jetzt aktualisieren" unconditionally, because
|
||
* `behind` counted commits on main and was a reading a minute old — refusing on
|
||
* a stale reading cost the deployment. That whole problem belonged to following
|
||
* a branch. Nothing is installed now unless a tag exists whose version is
|
||
* higher than the one deployed, and pressing the check button answers within a
|
||
* second, so a stale reading is no longer something to design around.
|
||
*/
|
||
it('refuses to queue an update when nothing newer has been released', function () {
|
||
writeStatus([
|
||
'checked_at' => now()->toIso8601String(),
|
||
'state' => 'idle',
|
||
'behind' => 0,
|
||
]);
|
||
|
||
Livewire::actingAs(operator('Owner'), 'operator')
|
||
->test(AdminSettings::class)
|
||
->assertSee(__('admin_settings.update_only_releases'))
|
||
->call('requestUpdate')
|
||
->assertDispatched('notify', message: __('admin_settings.update_nothing_released'));
|
||
|
||
// Not merely a disabled button: a Livewire action is a public endpoint, and
|
||
// a queued run for nothing costs a maintenance window.
|
||
expect(app(UpdateChannel::class)->pendingRequest())->toBeNull();
|
||
});
|
||
|
||
it('offers the update, by version, once a release above the installed one exists', function () {
|
||
writeStatus([
|
||
'checked_at' => now()->toIso8601String(),
|
||
'state' => 'idle',
|
||
'behind' => 1,
|
||
'target_release' => 'v1.1.0',
|
||
]);
|
||
|
||
Livewire::actingAs(operator('Owner'), 'operator')
|
||
->test(AdminSettings::class)
|
||
// The number somebody can decide about, not "1 Aktualisierung".
|
||
->assertSee(__('admin_settings.update_available_release', ['version' => '1.1.0']))
|
||
->assertDontSee(__('admin_settings.update_only_releases'))
|
||
->call('requestUpdate')
|
||
->assertDispatched('notify', message: __('admin_settings.update_requested'));
|
||
|
||
expect(app(UpdateChannel::class)->pendingRequest())->not->toBeNull();
|
||
});
|
||
|
||
it('does not name a release the agent never reported', function () {
|
||
// An older agent writes no target_release. The count is all there is, and
|
||
// inventing a version number from it would be worse than the count.
|
||
writeStatus([
|
||
'checked_at' => now()->toIso8601String(),
|
||
'state' => 'idle',
|
||
'behind' => 2,
|
||
]);
|
||
|
||
expect(app(UpdateChannel::class)->state()['target_release'])->toBeNull();
|
||
|
||
Livewire::actingAs(operator('Owner'), 'operator')
|
||
->test(AdminSettings::class)
|
||
->assertSee(__('admin_settings.update_available', ['n' => 2]));
|
||
});
|
||
|
||
it('decides what is newer by version and installs only a tag', function () {
|
||
// The agent's half of the same rule, which no PHP test can reach: it asks
|
||
// one question regardless of what the checkout is attached to, compares by
|
||
// version rather than by branch position, and hands update.sh a RELEASE
|
||
// rather than a BRANCH.
|
||
$agent = File::get(base_path('deploy/update-agent.sh'));
|
||
|
||
expect($agent)->toContain('version_gt')
|
||
->and($agent)->toContain('sort -V')
|
||
->and($agent)->toContain('RELEASE="$TARGET_RELEASE"')
|
||
// No branch left to fall back onto, which is what made a commit on main
|
||
// installable in the first place.
|
||
->and($agent)->not->toContain('BRANCH="$BRANCH" "$ROOT/deploy/update.sh"');
|
||
});
|
||
|
||
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', 'running_since', 'log'])
|
||
->assertHeader('Cache-Control', 'no-store, private');
|
||
});
|
||
|
||
it('carries the current step in the watcher JSON, already in the operator’s 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, presented as the
|
||
// progress of a run that has not begun.
|
||
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])
|
||
// Nothing for the overlay to count down to, either.
|
||
->assertJsonMissing(['next_check_at' => null]);
|
||
});
|
||
|
||
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();
|
||
});
|
||
|
||
it('shows one design while updating, not two swapping mid-run', function () {
|
||
// Reported as "the first screen must not be there". It was the console's
|
||
// own overlay in the app's light tokens; then the containers went down and
|
||
// the 503 page took over with a completely different face. Two designs for
|
||
// one event, replacing each other mid-update.
|
||
//
|
||
// Both render the same partial now, so there is nothing left that can
|
||
// disagree.
|
||
$console = Illuminate\Support\Facades\File::get(resource_path('views/layouts/admin.blade.php'));
|
||
$maintenance = Illuminate\Support\Facades\File::get(resource_path('views/errors/503.blade.php'));
|
||
|
||
expect($console)->toContain("partials.updating-panel")
|
||
->and($maintenance)->toContain("partials.updating-panel")
|
||
// And nothing left behind that would render a second look.
|
||
->and($console)->not->toContain('update_overlay_title')
|
||
->and($maintenance)->not->toContain('<style>');
|
||
});
|
||
|
||
it('keeps the deployment step in the overlay, and nothing else from the run', function () {
|
||
// The panel is shared with a page customers see, so the operator detail is
|
||
// behind a flag rather than always present — but the STEP has to still be
|
||
// there, because "läuft gerade" on its own is what sends somebody to the
|
||
// shell.
|
||
//
|
||
// The elapsed time and the deployment's raw output are deliberately gone.
|
||
// Neither is the question anybody staring at a maintenance screen has: the
|
||
// log was rsync listing every font file it copied, and reading it meant a
|
||
// file read on every console page load whether or not anything was
|
||
// running. The settings page still shows it, where somebody looks for it.
|
||
$panel = Illuminate\Support\Facades\File::get(resource_path('views/partials/updating-panel.blade.php'));
|
||
|
||
expect($panel)->toContain('x-text="step"')
|
||
->not->toContain('x-text="runningSince"')
|
||
->not->toContain('x-text="log"');
|
||
});
|
||
|
||
it('shows that something is happening without asking anybody to read', function () {
|
||
// The one question a person on this page has is whether anything is moving
|
||
// at all. The bar answers it in CSS — this page is served while the
|
||
// application is down, so it must not depend on JavaScript having loaded or
|
||
// on the server answering another request — and it is indeterminate on
|
||
// purpose: a deployment has no honest percentage, and a bar that claims one
|
||
// always stalls at 90 %.
|
||
$panel = Illuminate\Support\Facades\File::get(resource_path('views/partials/updating-panel.blade.php'));
|
||
|
||
expect($panel)->toContain('cpu-bar')
|
||
->toContain('@keyframes cpu-slide')
|
||
// Everyone sees it, not only the console: a customer on the 503 is
|
||
// asking the same question.
|
||
->and(str_contains(
|
||
Illuminate\Support\Str::before($panel, "@if (\$detail ?? false)"),
|
||
'cpu-bar" aria-hidden="true"',
|
||
))->toBeTrue()
|
||
// And it survives somebody who has switched motion off.
|
||
->and($panel)->toContain('prefers-reduced-motion');
|
||
});
|
||
|
||
it('has no dark mode of its own, in a product that has none', function () {
|
||
// Reported from a phone: the update screen — and only the update screen —
|
||
// came up dark blue. It carried a prefers-color-scheme block nothing else
|
||
// in this product has, so it was the one surface that flipped, and it
|
||
// flipped into Tailwind's slate ramp rather than the product's warm
|
||
// neutrals.
|
||
// Comments stripped first. The panel documents at length what it used to
|
||
// get wrong, naming the exact values — and a scan over the raw file reads
|
||
// that explanation as the offence it warns about. Twice now this file has
|
||
// had to learn the same thing: measure the thing, not the note about it.
|
||
$panel = preg_replace(
|
||
'/\{\{--.*?--\}\}/s',
|
||
'',
|
||
Illuminate\Support\Facades\File::get(resource_path('views/partials/updating-panel.blade.php')),
|
||
);
|
||
|
||
expect($panel)->not->toContain('prefers-color-scheme')
|
||
// The slate values it used. Named, so a paste of the same ramp is
|
||
// caught rather than merely discouraged by a comment.
|
||
->not->toContain('#0f172a')
|
||
->not->toContain('#64748b')
|
||
->not->toContain('#e2e8f0')
|
||
->not->toContain('#0b0f19')
|
||
// And the tokens it should be using instead.
|
||
->toContain('#f6f6f8')
|
||
->toContain('#17171c')
|
||
->toContain('#6e6e7a');
|
||
});
|
||
|
||
it('shows the step in one place, not in two of different sizes', function () {
|
||
// The settings card grew its own little "läuft gerade" block the instant a
|
||
// run started, and up to three seconds later the full-page overlay covered
|
||
// it saying the same thing. Reported as "first a small window, then the big
|
||
// one".
|
||
$card = Illuminate\Support\Facades\File::get(resource_path('views/livewire/admin/settings.blade.php'));
|
||
$layout = Illuminate\Support\Facades\File::get(resource_path('views/layouts/admin.blade.php'));
|
||
|
||
expect($card)->not->toContain("admin_settings.update_offline_hint")
|
||
->and($layout)->toContain('admin_settings.update_offline_hint');
|
||
});
|
||
|
||
it('opens the overlay on the click, not on the next poll', function () {
|
||
// Three seconds is long enough to see the page arrange itself twice.
|
||
$settings = Illuminate\Support\Facades\File::get(app_path('Livewire/Admin/Settings.php'));
|
||
$layout = Illuminate\Support\Facades\File::get(resource_path('views/layouts/admin.blade.php'));
|
||
|
||
expect($settings)->toContain("dispatch('update-started')")
|
||
->and($layout)->toContain('@update-started.window="wasRunning = true"');
|
||
});
|
||
|
||
it('does not cover the console because a button was pressed over nothing', function () {
|
||
// The overlay opens on the event, so the event must only fire once a run is
|
||
// actually pending. A second press, which the channel refuses, must not
|
||
// black out the page again.
|
||
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);
|
||
|
||
$component = Livewire::actingAs(operator('Owner'), 'operator')->test(AdminSettings::class);
|
||
|
||
$component->call('requestUpdate')->assertDispatched('update-started');
|
||
$component->call('requestUpdate')->assertNotDispatched('update-started');
|
||
});
|
||
|
||
it('does not read the agent’s start-up gap as the end of the run', function () {
|
||
// The reported loop, in the one place it can be reproduced: the agent
|
||
// consumes the request file before it resolves the release, and writes
|
||
// `state: running` only once it has decided to go ahead. In between, the
|
||
// endpoint honestly answers "nothing is running" — and the watcher read
|
||
// that as "finished" and reloaded, so the overlay opened on the click,
|
||
// vanished a poll later, and the 503 arrived after it.
|
||
$watcher = Illuminate\Support\Facades\File::get(resource_path('js/app.js'));
|
||
|
||
// The overlay may only close once the SERVER has confirmed a run and then
|
||
// stopped reporting it.
|
||
expect($watcher)->toContain('this.wasRunning && this.serverConfirmed && !state.running')
|
||
// And it may not wait forever: the agent refuses a request with nothing
|
||
// to install by writing nothing at all.
|
||
->and($watcher)->toContain('this.unconfirmedPolls > 20');
|
||
});
|
||
|
||
it('reproduces the gap the console used to reload on', function () {
|
||
// The state the endpoint reports between the two agent steps: the request
|
||
// has been consumed, the status still says idle. `running` is false here
|
||
// and that is correct — the fix is on the reader, not on this value.
|
||
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);
|
||
|
||
app(UpdateChannel::class)->request('owner@example.com');
|
||
expect(app(UpdateChannel::class)->state()['running'])->toBeTrue();
|
||
|
||
// The agent takes the request…
|
||
Illuminate\Support\Facades\File::delete(storage_path('app/deploy/update-request.json'));
|
||
|
||
// …and has not written its status yet.
|
||
expect(app(UpdateChannel::class)->state()['running'])->toBeFalse();
|
||
});
|
||
|
||
it('does not hold the console behind the panel when the application comes back broken', function () {
|
||
// Reported together with a 500: after the update every request failed, the
|
||
// watcher treated each failure as "still restarting", and the overlay
|
||
// stayed up over a console nobody could reach to find out why.
|
||
//
|
||
// A restart is seconds. Two minutes of nothing is an installation that came
|
||
// back broken, and then the operator needs the page.
|
||
$watcher = Illuminate\Support\Facades\File::get(resource_path('js/app.js'));
|
||
|
||
expect($watcher)->toContain('this.failedPolls > 40')
|
||
->toContain('this.stuck = true')
|
||
// A successful answer clears it: one bad minute must not leave a
|
||
// "something is wrong" notice sitting there for the rest of the run.
|
||
->toContain('this.failedPolls = 0');
|
||
});
|
||
|
||
it('offers the way out only once it is true, and only to an operator', function () {
|
||
// An always-present close button would be a lie: the deployment does not
|
||
// stop because somebody dismissed a panel. And the 503 page has no Alpine
|
||
// at all, so the affordance lives behind the same operator flag as the step
|
||
// and the log.
|
||
$panel = Illuminate\Support\Facades\File::get(resource_path('views/partials/updating-panel.blade.php'));
|
||
|
||
expect($panel)->toContain('x-show="stuck"')
|
||
->toContain('@click="dismiss()"');
|
||
|
||
// Two operator-only blocks, both gated by the same flag: the deployment
|
||
// detail and this. The 503 page passes no `detail`, so a customer sees
|
||
// neither the step nor a button suggesting they can call the update off.
|
||
expect(substr_count($panel, "@if (\$detail ?? false)"))->toBe(2);
|
||
});
|
||
|
||
it('runs the workers as the same user as the web process', function () {
|
||
// Blade compiles a view and then touch()es the compiled file to the
|
||
// source's mtime. touch() with an explicit time needs ownership — so a view
|
||
// compiled by a ROOT worker can never be refreshed by the web process
|
||
// again, and every request for it answers 500 with "touch(): Utime failed".
|
||
// A compose service without `user:` runs as root, and the queue is what
|
||
// renders mails.
|
||
$compose = Illuminate\Support\Facades\File::get(base_path('docker-compose.yml'));
|
||
|
||
foreach (['queue', 'reverb', 'scheduler'] as $service) {
|
||
// From the service key to the next one at the same indentation. An
|
||
// earlier version cut at the first "\n ", which is the end of the
|
||
// block's own first line.
|
||
preg_match('/\n '.$service.':\n(.*?)(?=\n \S|\z)/s', $compose, $match);
|
||
|
||
expect($match[1] ?? '')->toContain('user: "www-data"');
|
||
}
|
||
|
||
// And the deployment repairs whatever a root process left behind, at the
|
||
// end of the run as well as at the start.
|
||
expect(substr_count(
|
||
Illuminate\Support\Facades\File::get(base_path('deploy/update.sh')),
|
||
"\nnormalise_ownership\n",
|
||
))->toBe(2);
|
||
});
|
||
|
||
it('keeps answering its own status while the application is down', function () {
|
||
// The deployment's status is the one thing that must be readable DURING
|
||
// the deployment. Behind maintenance mode it answered 503 for the whole
|
||
// run, so the overlay had nothing to show but "still waiting" and could
|
||
// never name the step it was on.
|
||
expect(Illuminate\Support\Facades\File::get(base_path('bootstrap/app.php')))
|
||
->toContain("preventRequestsDuringMaintenance(except: [")
|
||
->toContain("'update/state'")
|
||
->toContain("'*/update/state'");
|
||
});
|
||
|
||
it('does not let a Livewire request during maintenance replace the page', function () {
|
||
// Reported with the network panel open: `update` and `state` both 503, and
|
||
// Livewire's default on a failed request is to render the response body —
|
||
// so a wire:poll that happened to fire mid-deployment swapped the console
|
||
// for Laravel's 503 page. Overlay, then error page, then reload.
|
||
//
|
||
// 503 is not a fault here. It IS the event being watched.
|
||
$app = Illuminate\Support\Facades\File::get(resource_path('js/app.js'));
|
||
|
||
expect($app)->toContain('if (status === 503) {')
|
||
// And the poll that used to fire it stops for the duration.
|
||
->and(Illuminate\Support\Facades\File::get(resource_path('views/livewire/admin/settings.blade.php')))
|
||
->toContain("@if (! \$update['running']) wire:poll");
|
||
});
|