Files
coolify/tests/Feature/Server/ServerPatchesTest.php
T
Andras BacsaiandClaude Opus 5.5 3da85e2c07 feat(patches): mark security updates, reboot state, and risky packages
- Security updates: apt marks a package from a "-security" suite (Debian
  12 now reports oldstable-security). dnf reads `dnf updateinfo list
  --security` (dnf5 keeps updateinfo as an alias of advisory). zypper
  uses `zypper -x patch --dry-run --category security`, because
  `zypper -x list-patches` ignores --category and has no package names.
  pacman and apk cannot tell, so they show no security count.
  https://dnf5.readthedocs.io/en/latest/commands/advisory.8.html
  https://github.com/openSUSE/zypper/blob/master/doc/zypper.8.txt
- Reboot required: /var/run/reboot-required, `needrestart -b` (KSTA 2
  or 3), `dnf needs-restarting -r` (matched by text, because a missing
  plugin also exits 1), and `zypper needs-rebooting` (exit 102).
  https://github.com/liske/needrestart/blob/master/README.batch.md
  https://dnf-plugins-core.readthedocs.io/en/latest/needs_restarting.html
- An explicit risk list per package manager (Docker engine, kernels,
  libc/glibc/musl, systemd, dbus, openssh) replaces the "name contains
  docker/kernel" guess on the page and in every notification channel.
- The page shows security and reboot badges, a security count, and the
  risk of each package. "Reboot server" (with confirmation, update
  policy on the loaded server) schedules the reboot with systemd-run and
  checks the server again five minutes later. Archived (transferred)
  servers are never touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:39:51 +02:00

337 lines
15 KiB
PHP

<?php
use App\Actions\Server\CheckUpdates;
use App\Actions\Server\RebootServer;
use App\Events\ServerPackagesUpdateFinished;
use App\Events\ServerPatchCheckFinished;
use App\Jobs\ServerManagerJob;
use App\Jobs\ServerPatchCheckJob;
use App\Livewire\Server\Security\Patches;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use App\Notifications\Server\ServerPatchCheck;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
Storage::fake('ssh-keys');
$this->withoutVite();
InstanceSettings::forceCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->team->members()->attach($this->admin, ['role' => 'admin']);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
]);
$this->server->settings->forceFill(['is_reachable' => true, 'is_usable' => true])->saveQuietly();
});
function patchesTestResult(int $updates = 1): array
{
return [
'osId' => 'ubuntu',
'package_manager' => 'apt',
'total_updates' => $updates,
'updates' => array_slice([
['package' => 'openssl', 'current_version' => '3.0.13-0ubuntu3.1', 'new_version' => '3.0.13-0ubuntu3.4', 'architecture' => 'amd64', 'repository' => 'noble-updates,noble-security'],
['package' => 'curl', 'current_version' => '8.5.0-2ubuntu10.1', 'new_version' => '8.5.0-2ubuntu10.6', 'architecture' => 'amd64', 'repository' => 'noble-updates'],
], 0, $updates),
];
}
function runPatchesTestJob(Server $server, bool $manual = false): void
{
(new ServerPatchCheckJob($server->fresh(), $manual))->handle();
}
describe('patch check job', function () {
beforeEach(function () {
Notification::fake();
Event::fake([ServerPatchCheckFinished::class]);
$this->team->emailNotificationSettings()->update(['use_instance_email_settings' => true, 'server_patch_email_notifications' => true]);
});
it('saves the result, clears the running flag, and broadcasts to the server team', function () {
CheckUpdates::shouldRun()->andReturn(patchesTestResult(2));
Cache::put(ServerPatchCheckJob::runningCacheKey($this->server), true);
runPatchesTestJob($this->server, manual: true);
$settings = $this->server->settings->fresh();
expect($settings->patch_check_result['total_updates'])->toBe(2)
->and($settings->patch_check_result['updates'][0]['package'])->toBe('openssl')
->and($settings->patch_check_error)->toBeNull()
->and($settings->patch_checked_at)->not->toBeNull()
->and(Cache::has(ServerPatchCheckJob::runningCacheKey($this->server)))->toBeFalse();
Event::assertDispatched(ServerPatchCheckFinished::class, fn ($event) => $event->teamId === $this->team->id && $event->serverUuid === $this->server->uuid);
Notification::assertNothingSent();
});
it('notifies about available updates on a scheduled check', function () {
CheckUpdates::shouldRun()->andReturn(patchesTestResult());
runPatchesTestJob($this->server);
Notification::assertSentTo($this->team, ServerPatchCheck::class);
});
it('sends the failure notification only when the check changes from ok to error', function () {
CheckUpdates::shouldRun()->andReturn(
['osId' => 'ubuntu', 'package_manager' => 'apt', 'error' => 'apt is locked'],
['osId' => 'ubuntu', 'package_manager' => 'apt', 'error' => 'apt is locked'],
patchesTestResult(0),
['osId' => 'ubuntu', 'package_manager' => 'apt', 'error' => 'apt is locked again'],
);
runPatchesTestJob($this->server);
runPatchesTestJob($this->server);
expect($this->server->settings->fresh()->patch_check_error)->toBe('apt is locked');
Notification::assertSentToTimes($this->team, ServerPatchCheck::class, 1);
runPatchesTestJob($this->server);
expect($this->server->settings->fresh()->patch_check_error)->toBeNull();
runPatchesTestJob($this->server);
Notification::assertSentToTimes($this->team, ServerPatchCheck::class, 2);
});
it('saves an error for a manual check of a server that is not functional', function () {
$this->server->settings->forceFill(['is_reachable' => false])->saveQuietly();
CheckUpdates::shouldRun()->never();
runPatchesTestJob($this->server, manual: true);
expect($this->server->settings->fresh()->patch_check_error)->toBe('Server is not reachable or not ready.');
Event::assertDispatched(ServerPatchCheckFinished::class);
});
});
describe('check frequency', function () {
it('uses the server frequency instead of the weekly default', function () {
$default = ServerManagerJob::patchCheckCron($this->server);
expect($default)->toEndWith('* * 0');
$this->server->settings->forceFill(['patch_check_frequency' => 'daily'])->saveQuietly();
expect(ServerManagerJob::patchCheckCron($this->server->fresh()))->toBe('0 0 * * *');
$this->server->settings->forceFill(['patch_check_frequency' => '30 3 * * 1'])->saveQuietly();
expect(ServerManagerJob::patchCheckCron($this->server->fresh()))->toBe('30 3 * * 1');
});
it('accepts presets and cron expressions that run at most once per hour', function (string $frequency, bool $valid) {
expect(Patches::isValidPatchCheckFrequency($frequency))->toBe($valid);
})->with([
['daily', true],
['hourly', true],
['0 3 * * *', true],
['every_minute', false],
['*/30 * * * *', false],
['not a cron', false],
]);
it('saves a valid frequency and rejects an invalid one', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])
->set('patchCheckFrequency', 'daily')
->call('savePatchCheckFrequency')
->assertDispatched('success')
->set('patchCheckFrequency', '* * * * *')
->call('savePatchCheckFrequency')
->assertDispatched('error');
expect($this->server->settings->fresh()->patch_check_frequency)->toBe('daily');
});
});
describe('patches page', function () {
beforeEach(function () {
Queue::fake();
});
it('shows the saved result of the last check', function () {
$this->server->settings->forceFill(['patch_check_result' => patchesTestResult(2), 'patch_checked_at' => now()->subHour()])->saveQuietly();
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])
->assertSee('1 hour ago')
->assertSee('openssl')
->assertSee('8.5.0-2ubuntu10.6');
});
it('queues the check for an admin and shows the progress', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])
->call('checkForUpdates')
->assertSet('checking', true)
->assertSee('Inspecting installed packages');
Queue::assertPushed(ServerPatchCheckJob::class, fn ($job) => $job->manual && $job->server->is($this->server));
});
it('refreshes from the saved result when the check finishes', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$component = Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])->call('checkForUpdates');
$this->server->settings->forceFill(['patch_check_result' => patchesTestResult(), 'patch_checked_at' => now()])->saveQuietly();
Cache::forget(ServerPatchCheckJob::runningCacheKey($this->server));
$component->call('patchCheckFinished', ['serverUuid' => $this->server->uuid])
->assertSet('checking', false)
->assertSee('openssl');
});
it('updates only a package from the saved result', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$this->server->settings->forceFill(['patch_check_result' => patchesTestResult(), 'patch_checked_at' => now()])->saveQuietly();
Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])
->call('updatePackage', 'curl; reboot')
->assertDispatched('error', message: 'This package has no update in the last check.');
});
});
describe('patches authorization', function () {
beforeEach(function () {
Queue::fake();
$this->member = User::factory()->create();
$this->team->members()->attach($this->member, ['role' => 'member']);
});
it('does not let a member open the page', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])->assertForbidden();
});
it('does not show a server of another team', function () {
$otherTeam = Team::factory()->create();
$this->admin->teams()->attach($otherTeam, ['role' => 'owner']);
$this->actingAs($this->admin);
session(['currentTeam' => $otherTeam]);
expect(fn () => Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]))->toThrow(ModelNotFoundException::class);
});
it('uses the role in the server team after the session switches to a team the user owns', function () {
$otherTeam = Team::factory()->create();
$this->member->teams()->attach($otherTeam, ['role' => 'owner']);
$this->member->teams()->updateExistingPivot($this->team->id, ['role' => 'admin']);
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$component = Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]);
// The user is now only a member of the server team, but owns the session team.
$this->member->teams()->updateExistingPivot($this->team->id, ['role' => 'member']);
$this->member->unsetRelation('teams');
session(['currentTeam' => $otherTeam]);
$component->call('checkForUpdates')->assertDispatched('error');
$component->set('patchCheckFrequency', 'daily')->call('savePatchCheckFrequency')->assertDispatched('error');
Queue::assertNothingPushed();
expect($this->server->settings->fresh()->patch_check_frequency)->toBeNull();
});
});
it('runs a new check when a package update finishes', function () {
Queue::fake();
ServerPackagesUpdateFinished::dispatch(['server_uuid' => $this->server->uuid, 'packages' => ['curl']]);
Queue::assertPushed(ServerPatchCheckJob::class, fn ($job) => $job->manual && $job->server->is($this->server));
expect(Cache::has(ServerPatchCheckJob::runningCacheKey($this->server)))->toBeTrue();
});
describe('security, reboot, and risk', function () {
beforeEach(function () {
Queue::fake();
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
});
it('shows security updates, risky packages, and the reboot action', function () {
$result = (new CheckUpdates)->addUpdateDetails([
'osId' => 'ubuntu', 'package_manager' => 'apt', 'total_updates' => 2,
'updates' => [
['package' => 'curl', 'new_version' => '8.5.0-2ubuntu10.6', 'repository' => 'noble-updates'],
['package' => 'docker-ce', 'new_version' => '5:28.0.0-1', 'repository' => 'noble-security'],
],
], 'apt');
$result['reboot_required'] = true;
$this->server->settings->forceFill(['patch_check_result' => $result, 'patch_checked_at' => now()])->saveQuietly();
Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])
->assertSee('1 security update')
->assertSee('Reboot required')
->assertSee('Reboot server')
->assertSee(CheckUpdates::RISK_LABELS['docker'])
->assertSeeInOrder(['docker-ce', 'curl']);
});
it('reboots the server for an admin and checks it again later', function () {
RebootServer::shouldRun()->once()->with(Mockery::on(fn ($server) => $server->is($this->server)));
Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])
->call('rebootServer')
->assertDispatched('success');
});
it('does not let a member of the server team reboot after the session switches to a team the user owns', function () {
$user = User::factory()->create();
$this->team->members()->attach($user, ['role' => 'admin']);
$otherTeam = Team::factory()->create();
$user->teams()->attach($otherTeam, ['role' => 'owner']);
$this->actingAs($user);
$component = Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]);
$user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']);
$user->unsetRelation('teams');
session(['currentTeam' => $otherTeam]);
RebootServer::shouldRun()->never();
$component->call('rebootServer')->assertDispatched('error');
});
it('does not reboot a server that another instance manages', function () {
$this->server->forceFill(['server_metadata' => ['transfer' => ['status' => 'transferred']]])->saveQuietly();
expect(fn () => RebootServer::run($this->server->fresh()))->toThrow(RuntimeException::class, 'managed by another Coolify instance');
});
it('marks the risky packages in the notification', function () {
$result = (new CheckUpdates)->addUpdateDetails(['updates' => [
['package' => 'linux-image-6.8.0-45-generic', 'repository' => 'noble-security', 'current_version' => '1', 'new_version' => '2', 'architecture' => 'amd64'],
['package' => 'curl', 'repository' => 'noble-updates', 'current_version' => '1', 'new_version' => '2', 'architecture' => 'amd64'],
]], 'apt');
$payload = (new ServerPatchCheck($this->server, [...$result, 'total_updates' => 2, 'reboot_required' => true]))->toWebhook();
expect($payload['critical_packages_count'])->toBe(1)
->and($payload['security_updates'])->toBe(1)
->and($payload['reboot_required'])->toBeTrue();
});
});