Files
projectsend/tests/Feature/Platform/UpdateCommandTest.php
T
Ignacio Nelson ed0d36de25 Reduce a manual update to one command that asks first (#1628)
Updating a server install cost nine artisan invocations plus a PHP-FPM
reload, written out in three places that had already drifted apart. One
of those steps is silently fatal to skip: with opcache.validate_timestamps
off — what production guides recommend and what our own image ships — the
database moves to the new version while every visitor keeps being served
the old code, and artisan reports the new version throughout.

`sudo ./update.sh` is now the whole procedure. It asks whether to check
GitHub, asks whether to download the release and verifies the checksum
published beside it, and asks whether there is a backup — offering to dump
the database when the answer is no. Then it takes the site down, replaces
the files, runs the update, reloads PHP-FPM, restarts the worker and
brings the site back. The application still has no self-updater: nothing
is fetched or applied unless somebody runs this and answers yes.

Underneath it is `php artisan projectsend:update`, which is everything an
update does that needs no root — and now the only definition of it. Both
container entrypoints call it instead of carrying their own copy of the
sequence, so the two paths cannot drift again.

Three findings worth keeping in the record, all from rehearsing rather
than reasoning:

  - queue:restart has to come last. It writes its signal into the cache,
    so clearing the cache afterwards deletes it and the worker runs old
    code forever.
  - optimize:clear is not safe to recommend. It runs cache:clear, which
    on Redis is FLUSHDB — harmless on the default two-database layout,
    but on a single-database Redis it takes the sessions and the queue
    with it. The compiled caches are cleared individually instead.
  - update.sh overwrites itself mid-run, because the zip contains it and
    bash reads its own script lazily by byte offset. It re-execs from a
    temporary copy before touching anything.

And when the reload is skipped anyway, the application now says so:
projectsend:update records the version it applied, and any staff page
compares that with what the running process actually compiled. The same
check catches the mirror image — new files in place, update never run.

Rehearsed end to end against real installs: a container upgrade (69 to 73
migrations, key and data intact, healthy), a scripted update on a real
nginx + php-fpm install with OPcache pinned (web process moved 2.1.0 to
2.1.1), the skipped-reload case (banner appears naming both versions, and
clears on reload), the refusals (downgrade, non-release zip, truncated
zip, URL passed to --zip, non-root), a database taken down mid-update
(site comes back out of maintenance mode by itself), and a real download
of the published 2.0.0 zip with its checksum verified.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:29:20 -03:00

178 lines
6.7 KiB
PHP

<?php
declare(strict_types=1);
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\Models\Role;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Modules\Platform\Updates\UpdateInstallation;
use Illuminate\Console\OutputStyle;
/**
* The ordering constraints inside UpdateInstallation are invisible in its
* result and expensive when wrong — a queue:restart before a cache clear
* leaves a worker on old code indefinitely, and config:cache breaks
* TRUSTED_PROXIES silently. An ordered list of the commands it ran is the
* only thing that can assert them, so the artisan call is a seam.
*/
class RecordingUpdate extends UpdateInstallation
{
/** @var list<string> */
public array $calls = [];
/** @var array<string, int> */
public array $exitCodes = [];
/** @var array{route: bool, event: bool, config: bool} */
public array $warm = ['route' => false, 'event' => false, 'config' => false];
protected function artisan(string $command, array $parameters = [], ?OutputStyle $output = null): int
{
$this->calls[] = $command;
return $this->exitCodes[$command] ?? 0;
}
protected function warmCaches(): array
{
return $this->warm;
}
}
/**
* @param array{route?: bool, event?: bool, config?: bool} $warm
* @param array<string, int> $exitCodes
*/
function recordingUpdate(array $warm = [], array $exitCodes = []): RecordingUpdate
{
$fake = new RecordingUpdate(
app(Illuminate\Contracts\Foundation\Application::class),
app(App\Modules\Identity\Permissions\EnsureSystemRoles::class),
app(Settings::class),
);
$fake->warm = [...$fake->warm, ...$warm];
$fake->exitCodes = $exitCodes;
app()->instance(UpdateInstallation::class, $fake);
return $fake;
}
test('it migrates, ensures roles, links storage and restarts the queue', function () {
$fake = recordingUpdate();
$this->artisan('projectsend:update')->assertSuccessful();
expect($fake->calls)->toContain('migrate', 'storage:link', 'queue:restart')
->and(array_search('migrate', $fake->calls, true))->toBe(0);
});
// queue:restart writes its signal into the cache. Anything that clears the
// cache afterwards deletes it, and the worker keeps running the old code
// with nothing to show for it.
test('queue:restart is the last thing it does', function () {
$fake = recordingUpdate();
$this->artisan('projectsend:update')->assertSuccessful();
expect(array_key_last($fake->calls))->toBe(array_search('queue:restart', $fake->calls, true));
});
// config:cache stops TRUSTED_PROXIES from being read at all — see
// INSTALL.md. Nothing in an update may ever put one in place.
test('it never caches the configuration', function () {
$fake = recordingUpdate(['config' => true]);
$this->artisan('projectsend:update')->assertSuccessful();
expect($fake->calls)->toContain('config:clear')
->and($fake->calls)->not->toContain('config:cache');
});
// Laravel's Redis cache store implements cache:clear as FLUSHDB, which on
// a single-database Redis takes the sessions and the queue with it.
test('it does not flush the application cache', function () {
$fake = recordingUpdate();
$this->artisan('projectsend:update')->assertSuccessful();
expect($fake->calls)->not->toContain('cache:clear')
->and($fake->calls)->not->toContain('optimize:clear');
});
test('it rebuilds only the caches that were in place beforehand', function (array $warm, array $expected) {
$fake = recordingUpdate($warm);
$this->artisan('projectsend:update')->assertSuccessful();
$rebuilt = array_values(array_filter($fake->calls, fn (string $call): bool => str_ends_with($call, ':cache')));
expect($rebuilt)->toBe($expected);
})->with([
'nothing cached — a container, or an install that never optimised' => [[], []],
'routes only' => [['route' => true], ['route:cache', 'event:cache', 'view:cache']],
'events only' => [['event' => true], ['route:cache', 'event:cache', 'view:cache']],
'both' => [['route' => true, 'event' => true], ['route:cache', 'event:cache', 'view:cache']],
]);
test('a failed migration stops everything and records nothing', function () {
$fake = recordingUpdate([], ['migrate' => 1]);
$this->artisan('projectsend:update')->assertFailed();
expect($fake->calls)->toBe(['migrate'])
->and(app(Settings::class)->get(Setting::AppliedVersion))->toBe('');
});
// A route file that will not compile leaves a slower site. Failing the
// update over it would leave a broken one.
test('a cache that will not rebuild is a warning, not a failure', function () {
recordingUpdate(['route' => true], ['route:cache' => 1]);
$this->artisan('projectsend:update')->assertSuccessful();
expect(app(Settings::class)->get(Setting::AppliedVersion))->toBe(config('projectsend.version'));
});
test('it records the version it applied', function () {
$this->artisan('projectsend:update')->assertSuccessful();
expect(app(Settings::class)->get(Setting::AppliedVersion))->toBe(config('projectsend.version'))
->and(app(Settings::class)->get(Setting::AppliedVersionAt))->not->toBe('');
});
// The real thing, not the seam: both entrypoints run this on every boot,
// so a second run has to be as uneventful as the first.
test('running it twice is uneventful', function () {
$this->artisan('projectsend:update')->assertSuccessful();
$this->artisan('projectsend:update')->assertSuccessful();
expect(app(Settings::class)->get(Setting::AppliedVersion))->toBe(config('projectsend.version'));
});
// Not through the seam: this is the one assertion that the real wiring
// runs, and EnsureSystemRoles is the part of an update that a migration
// cannot do for itself. AccountManager rather than Uploader — the latter
// is legacy and deliberately never seeded.
test('it puts back a system role somebody deleted', function () {
Role::query()->where('name', SystemRole::AccountManager->value)->delete();
$this->artisan('projectsend:update')->assertSuccessful();
expect(Role::query()->where('name', SystemRole::AccountManager->value)->exists())->toBeTrue();
});
// The two shell files are the one place the sequence could quietly grow a
// second definition again, and nothing else in the suite would notice.
test('both container entrypoints run the command rather than their own sequence', function () {
foreach (['docker/production/entrypoint.sh', 'docker/app/entrypoint.sh'] as $entrypoint) {
$contents = file_get_contents(base_path($entrypoint));
expect($contents)->toContain('projectsend:update')
->and($contents)->not->toContain('projectsend:ensure-roles')
->and($contents)->not->toContain('migrate --force');
}
});