diff --git a/app/Actions/Server/ConfigureAutomaticSecurityUpdates.php b/app/Actions/Server/ConfigureAutomaticSecurityUpdates.php new file mode 100644 index 0000000000..6d1e046291 --- /dev/null +++ b/app/Actions/Server/ConfigureAutomaticSecurityUpdates.php @@ -0,0 +1,171 @@ +-security` codename (for Debian 12 the suite is now `oldstable-security`). The Ubuntu Pro (ESM) + * security origins are kept, as in the Ubuntu default file. + * The time is set with a drop-in for `apt-daily-upgrade.timer`. + * + * dnf (RHEL family, Fedora): dnf-automatic with `upgrade_type = security` and `apply_updates = yes` in + * `/etc/dnf/automatic.conf`. dnf4 reads only that file (https://dnf.readthedocs.io/en/latest/automatic.html); + * dnf5 reads its defaults from /usr/share/dnf5/dnf5-plugins/automatic.conf and the overrides from that file + * (https://dnf5.readthedocs.io/en/latest/dnf5_plugins/automatic.8.html). The timer is `dnf5-automatic.timer` + * on dnf5 and `dnf-automatic.timer` on dnf4. + * + * Commands are written for non-root SSH users: files are written with `tee`, and multi-step logic is one + * `sh -c` script, so `parseCommandsByLineForSudo()` only puts sudo in front. + */ +class ConfigureAutomaticSecurityUpdates +{ + use AsAction; + + public const TIME_PATTERN = '/^([01]\d|2[0-3]):[0-5]\d$/'; + + public const APT_CONFIG_FILE = '/etc/apt/apt.conf.d/52coolify-unattended-upgrades'; + + public const APT_TIMER_DROP_IN_DIRECTORY = '/etc/systemd/system/apt-daily-upgrade.timer.d'; + + public const DNF_CONFIG_FILE = '/etc/dnf/automatic.conf'; + + private const SUPPORTED_PACKAGE_MANAGERS = ['apt', 'dnf']; + + public function handle(Server $server, string $packageManager, bool $enabled, string $time): void + { + if (preg_match(self::TIME_PATTERN, $time) !== 1) { + throw new InvalidArgumentException('The time must be in HH:MM format (00:00 to 23:59).'); + } + if (! self::supportFor($packageManager)) { + throw new RuntimeException("Automatic security updates are not supported for the package manager {$packageManager}."); + } + + instant_remote_process(self::commands($packageManager, $enabled, $time), $server); + } + + public static function supportFor(?string $packageManager): bool + { + return in_array($packageManager, self::SUPPORTED_PACKAGE_MANAGERS, true); + } + + /** + * @return list + */ + public static function commands(string $packageManager, bool $enabled, string $time): array + { + return match ($packageManager) { + 'apt' => $enabled ? self::aptEnableCommands($time) : self::aptDisableCommands(), + 'dnf' => $enabled ? self::dnfEnableCommands($time) : self::dnfDisableCommands(), + default => throw new RuntimeException("Automatic security updates are not supported for the package manager {$packageManager}."), + }; + } + + public static function aptConfig(): string + { + return <<<'CONF' + // Managed by Coolify: install security updates only. + APT::Periodic::Update-Package-Lists "1"; + APT::Periodic::Unattended-Upgrade "1"; + #clear Unattended-Upgrade::Allowed-Origins; + #clear Unattended-Upgrade::Origins-Pattern; + Unattended-Upgrade::Origins-Pattern { + "origin=Debian,codename=${distro_codename}-security,label=Debian-Security"; + "origin=Debian,codename=${distro_codename},label=Debian-Security"; + "origin=Ubuntu,archive=${distro_codename}-security"; + "origin=UbuntuESMApps,archive=${distro_codename}-apps-security"; + "origin=UbuntuESM,archive=${distro_codename}-infra-security"; + }; + + CONF; + } + + public static function timerDropIn(string $time): string + { + return "[Timer]\nOnCalendar=\nOnCalendar=*-*-* {$time}\nRandomizedDelaySec=0\n"; + } + + /** + * @return list + */ + private static function aptEnableCommands(string $time): array + { + $dropIn = self::APT_TIMER_DROP_IN_DIRECTORY.'/coolify.conf'; + + return [ + "sh -c 'command -v unattended-upgrade >/dev/null 2>&1 || { apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y unattended-upgrades; }'", + 'mkdir -p '.self::APT_TIMER_DROP_IN_DIRECTORY, + "echo '".base64_encode(self::aptConfig())."' | base64 -d | tee ".self::APT_CONFIG_FILE.' > /dev/null', + "echo '".base64_encode(self::timerDropIn($time))."' | base64 -d | tee {$dropIn} > /dev/null", + 'chmod 644 '.self::APT_CONFIG_FILE." {$dropIn}", + 'systemctl daemon-reload', + 'systemctl enable --now apt-daily.timer apt-daily-upgrade.timer', + ]; + } + + /** + * @return list + */ + private static function aptDisableCommands(): array + { + return [ + 'rm -f '.self::APT_CONFIG_FILE.' '.self::APT_TIMER_DROP_IN_DIRECTORY.'/coolify.conf', + 'systemctl daemon-reload', + ]; + } + + /** + * The settings are removed wherever they are and written again right below `[commands]`, which works for + * the full dnf4 file and for a missing or short dnf5 override file. + * + * @return list + */ + private static function dnfEnableCommands(string $time): array + { + $file = self::DNF_CONFIG_FILE; + $dropIn = base64_encode(self::timerDropIn($time)); + + return [ + 'dnf install -y dnf-automatic', + "sh -c 'set -e; f={$file}; touch \$f; " + .'sed -i -E "/^[[:space:]]*(upgrade_type|apply_updates)[[:space:]]*=/d" $f; ' + .'grep -q "^\[commands\]" $f || printf "\n[commands]\n" >> $f; ' + .'sed -i -e "/^\[commands\]/a upgrade_type = security" -e "/^\[commands\]/a apply_updates = yes" $f; ' + ."chmod 644 \$f'", + "sh -c 'set -e; ".self::dnfTimerSelection() + .'mkdir -p /etc/systemd/system/$t.d; ' + ."echo {$dropIn} | base64 -d > /etc/systemd/system/\$t.d/coolify.conf; " + .'chmod 644 /etc/systemd/system/$t.d/coolify.conf; ' + ."systemctl daemon-reload; systemctl enable --now \$t'", + ]; + } + + /** + * @return list + */ + private static function dnfDisableCommands(): array + { + return [ + "sh -c 'set -e; ".self::dnfTimerSelection() + .'if [ -e /usr/lib/systemd/system/$t ]; then systemctl disable --now $t; fi; ' + ."rm -f /etc/systemd/system/\$t.d/coolify.conf; systemctl daemon-reload'", + ]; + } + + /** + * Sets `$t` to the timer unit of the installed dnf-automatic (dnf5 or dnf4). + */ + private static function dnfTimerSelection(): string + { + return 't=dnf-automatic.timer; if [ -e /usr/lib/systemd/system/dnf5-automatic.timer ]; then t=dnf5-automatic.timer; fi; '; + } +} diff --git a/app/Livewire/Server/Security/Patches.php b/app/Livewire/Server/Security/Patches.php index ea1c1f5625..7df4dbd521 100644 --- a/app/Livewire/Server/Security/Patches.php +++ b/app/Livewire/Server/Security/Patches.php @@ -2,6 +2,7 @@ namespace App\Livewire\Server\Security; +use App\Actions\Server\ConfigureAutomaticSecurityUpdates; use App\Actions\Server\RebootServer; use App\Actions\Server\UpdatePackage; use App\Jobs\ServerPatchCheckJob; @@ -35,6 +36,10 @@ class Patches extends Component public string $patchCheckFrequency = ''; + public bool $autoSecurityUpdates = false; + + public string $autoSecurityUpdatesTime = '03:00'; + public function getListeners() { return $this->teamChannelListeners([ @@ -47,7 +52,14 @@ class Patches extends Component $this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); $this->authorize('viewSecurity', $this->server); $this->loadResult(); + $this->syncSettings(); + } + + private function syncSettings(): void + { $this->patchCheckFrequency = (string) $this->server->settings->patch_check_frequency; + $this->autoSecurityUpdates = (bool) $this->server->settings->is_auto_security_updates_enabled; + $this->autoSecurityUpdatesTime = (string) ($this->server->settings->auto_security_updates_time ?? '03:00'); } public function loadResult(): void @@ -116,20 +128,43 @@ class Patches extends Component } } - public function savePatchCheckFrequency() + public function submit() { try { $this->authorize('update', $this->server); + $settings = $this->server->settings; + $frequency = trim($this->patchCheckFrequency); if ($frequency !== '' && ! self::isValidPatchCheckFrequency($frequency)) { - $this->patchCheckFrequency = (string) $this->server->settings->patch_check_frequency; throw new \Exception('Invalid check frequency. Use a cron expression or a preset (daily, weekly, monthly) that runs at most once per hour.'); } - $this->server->settings->patch_check_frequency = $frequency === '' ? null : $frequency; - $this->server->settings->save(); - $this->patchCheckFrequency = $frequency; - $this->dispatch('success', 'Check frequency saved.'); + $time = trim($this->autoSecurityUpdatesTime); + if (preg_match(ConfigureAutomaticSecurityUpdates::TIME_PATTERN, $time) !== 1) { + throw new \Exception('Invalid time. Use the 24-hour format HH:MM, for example 03:00.'); + } + + $wasEnabled = (bool) $settings->is_auto_security_updates_enabled; + $autoUpdatesChanged = $this->autoSecurityUpdates !== $wasEnabled + || ($this->autoSecurityUpdates && $time !== $settings->auto_security_updates_time); + if ($autoUpdatesChanged) { + $this->ensureManagedHere(); + $packageManager = data_get($settings->patch_check_result, 'package_manager'); + if (! ConfigureAutomaticSecurityUpdates::supportFor($packageManager)) { + throw new \Exception('Automatic security updates are supported only on Debian, Ubuntu, and RHEL-family servers. Run "Check for updates" first, so Coolify knows the package manager.'); + } + ConfigureAutomaticSecurityUpdates::run($this->server, $packageManager, $this->autoSecurityUpdates, $time); + } + + $settings->patch_check_frequency = $frequency === '' ? null : $frequency; + $settings->is_auto_security_updates_enabled = $this->autoSecurityUpdates; + $settings->auto_security_updates_time = $time; + $settings->save(); + $this->syncSettings(); + $this->dispatch('success', 'Patch settings saved.'); } catch (\Throwable $e) { + $this->server->settings->refresh(); + $this->syncSettings(); + return handleError($e, $this); } } diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 5519e1effa..53a63cc6ee 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -157,6 +157,8 @@ class ServerSetting extends Model 'patch_check_result', 'patch_check_error', 'patch_checked_at', + 'is_auto_security_updates_enabled', + 'auto_security_updates_time', ]; /** @@ -196,6 +198,7 @@ class ServerSetting extends Model 'server_disk_usage_notification_interval_hours' => 'integer', 'patch_check_result' => 'array', 'patch_checked_at' => 'datetime', + 'is_auto_security_updates_enabled' => 'boolean', ]; /** diff --git a/database/migrations/2026_10_19_120000_add_auto_security_updates_to_server_settings_table.php b/database/migrations/2026_10_19_120000_add_auto_security_updates_to_server_settings_table.php new file mode 100644 index 0000000000..507da0a86a --- /dev/null +++ b/database/migrations/2026_10_19_120000_add_auto_security_updates_to_server_settings_table.php @@ -0,0 +1,23 @@ +boolean('is_auto_security_updates_enabled')->default(false); + $table->string('auto_security_updates_time')->default('03:00'); + }); + } + + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn(['is_auto_security_updates_enabled', 'auto_security_updates_time']); + }); + } +}; diff --git a/docs/server-transfer-full-coverage.md b/docs/server-transfer-full-coverage.md index daabfaaec5..1574983fc3 100644 --- a/docs/server-transfer-full-coverage.md +++ b/docs/server-transfer-full-coverage.md @@ -32,7 +32,7 @@ When you add a model or a setting to the transfer, add it to this file too. | ➕ | Server timezone | `Europe/Budapest` | | ➕ | Docker cleanup | force cleanup on, frequency `0 4 * * *`, threshold 70, delete unused volumes on, delete unused networks on | | ➕ | Disk usage notification | threshold 70, check frequency `*/30 * * * *`, interval 12 h | -| ➕ | Security > Patches | check frequency `daily` (the last check result is not moved; the target shows "never" until its first check) | +| ➕ | Security > Patches | check frequency `daily`, automatic security updates on at `04:30` (the last check result is not moved; the target shows "never" until its first check) | | ➕ | Terminal | terminal access off | | ➕ | Image retention | "disable application image retention" on | | ➕ | Labels / Traefik | generate exact labels on, Traefik certificate resolver `custom` | diff --git a/resources/views/livewire/server/security/patches.blade.php b/resources/views/livewire/server/security/patches.blade.php index 77d0e6f8c5..30a95ad2b3 100644 --- a/resources/views/livewire/server/security/patches.blade.php +++ b/resources/views/livewire/server/security/patches.blade.php @@ -198,18 +198,42 @@ @endif -
+ + + - - @can('update', $server) - Save - @endcan - + + + @if (\App\Actions\Server\ConfigureAutomaticSecurityUpdates::supportFor($packageManager)) +
+ + +
+ @elseif ($packageManager) + + Automatic security updates are available for Debian, Ubuntu, and RHEL-family servers + (apt and dnf). This server uses {{ $packageManager }}. + + @else + + Coolify needs one successful check to know the package manager of this server. + + @endif +
diff --git a/tests/Feature/Server/ServerPatchesTest.php b/tests/Feature/Server/ServerPatchesTest.php index 6f2282e529..99378339cd 100644 --- a/tests/Feature/Server/ServerPatchesTest.php +++ b/tests/Feature/Server/ServerPatchesTest.php @@ -1,6 +1,7 @@ $this->server->uuid]) ->set('patchCheckFrequency', 'daily') - ->call('savePatchCheckFrequency') + ->call('submit') ->assertDispatched('success') ->set('patchCheckFrequency', '* * * * *') - ->call('savePatchCheckFrequency') + ->call('submit') ->assertDispatched('error'); expect($this->server->settings->fresh()->patch_check_frequency)->toBe('daily'); @@ -249,7 +250,7 @@ describe('patches authorization', function () { session(['currentTeam' => $otherTeam]); $component->call('checkForUpdates')->assertDispatched('error'); - $component->set('patchCheckFrequency', 'daily')->call('savePatchCheckFrequency')->assertDispatched('error'); + $component->set('patchCheckFrequency', 'daily')->call('submit')->assertDispatched('error'); Queue::assertNothingPushed(); expect($this->server->settings->fresh()->patch_check_frequency)->toBeNull(); @@ -334,3 +335,69 @@ describe('security, reboot, and risk', function () { ->and($payload['reboot_required'])->toBeTrue(); }); }); + +describe('automatic security updates', function () { + beforeEach(function () { + Queue::fake(); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + $this->server->settings->forceFill(['patch_check_result' => patchesTestResult(0), 'patch_checked_at' => now()])->saveQuietly(); + }); + + it('configures the server and saves the setting', function () { + ConfigureAutomaticSecurityUpdates::shouldRun()->once() + ->with(Mockery::on(fn ($server) => $server->is($this->server)), 'apt', true, '04:30'); + + Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]) + ->set('autoSecurityUpdates', true) + ->set('autoSecurityUpdatesTime', '04:30') + ->call('submit') + ->assertDispatched('success'); + + expect($this->server->settings->fresh()) + ->is_auto_security_updates_enabled->toBeTrue() + ->auto_security_updates_time->toBe('04:30'); + }); + + it('does not run the server commands when only the frequency changes', function () { + ConfigureAutomaticSecurityUpdates::shouldRun()->never(); + + Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]) + ->set('patchCheckFrequency', 'daily') + ->call('submit') + ->assertDispatched('success'); + }); + + it('keeps the setting off when the server commands fail', function () { + ConfigureAutomaticSecurityUpdates::shouldRun()->once()->andThrow(new RuntimeException('apt-get failed')); + + Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]) + ->set('autoSecurityUpdates', true) + ->call('submit') + ->assertDispatched('error') + ->assertSet('autoSecurityUpdates', false); + + expect($this->server->settings->fresh()->is_auto_security_updates_enabled)->toBeFalse(); + }); + + it('shows that other package managers are not supported', function () { + $this->server->settings->forceFill(['patch_check_result' => [...patchesTestResult(0), 'package_manager' => 'pacman']])->saveQuietly(); + ConfigureAutomaticSecurityUpdates::shouldRun()->never(); + + Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]) + ->assertSee('This server uses pacman') + ->set('autoSecurityUpdates', true) + ->call('submit') + ->assertDispatched('error'); + }); + + it('rejects an invalid maintenance window', function () { + ConfigureAutomaticSecurityUpdates::shouldRun()->never(); + + Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]) + ->set('autoSecurityUpdates', true) + ->set('autoSecurityUpdatesTime', '25:00') + ->call('submit') + ->assertDispatched('error'); + }); +}); diff --git a/tests/Unit/Actions/Server/ConfigureAutomaticSecurityUpdatesTest.php b/tests/Unit/Actions/Server/ConfigureAutomaticSecurityUpdatesTest.php new file mode 100644 index 0000000000..2e3afb22dc --- /dev/null +++ b/tests/Unit/Actions/Server/ConfigureAutomaticSecurityUpdatesTest.php @@ -0,0 +1,124 @@ + $commands + * @return list + */ +function autoSecurityUpdatesDecodedPayloads(array $commands): array +{ + preg_match_all("/echo '?([A-Za-z0-9+\/=]{8,})'? \| base64 -d/", implode("\n", $commands), $matches); + + return array_map(fn (string $payload) => base64_decode($payload, true), $matches[1]); +} + +function autoSecurityUpdatesNonRootServer(): 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(); +}); + +test('only apt and dnf support automatic security updates', function (?string $packageManager, bool $supported) { + expect(ConfigureAutomaticSecurityUpdates::supportFor($packageManager))->toBe($supported); +})->with([ + ['apt', true], + ['dnf', true], + ['zypper', false], + ['apk', false], + ['pacman', false], + [null, false], +]); + +test('apt installs unattended-upgrades and allows only the security origins at the chosen time', function () { + $commands = ConfigureAutomaticSecurityUpdates::commands('apt', true, '04:30'); + [$config, $dropIn] = autoSecurityUpdatesDecodedPayloads($commands); + + expect($commands[0])->toContain('command -v unattended-upgrade') + ->toContain('DEBIAN_FRONTEND=noninteractive apt-get install -y unattended-upgrades') + ->and(implode("\n", $commands)) + ->toContain('tee /etc/apt/apt.conf.d/52coolify-unattended-upgrades > /dev/null') + ->toContain('tee /etc/systemd/system/apt-daily-upgrade.timer.d/coolify.conf > /dev/null') + ->toContain('chmod 644 /etc/apt/apt.conf.d/52coolify-unattended-upgrades /etc/systemd/system/apt-daily-upgrade.timer.d/coolify.conf') + ->and($commands)->toContain('systemctl daemon-reload') + ->toContain('systemctl enable --now apt-daily.timer apt-daily-upgrade.timer') + ->and($config) + ->toContain('APT::Periodic::Unattended-Upgrade "1";') + ->toContain('#clear Unattended-Upgrade::Allowed-Origins;') + ->toContain('#clear Unattended-Upgrade::Origins-Pattern;') + ->toContain('"origin=Debian,codename=${distro_codename}-security,label=Debian-Security";') + ->toContain('"origin=Ubuntu,archive=${distro_codename}-security";') + ->not->toContain('archive=${distro_codename}-updates') + ->and($dropIn)->toBe("[Timer]\nOnCalendar=\nOnCalendar=*-*-* 04:30\nRandomizedDelaySec=0\n"); +}); + +test('apt disable removes only the Coolify files', function () { + expect(ConfigureAutomaticSecurityUpdates::commands('apt', false, '03:00'))->toBe([ + 'rm -f /etc/apt/apt.conf.d/52coolify-unattended-upgrades /etc/systemd/system/apt-daily-upgrade.timer.d/coolify.conf', + 'systemctl daemon-reload', + ]); +}); + +test('dnf installs dnf-automatic, applies only security updates and sets the timer of dnf4 or dnf5', function () { + $commands = ConfigureAutomaticSecurityUpdates::commands('dnf', true, '22:15'); + [$dropIn] = autoSecurityUpdatesDecodedPayloads($commands); + + expect($commands[0])->toBe('dnf install -y dnf-automatic') + ->and($commands[1]) + ->toContain('f=/etc/dnf/automatic.conf') + ->toContain('(upgrade_type|apply_updates)[[:space:]]*=/d') + ->toContain('a upgrade_type = security') + ->toContain('a apply_updates = yes') + ->and($commands[2]) + ->toContain('t=dnf-automatic.timer; if [ -e /usr/lib/systemd/system/dnf5-automatic.timer ]; then t=dnf5-automatic.timer; fi;') + ->toContain('/etc/systemd/system/$t.d/coolify.conf') + ->toContain('systemctl enable --now $t') + ->and($dropIn)->toBe("[Timer]\nOnCalendar=\nOnCalendar=*-*-* 22:15\nRandomizedDelaySec=0\n"); +}); + +test('dnf disable stops the timer and removes the Coolify drop-in', function () { + $commands = ConfigureAutomaticSecurityUpdates::commands('dnf', false, '03:00'); + + expect($commands)->toHaveCount(1) + ->and($commands[0]) + ->toContain('systemctl disable --now $t') + ->toContain('rm -f /etc/systemd/system/$t.d/coolify.conf') + ->toContain('systemctl daemon-reload') + ->not->toContain('automatic.conf'); +}); + +test('every command runs as root for a non-root SSH user', function (string $packageManager, bool $enabled) { + $commands = ConfigureAutomaticSecurityUpdates::commands($packageManager, $enabled, '03:00'); + + $parsed = parseCommandsByLineForSudo(collect($commands), autoSecurityUpdatesNonRootServer()); + + foreach ($parsed as $line) { + $isSudoLine = str_starts_with($line, 'sudo ') && ! str_contains($line, ' | '); + $isSudoShellScript = isSingleSudoShellScript($line) && substr_count($line, 'sudo') === 1; + $isSudoTeePipe = preg_match("/^echo '[A-Za-z0-9+\/=]+' \| sudo base64 -d \| sudo tee \/etc\/\S+ > \/dev\/null$/", $line) === 1; + + expect($isSudoLine || $isSudoShellScript || $isSudoTeePipe)->toBeTrue("Line does not run as root: {$line}"); + } +})->with([ + 'apt enabled' => ['apt', true], + 'apt disabled' => ['apt', false], + 'dnf enabled' => ['dnf', true], + 'dnf disabled' => ['dnf', false], +]); + +test('handle rejects a time that is not HH:MM', function (string $time) { + ConfigureAutomaticSecurityUpdates::run(new Server, 'apt', true, $time); +})->throws(InvalidArgumentException::class)->with(['24:00', '3:00', '03:60', '03:00; reboot', '']); + +test('handle rejects an unsupported package manager', function () { + ConfigureAutomaticSecurityUpdates::run(new Server, 'zypper', true, '03:00'); +})->throws(RuntimeException::class); diff --git a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php index 7970238a1b..2160f1f131 100644 --- a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php +++ b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php @@ -396,6 +396,8 @@ test('the patch check frequency moves with the server and the last patch check r 'patch_check_result' => ['osId' => 'ubuntu', 'package_manager' => 'apt', 'total_updates' => 0, 'updates' => []], 'patch_check_error' => 'old error', 'patch_checked_at' => now(), + 'is_auto_security_updates_enabled' => true, + 'auto_security_updates_time' => '04:30', ])->saveQuietly(); $bundle = $this->exporter->export($this->server); @@ -411,6 +413,8 @@ test('the patch check frequency moves with the server and the last patch check r $settings = Server::where('uuid', $result['server_uuid'])->first()->settings; expect($settings->patch_check_frequency)->toBe('daily') + ->and($settings->is_auto_security_updates_enabled)->toBeTrue() + ->and($settings->auto_security_updates_time)->toBe('04:30') ->and($settings->patch_check_result)->toBeNull() ->and($settings->patch_checked_at)->toBeNull(); });