Files
coolify/tests/Unit/Actions/Server/ServerPatchCommandsTest.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

213 lines
9.9 KiB
PHP

<?php
use App\Actions\Server\CheckUpdates;
use App\Actions\Server\UpdatePackage;
use App\Models\Server;
function patchCommandsNonRootServer(): Server
{
$server = Mockery::mock(Server::class)->makePartial();
$server->shouldReceive('getAttribute')->with('user')->andReturn('ubuntu');
$server->shouldReceive('setAttribute')->andReturnSelf();
$server->user = 'ubuntu';
return $server;
}
afterEach(function () {
Mockery::close();
});
it('updates apt packages without prompts and keeps changed configuration files', function () {
expect(UpdatePackage::commands('apt'))->toBe([
'env DEBIAN_FRONTEND=noninteractive apt-get update -qq',
'env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold upgrade',
])->and(UpdatePackage::commands('apt', "'openssl'"))->toBe([
'env DEBIAN_FRONTEND=noninteractive apt-get update -qq',
"env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold install --only-upgrade 'openssl'",
]);
});
it('runs the apt update commands with sudo for a non-root user', function () {
$parsed = parseCommandsByLineForSudo(collect(UpdatePackage::commands('apt', "'openssl'")), patchCommandsNonRootServer());
expect($parsed)->toBe([
'sudo env DEBIAN_FRONTEND=noninteractive apt-get update -qq',
"sudo env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold install --only-upgrade 'openssl'",
]);
});
it('allows only a full system upgrade on Arch Linux', function () {
expect(UpdatePackage::commands('pacman'))->toBe(['pacman -Syu --noconfirm'])
->and(UpdatePackage::commands('pacman', "'curl'"))->toBeNull();
$server = Mockery::mock(Server::class)->makePartial();
$server->shouldReceive('serverStatus')->andReturn(true);
expect(UpdatePackage::run($server, 'arch', 'curl', 'pacman'))
->toHaveKey('error', 'This operating system supports only a full system upgrade. Use "Update all packages".');
});
it('runs the pacman check as one sudo shell script for a non-root user', function () {
$parsed = parseCommandsByLineForSudo(collect([CheckUpdates::PACMAN_CHECK_COMMAND]), patchCommandsNonRootServer());
expect($parsed)->toBe(['sudo '.CheckUpdates::PACMAN_CHECK_COMMAND])
->and(CheckUpdates::PACMAN_CHECK_COMMAND)->not->toContain('pacman -Sy --noconfirm')
->and(CheckUpdates::PACMAN_CHECK_COMMAND)->toContain('--dbpath');
});
it('parses checkupdates output', function () {
$output = <<<'OUTPUT'
libarchive 3.8.9-1 -> 3.8.10-1
zlib 1:1.3.2-3 -> 1:1.3.2-4
OUTPUT;
expect((new CheckUpdates)->parsePacmanOutput($output)['updates'])->toBe([
['package' => 'libarchive', 'current_version' => '3.8.9-1', 'new_version' => '3.8.10-1', 'architecture' => 'unknown', 'repository' => 'unknown'],
['package' => 'zlib', 'current_version' => '1:1.3.2-3', 'new_version' => '1:1.3.2-4', 'architecture' => 'unknown', 'repository' => 'unknown'],
]);
});
it('parses dnf4 repoquery output with the installed versions and without the arch suffix', function () {
// Rocky Linux 9 (dnf 4.14): dnf4 adds its own newline after --qf, so every second line is empty.
$upgrades = "acl x86_64 2.4.0-1.el9_8 baseos\n\nopenssl x86_64 1:3.5.8-2.el9_8 baseos\n\npython3.11 x86_64 3.11.13-1.el9 appstream\n\n";
$installed = "acl x86_64 2.3.1-3.el9\nopenssl x86_64 3.0.1-41.el9_0\nbash x86_64 5.1.8-4.el9\n";
expect((new CheckUpdates)->parseDnfOutput($upgrades, $installed))->toBe([
'total_updates' => 3,
'updates' => [
['package' => 'acl', 'new_version' => '2.4.0-1.el9_8', 'repository' => 'baseos', 'architecture' => 'x86_64', 'current_version' => '2.3.1-3.el9'],
['package' => 'openssl', 'new_version' => '1:3.5.8-2.el9_8', 'repository' => 'baseos', 'architecture' => 'x86_64', 'current_version' => '3.0.1-41.el9_0'],
['package' => 'python3.11', 'new_version' => '3.11.13-1.el9', 'repository' => 'appstream', 'architecture' => 'x86_64', 'current_version' => 'unknown'],
],
]);
});
it('parses dnf5 repoquery output', function () {
// Fedora 44 (dnf 5.4).
$upgrades = "ca-certificates noarch 2026.2.90_v9.0.317-1.fc44 updates\ncurl x86_64 8.18.0-14.fc44 updates\n";
$installed = "curl x86_64 8.18.0-12.fc44\n";
$updates = (new CheckUpdates)->parseDnfOutput($upgrades, $installed)['updates'];
expect($updates)->toHaveCount(2)
->and($updates[0])->toMatchArray(['package' => 'ca-certificates', 'architecture' => 'noarch', 'current_version' => 'unknown'])
->and($updates[1])->toMatchArray(['package' => 'curl', 'new_version' => '8.18.0-14.fc44', 'current_version' => '8.18.0-12.fc44']);
});
it('updates a dnf package by its name', function () {
expect(UpdatePackage::commands('dnf', "'python3.11'"))->toBe(["dnf upgrade -y 'python3.11'"]);
});
it('marks apt updates from a security suite', function () {
// Ubuntu 24.04 and Debian 12 (now oldstable-security) `apt list --upgradable` lines.
$output = <<<'OUTPUT'
Listing... Done
libsystemd0/noble-security 255.4-1ubuntu8.18 amd64 [upgradable from: 255.4-1ubuntu8.17]
openssl/noble-updates,noble-security 3.0.13-0ubuntu3.4 amd64 [upgradable from: 3.0.13-0ubuntu3.1]
perl-base/oldstable-security 5.36.0-7+deb12u4 amd64 [upgradable from: 5.36.0-7+deb12u3]
curl/noble-updates 8.5.0-2ubuntu10.6 amd64 [upgradable from: 8.5.0-2ubuntu10.1]
OUTPUT;
$action = new CheckUpdates;
$result = $action->addUpdateDetails($action->parseAptOutput($output), 'apt');
expect(collect($result['updates'])->pluck('security', 'package')->all())->toBe([
'libsystemd0' => true,
'openssl' => true,
'perl-base' => true,
'curl' => false,
])->and($result['security_updates'])->toBe(3)
->and($result['updates'][0]['risk'])->toBe('system');
});
it('reads the security packages of dnf4 and dnf5 advisories', function () {
// Rocky Linux 9 (dnf4) and Fedora 42 (dnf5) `dnf updateinfo list --security`.
$dnf4 = <<<'OUTPUT'
RLSA-2026:42736 Important/Sec. acl-2.4.0-1.el9_8.x86_64
RLSA-2023:0340 Moderate/Sec. bash-5.1.8-6.el9_1.x86_64
RLSA-2025:23343 Moderate/Sec. binutils-gold-2.35.2-67.el9_7.1.x86_64
OUTPUT;
$dnf5 = <<<'OUTPUT'
Name Type Severity Package Issued
FEDORA-2026-11d7d4d8f3 security Moderate vim-data-2:9.2.390-1.fc42.noarch 2026-05-07 01:26:58
FEDORA-2026-7af660d639 security Moderate openssl-libs-1:3.2.6-4.fc42.x86_64 2026-05-08 01:24:13
OUTPUT;
$action = new CheckUpdates;
expect($action->parseDnfSecurityPackages($dnf4))->toBe(['acl', 'bash', 'binutils-gold'])
->and($action->parseDnfSecurityPackages($dnf5))->toBe(['vim-data', 'openssl-libs']);
$result = $action->addUpdateDetails(['updates' => [
['package' => 'openssl-libs', 'repository' => 'updates'],
['package' => 'kernel-core', 'repository' => 'updates'],
]], 'dnf', $action->parseDnfSecurityPackages($dnf5));
expect($result['security_updates'])->toBe(1)
->and($result['updates'][0]['security'])->toBeTrue()
->and($result['updates'][1])->toMatchArray(['security' => false, 'risk' => 'kernel']);
});
it('reads the packages that zypper security patches upgrade', function () {
$output = <<<'XML'
<?xml version='1.0'?>
<stream>
<message type="info">Loading repository data...</message>
<install-summary download-size="1024" space-usage-diff="0" packages-to-change="3">
<to-install>
<solvable type="patch" kind="patch" name="openSUSE-SLE-15.6-2024-1234" edition="1" arch="noarch"/>
</to-install>
<to-upgrade>
<solvable type="package" kind="package" name="openssh" edition="9.6p1-150600.6.3.1" arch="x86_64" edition-old="9.6p1-150600.6.1"/>
<solvable kind="package" name="libopenssl3" edition="3.1.4-150600.5.7.1" arch="x86_64"/>
</to-upgrade>
</install-summary>
</stream>
XML;
expect((new CheckUpdates)->parseZypperSecurityPackages($output))->toBe(['openssh', 'libopenssl3'])
->and((new CheckUpdates)->parseZypperSecurityPackages('<stream><message>Nothing to do.</message></stream>'))->toBe([]);
});
it('does not report a security count when the package manager cannot tell', function () {
$result = (new CheckUpdates)->addUpdateDetails(['updates' => [['package' => 'linux', 'repository' => 'unknown']]], 'pacman');
expect($result['security_updates'])->toBeNull()
->and($result['updates'][0])->toMatchArray(['security' => false, 'risk' => 'kernel']);
});
it('classifies risky packages per package manager', function (string $packageManager, string $package, ?string $risk) {
expect(CheckUpdates::riskFor($packageManager, $package))->toBe($risk);
})->with([
['apt', 'docker-ce', 'docker'],
['apt', 'containerd.io', 'docker'],
['apt', 'linux-image-6.8.0-45-generic', 'kernel'],
['apt', 'libc6', 'system'],
['apt', 'openssh-server', 'system'],
['apt', 'docker-compose', null],
['apt', 'linux-firmware', null],
['apt', 'curl', null],
['dnf', 'kernel-core', 'kernel'],
['dnf', 'glibc', 'system'],
['dnf', 'moby-engine', 'docker'],
['zypper', 'kernel-default', 'kernel'],
['pacman', 'linux-lts', 'kernel'],
['pacman', 'linux-firmware', null],
['apk', 'docker-engine', 'docker'],
['apk', 'musl', 'system'],
]);
it('reads the reboot status and runs its check as one sudo shell script', function () {
$action = new CheckUpdates;
expect($action->parseRebootStatus("REBOOT=yes\n"))->toBeTrue()
->and($action->parseRebootStatus("REBOOT=no\n"))->toBeFalse()
->and($action->parseRebootStatus("REBOOT=unknown\n"))->toBeNull()
->and($action->parseRebootStatus(''))->toBeNull();
$server = patchCommandsNonRootServer();
foreach ([CheckUpdates::REBOOT_CHECK_COMMAND, CheckUpdates::DNF_SECURITY_COMMAND, CheckUpdates::ZYPPER_SECURITY_COMMAND] as $command) {
expect(parseCommandsByLineForSudo(collect([$command]), $server))->toBe(['sudo '.$command]);
}
});