mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Co-authored-by: Aditya Tripathi <aditya@climactic.co>
881 lines
44 KiB
PHP
881 lines
44 KiB
PHP
<?php
|
|
|
|
use App\Enums\ApplicationDeploymentStatus;
|
|
use App\Enums\ImageScanStatus;
|
|
use App\Enums\SecurityActionType;
|
|
use App\Enums\SecurityStatusType;
|
|
use App\Models\Application;
|
|
use App\Models\ApplicationDeploymentQueue;
|
|
use App\Models\Environment;
|
|
use App\Models\ImageScan;
|
|
use App\Models\ImageScanIgnore;
|
|
use App\Models\ImageScanPolicy;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\Project;
|
|
use App\Models\Server;
|
|
use App\Models\Service;
|
|
use App\Models\ServiceApplication;
|
|
use App\Models\StandaloneDocker;
|
|
use App\Models\Team;
|
|
use App\Services\ImageScan\ResourceScans;
|
|
use App\Services\ImageScan\ResourceSecurity;
|
|
use App\Services\ImageScan\SecuritySentences;
|
|
use App\Services\ImageScan\SecurityStatus;
|
|
use App\Services\ImageScan\VulnerabilityMarkers;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Once;
|
|
use Illuminate\Support\Str;
|
|
use Symfony\Component\Yaml\Yaml;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
Once::flush();
|
|
Cache::flush();
|
|
Server::flushIdentityMap();
|
|
InstanceSettings::forceCreate(['id' => 0, 'is_image_scanning_allowed' => true]);
|
|
Queue::fake();
|
|
|
|
$this->team = Team::factory()->create();
|
|
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
|
$this->server->settings->forceFill(['is_image_scan_enabled' => true])->saveQuietly();
|
|
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
|
|
$this->environment = Environment::factory()->create([
|
|
'project_id' => Project::factory()->create(['team_id' => $this->team->id])->id,
|
|
]);
|
|
$this->application = statusTestApplication();
|
|
});
|
|
|
|
/**
|
|
* @param array<string, mixed> $attributes
|
|
*/
|
|
function statusTestApplication(array $attributes = []): Application
|
|
{
|
|
return Application::factory()->create([
|
|
'environment_id' => test()->environment->id,
|
|
'destination_id' => test()->destination->id,
|
|
'destination_type' => test()->destination->getMorphClass(),
|
|
'build_pack' => 'dockerimage',
|
|
'docker_registry_image_name' => 'nginx',
|
|
'docker_registry_image_tag' => '1.27',
|
|
...$attributes,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* A relevant high finding with a package fix and no exploit signal.
|
|
*
|
|
* @param array<string, mixed> $overrides
|
|
* @return array<string, mixed>
|
|
*/
|
|
function statusTestFinding(string $id, array $overrides = []): array
|
|
{
|
|
return [
|
|
'id' => $id,
|
|
'aliases' => [],
|
|
'severity' => 'high',
|
|
'package' => 'openssl',
|
|
'version' => '3.0.1',
|
|
'type' => 'apk',
|
|
'artifactId' => 'a-'.$id,
|
|
'fixedIn' => '3.0.2',
|
|
'fixState' => 'fixed',
|
|
'kev' => false,
|
|
'epss' => null,
|
|
'risk' => 1.0,
|
|
'summary' => '',
|
|
'layer' => 'app',
|
|
'title' => 'Problem '.$id,
|
|
...$overrides,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, list<string>> $fixedByRef Candidate ref => fixed IDs. A ref with the current tag is a same_tag candidate.
|
|
* @return array<string, mixed>
|
|
*/
|
|
function statusTestCandidates(array $fixedByRef = [], string $status = 'ok'): array
|
|
{
|
|
return [
|
|
'status' => $status,
|
|
'checked_at' => now()->toIso8601String(),
|
|
'error' => null,
|
|
'tags_truncated' => false,
|
|
'entries' => collect($fixedByRef)->map(fn (array $ids, string $ref): array => [
|
|
'kind' => str_ends_with($ref, ':1.27') || str_ends_with($ref, ':1.0') ? 'same_tag' : 'line',
|
|
'ref' => $ref,
|
|
'better' => true,
|
|
'fixed_ids' => $ids,
|
|
'eol' => null,
|
|
])->values()->all(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $findings
|
|
* @param array<string, mixed> $attributes
|
|
*/
|
|
function statusTestScan(Model $resource, array $findings, array $attributes = []): ImageScan
|
|
{
|
|
return ImageScan::factory()->create([
|
|
'server_id' => test()->server->id,
|
|
'resource_type' => $resource->getMorphClass(),
|
|
'resource_id' => $resource->getKey(),
|
|
'image_ref' => 'nginx:1.27',
|
|
'builds_image' => false,
|
|
'findings' => $findings,
|
|
'candidates' => statusTestCandidates(status: 'no_better'),
|
|
'summary' => ['truncated' => false, 'eol_distro' => null],
|
|
'grype_db_built_at' => now()->subDay(),
|
|
'scanned_at' => now(),
|
|
...$attributes,
|
|
]);
|
|
}
|
|
|
|
function statusTestService(): Service
|
|
{
|
|
return Service::factory()->create([
|
|
'environment_id' => test()->environment->id,
|
|
'server_id' => test()->server->id,
|
|
'destination_id' => test()->destination->id,
|
|
'destination_type' => test()->destination->getMorphClass(),
|
|
]);
|
|
}
|
|
|
|
function statusTestServiceApplication(Service $service, string $name, string $image): ServiceApplication
|
|
{
|
|
return ServiceApplication::forceCreate(['uuid' => (string) Str::uuid(), 'service_id' => $service->id, 'name' => $name, 'image' => $image]);
|
|
}
|
|
|
|
describe('status rules', function () {
|
|
it('is not checked without a completed scan', function () {
|
|
statusTestScan($this->application, [], ['status' => ImageScanStatus::Failed]);
|
|
|
|
$security = SecurityStatus::forResource($this->application);
|
|
|
|
expect($security->status)->toBe(SecurityStatusType::NotChecked)
|
|
->and($security->checkReason)->toBe('not_scanned')
|
|
->and(SecurityStatus::stored($this->application)->status)->toBe(SecurityStatusType::NotChecked);
|
|
});
|
|
|
|
it('is not checked when scanning is off for the server', function () {
|
|
statusTestScan($this->application, [statusTestFinding('CVE-2024-0001', ['kev' => true])]);
|
|
$this->server->settings->forceFill(['is_image_scan_enabled' => false])->saveQuietly();
|
|
|
|
$security = SecurityStatus::forResource($this->application->fresh());
|
|
|
|
expect($security->status)->toBe(SecurityStatusType::NotChecked)
|
|
->and($security->checkReason)->toBe('server');
|
|
});
|
|
|
|
it('needs an update now when a finding that attackers are using has an action', function () {
|
|
statusTestScan($this->application, [
|
|
statusTestFinding('CVE-2024-0001', ['kev' => true]),
|
|
statusTestFinding('CVE-2024-0002', ['kev' => true, 'package' => 'curl']),
|
|
], ['candidates' => statusTestCandidates(['nginx:1.28' => ['CVE-2024-0001']])]);
|
|
|
|
$security = SecurityStatus::forResource($this->application);
|
|
|
|
expect($security->status)->toBe(SecurityStatusType::UpdateNow)
|
|
->and($security->checkReason)->toBeNull()
|
|
->and($security->actions[0]->type)->toBe(SecurityActionType::UpdateImage)
|
|
->and($security->actions[0]->target)->toBe('1.28')
|
|
->and($security->problemCount)->toBe(1)
|
|
->and($security->exploitedCount)->toBe(2)
|
|
->and($security->anyExploited)->toBeTrue()
|
|
->and($security->fixedByPrimary)->toBe(1)
|
|
->and($security->exploitedWithoutFix)->toBeTrue();
|
|
});
|
|
|
|
it('says likely when no fixed finding is in the known exploited list', function () {
|
|
statusTestScan($this->application, [
|
|
statusTestFinding('CVE-2024-0001', ['epss' => 0.4, 'cvssScore' => 8.1]),
|
|
], ['candidates' => statusTestCandidates(['nginx:1.28' => ['CVE-2024-0001']])]);
|
|
|
|
$security = SecurityStatus::forResource($this->application);
|
|
|
|
expect($security->status)->toBe(SecurityStatusType::UpdateNow)
|
|
->and($security->anyExploited)->toBeFalse()
|
|
->and($security->exploitedWithoutFix)->toBeFalse();
|
|
});
|
|
|
|
it('has a security update when a serious finding without an exploit signal has an action', function () {
|
|
statusTestScan($this->application, [
|
|
statusTestFinding('CVE-2024-0001', ['severity' => 'critical']),
|
|
statusTestFinding('CVE-2024-0002'),
|
|
statusTestFinding('CVE-2024-0003', ['package' => 'curl']),
|
|
], ['candidates' => statusTestCandidates(['nginx:1.28' => ['CVE-2024-0001', 'CVE-2024-0002']])]);
|
|
|
|
$security = SecurityStatus::forResource($this->application);
|
|
|
|
expect($security->status)->toBe(SecurityStatusType::UpdateAvailable)
|
|
->and($security->problemCount)->toBe(2)
|
|
->and($security->criticalCount)->toBe(1)
|
|
->and($security->highCount)->toBe(1)
|
|
->and($security->fixedByPrimary)->toBe(2)
|
|
->and($security->exploitedWithoutFix)->toBeFalse();
|
|
});
|
|
|
|
it('has a security update for a fixed medium finding only with a high EPSS or when medium problems count', function (array $overrides, ?bool $countMedium, SecurityStatusType $status) {
|
|
if ($countMedium !== null) {
|
|
ImageScanPolicy::factory()->create(['scope_type' => $this->team->getMorphClass(), 'scope_id' => $this->team->id, 'count_medium' => $countMedium]);
|
|
}
|
|
statusTestScan($this->application, [
|
|
statusTestFinding('CVE-2024-0001', ['severity' => 'medium', 'cvssScore' => 5.3, ...$overrides]),
|
|
], ['candidates' => statusTestCandidates(['nginx:1.28' => ['CVE-2024-0001']])]);
|
|
|
|
$security = SecurityStatus::forResource($this->application);
|
|
|
|
expect($security->status)->toBe($status)
|
|
->and($security->problemCount)->toBe($status === SecurityStatusType::UpdateAvailable ? 1 : 0)
|
|
->and($security->mediumCount)->toBe($status === SecurityStatusType::UpdateAvailable ? 1 : 0);
|
|
})->with([
|
|
'no EPSS' => [[], null, SecurityStatusType::Ok],
|
|
'no EPSS, team does not count mediums' => [[], false, SecurityStatusType::Ok],
|
|
'EPSS 0.10 or more' => [['epss' => 0.3], null, SecurityStatusType::UpdateAvailable],
|
|
'team counts mediums' => [[], true, SecurityStatusType::UpdateAvailable],
|
|
]);
|
|
|
|
it('names the medium fixes in the sentence of a security update', function () {
|
|
ImageScanPolicy::factory()->create(['scope_type' => $this->server->getMorphClass(), 'scope_id' => $this->server->id, 'count_medium' => true]);
|
|
statusTestScan($this->application, [
|
|
statusTestFinding('CVE-2024-0001', ['severity' => 'critical']),
|
|
statusTestFinding('CVE-2024-0002'),
|
|
statusTestFinding('CVE-2024-0003', ['severity' => 'medium', 'package' => 'curl']),
|
|
statusTestFinding('CVE-2024-0004', ['severity' => 'medium', 'package' => 'zlib']),
|
|
statusTestFinding('CVE-2024-0005', ['severity' => 'low', 'package' => 'musl']),
|
|
], ['candidates' => statusTestCandidates(['nginx:1.28' => ['CVE-2024-0001', 'CVE-2024-0002', 'CVE-2024-0003', 'CVE-2024-0004', 'CVE-2024-0005']])]);
|
|
|
|
$security = SecurityStatus::forResource($this->application);
|
|
ResourceScans::refreshSummaries(ResourceScans::current($this->application));
|
|
$stored = SecurityStatus::stored($this->application->fresh());
|
|
|
|
expect($security->status)->toBe(SecurityStatusType::UpdateAvailable)
|
|
->and($security->problemCount)->toBe(4)
|
|
->and($security->fixedByPrimary)->toBe(4)
|
|
->and($security->primaryMedium)->toBe(2)
|
|
->and(SecuritySentences::headline($security))->toBe('Update to 1.28. This fixes 4 problems (1 critical, 1 high, 2 medium). Attackers are not known to use them.')
|
|
->and($stored->mediumCount)->toBe(2)
|
|
->and($stored->primaryMedium)->toBe(2);
|
|
});
|
|
|
|
it('never needs an update now for a medium finding without KEV', function () {
|
|
ImageScanPolicy::factory()->create(['scope_type' => $this->team->getMorphClass(), 'scope_id' => $this->team->id, 'count_medium' => true]);
|
|
statusTestScan($this->application, [
|
|
statusTestFinding('CVE-2024-0001', ['severity' => 'medium', 'cvssScore' => 5.3, 'epss' => 0.9]),
|
|
], ['candidates' => statusTestCandidates(['nginx:1.28' => ['CVE-2024-0001']])]);
|
|
|
|
expect(SecurityStatus::forResource($this->application)->status)->toBe(SecurityStatusType::UpdateAvailable);
|
|
});
|
|
|
|
it('flags a security update when a finding that attackers are using has no fix', function () {
|
|
statusTestScan($this->application, [
|
|
statusTestFinding('CVE-2024-0001'),
|
|
statusTestFinding('CVE-2024-0002', ['kev' => true, 'package' => 'curl']),
|
|
], ['candidates' => statusTestCandidates(['nginx:1.28' => ['CVE-2024-0001']])]);
|
|
|
|
$security = SecurityStatus::forResource($this->application);
|
|
|
|
expect($security->status)->toBe(SecurityStatusType::UpdateAvailable)
|
|
->and($security->exploitedWithoutFix)->toBeTrue()
|
|
->and($security->exploitedCount)->toBe(1);
|
|
});
|
|
|
|
it('has a security update when an action leaves an end-of-life OS', function () {
|
|
statusTestScan($this->application, [], [
|
|
'summary' => ['truncated' => false, 'eol_distro' => ['name' => 'alpine', 'version' => '3.16']],
|
|
'candidates' => statusTestCandidates(['nginx:1.28' => []]),
|
|
]);
|
|
|
|
$security = SecurityStatus::forResource($this->application);
|
|
|
|
expect($security->status)->toBe(SecurityStatusType::UpdateAvailable)
|
|
->and($security->eol)->toMatchArray(['name' => 'alpine', 'version' => '3.16', 'fixed' => true])
|
|
->and($security->actions[0]->type)->toBe(SecurityActionType::UpdateImage)
|
|
->and($security->problemCount)->toBe(1)
|
|
->and($security->criticalCount)->toBe(1)
|
|
->and($security->fixedByPrimary)->toBe(1)
|
|
->and($security->primaryCritical)->toBe(1)
|
|
->and($security->why)->toBe([])
|
|
->and($security->moreFixed)->toBe(0);
|
|
});
|
|
|
|
it('counts leaving an end-of-life OS as one critical fix of the action', function () {
|
|
statusTestScan($this->application, [
|
|
statusTestFinding('CVE-2024-0001'),
|
|
statusTestFinding('CVE-2024-0002', ['package' => 'curl']),
|
|
statusTestFinding('CVE-2024-0003', ['package' => 'zlib', 'severity' => 'critical']),
|
|
], [
|
|
'summary' => ['truncated' => false, 'eol_distro' => ['name' => 'alpine', 'version' => '3.16']],
|
|
'candidates' => statusTestCandidates([
|
|
'nginx:1.28' => ['CVE-2024-0001'],
|
|
'nginx:1.27' => ['CVE-2024-0002', 'CVE-2024-0003'],
|
|
]),
|
|
]);
|
|
|
|
$security = SecurityStatus::forResource($this->application);
|
|
|
|
// 1 high + the end-of-life OS ties with 1 high + 1 critical; update_image comes first in the type order.
|
|
expect($security->actions[0]->type)->toBe(SecurityActionType::UpdateImage)
|
|
->and($security->problemCount)->toBe(4)
|
|
->and($security->criticalCount)->toBe(2)
|
|
->and($security->highCount)->toBe(2)
|
|
->and($security->fixedByPrimary)->toBe(2)
|
|
->and($security->primaryCritical)->toBe(1)
|
|
->and($security->primaryHigh)->toBe(1)
|
|
->and($security->moreFixed)->toBe(0);
|
|
});
|
|
|
|
it('has no fix when a finding that attackers are using has no action', function () {
|
|
statusTestScan($this->application, [
|
|
statusTestFinding('CVE-2024-0001', ['kev' => true, 'cvssScore' => 9.8]),
|
|
statusTestFinding('CVE-2024-0002'),
|
|
]);
|
|
|
|
$security = SecurityStatus::forResource($this->application);
|
|
|
|
expect($security->status)->toBe(SecurityStatusType::NoFix)
|
|
->and($security->actions)->toBe([])
|
|
->and($security->problemCount)->toBe(1)
|
|
->and($security->anyExploited)->toBeTrue()
|
|
->and($security->exploitedWithoutFix)->toBeFalse()
|
|
->and(array_column($security->why, 'display_id'))->toBe(['CVE-2024-0001']);
|
|
});
|
|
|
|
it('knows that a builder update fixes every problem without a fix', function () {
|
|
$builder = statusTestApplication(['build_pack' => 'nixpacks']);
|
|
statusTestScan($builder, [statusTestFinding('CVE-2024-0001', ['kev' => true])], ['builds_image' => true]);
|
|
$dockerfile = statusTestApplication(['build_pack' => 'dockerfile']);
|
|
statusTestScan($dockerfile, [statusTestFinding('CVE-2024-0001', ['kev' => true])], ['builds_image' => true]);
|
|
|
|
$builderSecurity = SecurityStatus::forResource($builder);
|
|
ResourceScans::refreshSummaries(ResourceScans::currentFor($builder));
|
|
|
|
expect($builderSecurity->status)->toBe(SecurityStatusType::NoFix)
|
|
->and($builderSecurity->builderImage)->toBeTrue()
|
|
->and(SecurityStatus::stored($builder)->builderImage)->toBeTrue()
|
|
->and(ResourceSecurity::fromArray($builderSecurity->toArray())->builderImage)->toBeTrue()
|
|
->and(SecuritySentences::headline($builderSecurity))->toContain('A fix exists, but it reaches this app only with a builder update')
|
|
->and(SecurityStatus::forResource($dockerfile)->builderImage)->toBeFalse()
|
|
->and(SecurityStatus::forResource($this->application)->builderImage)->toBeFalse();
|
|
});
|
|
|
|
it('does not promise a builder fix for a builder image without a fixed version', function () {
|
|
$builder = statusTestApplication(['build_pack' => 'nixpacks']);
|
|
statusTestScan($builder, [statusTestFinding('CVE-2024-0001', ['kev' => true, 'fixedIn' => null, 'fixState' => 'not-fixed'])], ['builds_image' => true]);
|
|
|
|
$security = SecurityStatus::forResource($builder);
|
|
|
|
expect($security->status)->toBe(SecurityStatusType::NoFix)
|
|
->and($security->builderImage)->toBeFalse()
|
|
->and(SecuritySentences::headline($security))->toContain('no fixed version exists yet')
|
|
->and(SecuritySentences::headline($security))->not->toContain('A fix exists');
|
|
});
|
|
|
|
it('does not promise a builder fix for a language package of a builder image', function () {
|
|
$builder = statusTestApplication(['build_pack' => 'nixpacks']);
|
|
statusTestScan($builder, [
|
|
statusTestFinding('CVE-2024-0001', ['kev' => true]),
|
|
statusTestFinding('CVE-2024-0002', ['kev' => true, 'type' => 'binary', 'package' => 'tool']),
|
|
], ['builds_image' => true]);
|
|
|
|
expect(SecurityStatus::forResource($builder)->builderImage)->toBeFalse();
|
|
});
|
|
|
|
it('does not call a service no_fix a builder one when another image drives it', function () {
|
|
$service = statusTestService();
|
|
$web = statusTestServiceApplication($service, 'web', 'nginx:1.27');
|
|
$worker = statusTestServiceApplication($service, 'worker', 'nginx:1.27');
|
|
statusTestScan($web, [statusTestFinding('CVE-2024-0001', ['kev' => true])]);
|
|
statusTestScan($worker, []);
|
|
|
|
$security = SecurityStatus::forResource($service);
|
|
|
|
expect($security->status)->toBe(SecurityStatusType::NoFix)
|
|
->and($security->builderImage)->toBeFalse();
|
|
});
|
|
|
|
it('says whether a problem without a fix is a known exploited one', function () {
|
|
statusTestScan($this->application, [statusTestFinding('CVE-2024-0001', ['epss' => 0.4, 'cvssScore' => 8.1])]);
|
|
|
|
$likely = SecurityStatus::forResource($this->application);
|
|
|
|
expect($likely->status)->toBe(SecurityStatusType::NoFix)
|
|
->and($likely->anyExploited)->toBeFalse()
|
|
->and(SecuritySentences::headline($likely))->toStartWith('This image has a security problem that attackers are likely to use, and no fixed version exists yet.');
|
|
|
|
$other = statusTestApplication();
|
|
statusTestScan($other, [statusTestFinding('CVE-2024-0001', ['epss' => 0.4, 'cvssScore' => 8.1]), statusTestFinding('CVE-2024-0002', ['kev' => true])]);
|
|
|
|
expect(SecuritySentences::headline(SecurityStatus::forResource($other)))
|
|
->toStartWith('This image has 2 security problems that attackers are using, and no fixed version exists yet.');
|
|
});
|
|
|
|
it('is ok when no relevant finding has an exploit signal or an action', function () {
|
|
statusTestScan($this->application, [
|
|
statusTestFinding('CVE-2024-0001'),
|
|
statusTestFinding('CVE-2024-0002', ['severity' => 'medium', 'epss' => 0.9]),
|
|
]);
|
|
|
|
expect(SecurityStatus::forResource($this->application)->status)->toBe(SecurityStatusType::Ok);
|
|
});
|
|
|
|
it('leaves ignored findings and CPE-only OS matches out', function () {
|
|
statusTestScan($this->application, [
|
|
statusTestFinding('CVE-2024-0001', ['kev' => true]),
|
|
statusTestFinding('CVE-2024-0002', ['kev' => true, 'namespace' => 'nvd:cpe']),
|
|
]);
|
|
ImageScanIgnore::query()->forceCreate(['team_id' => $this->team->id, 'vulnerability_id' => 'CVE-2024-0001', 'reason' => 'not used', 'expires_at' => now()->addDays(30)]);
|
|
|
|
expect(SecurityStatus::forResource($this->application)->status)->toBe(SecurityStatusType::Ok);
|
|
});
|
|
|
|
it('needs a check before it decides update now', function () {
|
|
statusTestScan($this->application, [statusTestFinding('CVE-2024-0001', ['kev' => true])], [
|
|
'candidates' => statusTestCandidates(['nginx:1.28' => ['CVE-2024-0001']]),
|
|
'scanned_at' => now()->subDays(3),
|
|
'grype_db_built_at' => now()->subDays(3),
|
|
]);
|
|
|
|
$security = SecurityStatus::forResource($this->application);
|
|
|
|
expect($security->status)->toBe(SecurityStatusType::CheckNeeded)
|
|
->and($security->checkReason)->toBe('stale')
|
|
->and($security->actions[0]->type)->toBe(SecurityActionType::UpdateImage);
|
|
});
|
|
});
|
|
|
|
describe('check needed reasons', function () {
|
|
it('needs a check when an image of a service is not checked', function () {
|
|
$service = statusTestService();
|
|
$web = statusTestServiceApplication($service, 'web', 'nginx:1.27');
|
|
statusTestServiceApplication($service, 'worker', 'nginx:1.27');
|
|
statusTestScan($web, [statusTestFinding('CVE-2024-0001')]);
|
|
|
|
$security = SecurityStatus::forResource($service);
|
|
|
|
expect($security->status)->toBe(SecurityStatusType::CheckNeeded)
|
|
->and($security->checkReason)->toBe('images_unchecked')
|
|
->and($security->checkContext)->toBe(['checked' => 1, 'total' => 2]);
|
|
});
|
|
|
|
it('counts only the images of a service that Coolify scans', function () {
|
|
$service = statusTestService();
|
|
$web = statusTestServiceApplication($service, 'web', 'nginx:1.27');
|
|
statusTestServiceApplication($service, 'sidecar', 'busybox:1')->forceFill(['exclude_from_status' => true])->save();
|
|
statusTestServiceApplication($service, 'empty', '');
|
|
statusTestScan($web, []);
|
|
ResourceScans::refreshSummaries(ResourceScans::currentFor($web));
|
|
|
|
expect(SecurityStatus::forResource($service)->status)->toBe(SecurityStatusType::Ok)
|
|
->and(SecurityStatus::stored($service->fresh())->status)->toBe(SecurityStatusType::Ok);
|
|
});
|
|
|
|
it('needs a check when an image of the current Compose deployment has no completed result', function (string $missing) {
|
|
$application = statusTestApplication(['build_pack' => 'dockercompose']);
|
|
$deployment = ApplicationDeploymentQueue::create([
|
|
'deployment_uuid' => Str::random(12),
|
|
'application_id' => $application->id,
|
|
'server_id' => $this->server->id,
|
|
'status' => ApplicationDeploymentStatus::FINISHED->value,
|
|
'pull_request_id' => 0,
|
|
]);
|
|
$application->forceFill(['docker_compose' => Yaml::dump(['services' => [
|
|
'web' => ['image' => 'nginx:1.27'],
|
|
'db' => ['image' => 'postgres:16'],
|
|
'cache' => ['image' => '${CACHE_IMAGE:-redis:7}'],
|
|
]])])->save();
|
|
statusTestScan($application, [], ['deployment_queue_id' => $deployment->id]);
|
|
if ($missing === 'failed row') {
|
|
statusTestScan($application, [], ['deployment_queue_id' => $deployment->id, 'image_ref' => 'postgres:16', 'status' => ImageScanStatus::Failed]);
|
|
}
|
|
ResourceScans::refreshSummaries(ResourceScans::currentFor($application));
|
|
|
|
foreach ([SecurityStatus::forResource($application), SecurityStatus::stored($application)] as $security) {
|
|
expect($security->status)->toBe(SecurityStatusType::CheckNeeded)
|
|
->and($security->checkReason)->toBe('images_unchecked')
|
|
->and($security->checkContext)->toBe(['checked' => 1, 'total' => 2]);
|
|
}
|
|
})->with(['failed row', 'image without a row']);
|
|
|
|
it('treats a Compose deployment with a result for every image as checked', function () {
|
|
$application = statusTestApplication(['build_pack' => 'dockercompose']);
|
|
$application->forceFill(['docker_compose' => Yaml::dump(['services' => ['web' => ['image' => 'nginx:1.27']]])])->save();
|
|
statusTestScan($application, []);
|
|
|
|
expect(SecurityStatus::forResource($application)->status)->toBe(SecurityStatusType::Ok);
|
|
});
|
|
|
|
it('needs a check when the newest result is older than 2 days', function () {
|
|
$scannedAt = now()->subDays(2)->subHour();
|
|
statusTestScan($this->application, [], ['scanned_at' => $scannedAt, 'grype_db_built_at' => $scannedAt]);
|
|
|
|
$security = SecurityStatus::forResource($this->application);
|
|
|
|
expect($security->status)->toBe(SecurityStatusType::CheckNeeded)
|
|
->and($security->checkReason)->toBe('stale')
|
|
->and($security->checkContext['time'])->toBe($scannedAt->toIso8601String())
|
|
->and($security->checkedAt?->toIso8601String())->toBe($scannedAt->toIso8601String());
|
|
});
|
|
|
|
it('needs a check when the vulnerability database was more than 5 days old', function () {
|
|
statusTestScan($this->application, [], ['grype_db_built_at' => now()->subDays(5)->subHour()]);
|
|
|
|
expect(SecurityStatus::forResource($this->application))
|
|
->status->toBe(SecurityStatusType::CheckNeeded)
|
|
->checkReason->toBe('db_outdated');
|
|
});
|
|
|
|
it('needs a check when the scan result was cut, also when nothing relevant was read', function (array $findings) {
|
|
statusTestScan($this->application, $findings, ['summary' => ['truncated' => true, 'eol_distro' => null]]);
|
|
ResourceScans::refreshSummaries(ResourceScans::currentFor($this->application));
|
|
|
|
$security = SecurityStatus::forResource($this->application);
|
|
|
|
expect($security->status)->toBe(SecurityStatusType::CheckNeeded)
|
|
->and($security->checkReason)->toBe('truncated')
|
|
->and(SecuritySentences::headline($security))->toBe('Coolify needs to check this image again: the scan result was too large to read completely.')
|
|
->and(SecurityStatus::stored($this->application->fresh())->checkReason)->toBe('truncated');
|
|
})->with([
|
|
'no findings' => [[]],
|
|
'only an exploited finding without a fix' => [[statusTestFinding('CVE-2026-9', ['kev' => true, 'fixedIn' => null, 'fixState' => 'not-fixed'])]],
|
|
]);
|
|
|
|
it('needs a check when the running image is not the scanned image', function () {
|
|
statusTestScan($this->application, []);
|
|
$this->application->update(['docker_registry_image_tag' => '1.28']);
|
|
|
|
expect(SecurityStatus::forResource($this->application->fresh()))
|
|
->status->toBe(SecurityStatusType::CheckNeeded)
|
|
->checkReason->toBe('image_changed');
|
|
});
|
|
|
|
it('needs a check while the version check failed or did not run and relevant findings exist', function (?array $candidates) {
|
|
statusTestScan($this->application, [statusTestFinding('CVE-2024-0001', ['kev' => true])], ['candidates' => $candidates]);
|
|
|
|
expect(SecurityStatus::forResource($this->application))
|
|
->status->toBe(SecurityStatusType::CheckNeeded)
|
|
->checkReason->toBe('versions_unchecked');
|
|
})->with([
|
|
'not run' => [null],
|
|
'failed' => [['status' => 'failed', 'error' => 'unknown', 'entries' => []]],
|
|
'busy' => [['status' => 'failed', 'error' => 'lock_busy', 'entries' => []]],
|
|
]);
|
|
|
|
it('counts a version check that needs a registry login or hit the daily limit as checked', function (array $candidates) {
|
|
statusTestScan($this->application, [statusTestFinding('CVE-2024-0001', ['kev' => true])], ['candidates' => $candidates]);
|
|
ResourceScans::refreshSummaries(ResourceScans::currentFor($this->application));
|
|
|
|
$security = SecurityStatus::forResource($this->application);
|
|
|
|
expect($security->status)->toBe(SecurityStatusType::NoFix)
|
|
->and($security->checkReason)->toBeNull()
|
|
->and(VulnerabilityMarkers::marker(SecurityStatus::stored($this->application->fresh())))
|
|
->toMatchArray(['status' => 'no_fix', 'text' => 'No fix yet']);
|
|
})->with([
|
|
'auth required' => [['status' => 'auth_required', 'error' => 'registry_auth', 'entries' => []]],
|
|
'daily limit' => [['status' => 'failed', 'error' => 'daily_limit', 'entries' => []]],
|
|
]);
|
|
|
|
it('counts a base check that needs a registry login as checked once the base origin is known', function (?int $attributable, SecurityStatusType $expected) {
|
|
$application = statusTestApplication(['build_pack' => 'dockerfile']);
|
|
statusTestScan($application, [statusTestFinding('CVE-2024-0001')], [
|
|
'image_ref' => $application->uuid.':latest',
|
|
'builds_image' => true,
|
|
'base_image_ref' => 'node:20-alpine',
|
|
'candidates' => ['status' => 'skipped', 'entries' => [], 'base' => ['image' => 'node:20-alpine', 'status' => 'auth_required', 'error' => 'registry_auth', 'attributable' => $attributable, 'attributable_ids' => [], 'entries' => []]],
|
|
]);
|
|
|
|
expect(SecurityStatus::forResource($application)->status)->toBe($expected);
|
|
})->with([
|
|
'origin known' => [0, SecurityStatusType::UpdateAvailable],
|
|
'origin unknown' => [null, SecurityStatusType::CheckNeeded],
|
|
]);
|
|
|
|
it('counts versions that cannot be compared as checked', function (string $status) {
|
|
statusTestScan($this->application, [statusTestFinding('CVE-2024-0001', ['kev' => true])], ['candidates' => statusTestCandidates(status: $status)]);
|
|
|
|
expect(SecurityStatus::forResource($this->application)->status)->toBe(SecurityStatusType::NoFix);
|
|
})->with(['cannot_compare', 'no_better']);
|
|
|
|
it('needs no version check without relevant findings', function () {
|
|
statusTestScan($this->application, [statusTestFinding('CVE-2024-0001', ['severity' => 'medium'])], ['candidates' => null]);
|
|
|
|
expect(SecurityStatus::forResource($this->application)->status)->toBe(SecurityStatusType::Ok);
|
|
});
|
|
|
|
it('needs a check while the base origin of a Dockerfile image is unknown', function () {
|
|
$application = statusTestApplication(['build_pack' => 'dockerfile']);
|
|
statusTestScan($application, [statusTestFinding('CVE-2024-0001')], [
|
|
'image_ref' => $application->uuid.':latest',
|
|
'builds_image' => true,
|
|
'base_image_ref' => 'node:20-alpine',
|
|
'candidates' => ['status' => 'skipped', 'entries' => [], 'base' => ['image' => 'node:20-alpine', 'status' => 'failed', 'attributable' => null, 'attributable_ids' => [], 'entries' => []]],
|
|
]);
|
|
|
|
expect(SecurityStatus::forResource($application))
|
|
->status->toBe(SecurityStatusType::CheckNeeded)
|
|
->checkReason->toBe('versions_unchecked');
|
|
});
|
|
|
|
it('decides a Dockerfile image once the base origin is known', function () {
|
|
$application = statusTestApplication(['build_pack' => 'dockerfile']);
|
|
statusTestScan($application, [statusTestFinding('CVE-2024-0001')], [
|
|
'image_ref' => $application->uuid.':latest',
|
|
'builds_image' => true,
|
|
'base_image_ref' => 'node:20-alpine',
|
|
'candidates' => ['status' => 'skipped', 'entries' => [], 'base' => ['image' => 'node:20-alpine', 'status' => 'no_better', 'attributable' => 0, 'attributable_ids' => [], 'entries' => []]],
|
|
]);
|
|
|
|
$security = SecurityStatus::forResource($application);
|
|
|
|
expect($security->status)->toBe(SecurityStatusType::UpdateAvailable)
|
|
->and($security->actions[0]->type)->toBe(SecurityActionType::Rebuild);
|
|
});
|
|
});
|
|
|
|
describe('primary action and why-list', function () {
|
|
it('picks the action that fixes the most findings with an exploit signal', function () {
|
|
statusTestScan($this->application, [
|
|
statusTestFinding('CVE-2024-0001', ['severity' => 'critical']),
|
|
statusTestFinding('CVE-2024-0002', ['severity' => 'critical', 'package' => 'curl']),
|
|
statusTestFinding('CVE-2024-0003', ['kev' => true, 'package' => 'zlib']),
|
|
], ['candidates' => statusTestCandidates([
|
|
'nginx:1.28' => ['CVE-2024-0001', 'CVE-2024-0002'],
|
|
'nginx:1.27' => ['CVE-2024-0003'],
|
|
])]);
|
|
|
|
$security = SecurityStatus::forResource($this->application);
|
|
|
|
expect(array_map(fn ($action) => $action->type, $security->actions))->toBe([SecurityActionType::PullLatest, SecurityActionType::UpdateImage])
|
|
->and($security->fixedByPrimary)->toBe(1)
|
|
->and($security->problemCount)->toBe(1);
|
|
});
|
|
|
|
it('picks the action that fixes the most critical and high findings next', function () {
|
|
statusTestScan($this->application, [
|
|
statusTestFinding('CVE-2024-0001'),
|
|
statusTestFinding('CVE-2024-0002', ['package' => 'curl']),
|
|
statusTestFinding('CVE-2024-0003', ['package' => 'zlib']),
|
|
], ['candidates' => statusTestCandidates([
|
|
'nginx:1.28' => ['CVE-2024-0001'],
|
|
'nginx:1.27' => ['CVE-2024-0002', 'CVE-2024-0003'],
|
|
])]);
|
|
|
|
$security = SecurityStatus::forResource($this->application);
|
|
|
|
expect($security->actions[0]->type)->toBe(SecurityActionType::PullLatest)
|
|
->and($security->fixedByPrimary)->toBe(2)
|
|
->and($security->problemCount)->toBe(3);
|
|
});
|
|
|
|
it('lists up to 3 fixed findings: exploited, likely, then by score', function () {
|
|
$ids = ['CVE-2024-0001', 'CVE-2024-0002', 'CVE-2024-0003', 'GHSA-abcd-efgh-ijkl', 'CVE-2024-0005'];
|
|
statusTestScan($this->application, [
|
|
statusTestFinding('CVE-2024-0001', ['cvssScore' => 9.8]),
|
|
statusTestFinding('CVE-2024-0002', ['epss' => 0.5, 'cvssScore' => 7.5, 'package' => 'curl']),
|
|
statusTestFinding('CVE-2024-0003', ['kev' => true, 'severity' => 'medium', 'cvssScore' => 5.0, 'package' => 'zlib']),
|
|
statusTestFinding('GHSA-abcd-efgh-ijkl', ['severity' => 'critical', 'cvssScore' => 9.9, 'package' => 'lodash', 'type' => 'npm']),
|
|
statusTestFinding('CVE-2024-0005', ['severity' => 'critical', 'package' => 'musl']),
|
|
], ['candidates' => statusTestCandidates(['nginx:1.28' => $ids])]);
|
|
|
|
$security = SecurityStatus::forResource($this->application);
|
|
|
|
expect($security->why)->toBe([
|
|
['title' => 'Problem CVE-2024-0003', 'display_id' => 'CVE-2024-0003', 'link' => 'https://nvd.nist.gov/vuln/detail/CVE-2024-0003', 'score' => 5.0, 'severity' => 'medium', 'signal' => 'exploited', 'package' => 'zlib', 'version' => '3.0.1'],
|
|
['title' => 'Problem CVE-2024-0002', 'display_id' => 'CVE-2024-0002', 'link' => 'https://nvd.nist.gov/vuln/detail/CVE-2024-0002', 'score' => 7.5, 'severity' => 'high', 'signal' => 'likely', 'package' => 'curl', 'version' => '3.0.1'],
|
|
['title' => 'Problem GHSA-abcd-efgh-ijkl', 'display_id' => 'GHSA-abcd-efgh-ijkl', 'link' => 'https://github.com/advisories/GHSA-abcd-efgh-ijkl', 'score' => 9.9, 'severity' => 'critical', 'signal' => null, 'package' => 'lodash', 'version' => '3.0.1'],
|
|
])->and($security->moreFixed)->toBe(2);
|
|
});
|
|
|
|
it('links no ID that does not pass the ID pattern', function () {
|
|
statusTestScan($this->application, [
|
|
statusTestFinding('ALPINE-1 <x>', ['kev' => true]),
|
|
], ['candidates' => statusTestCandidates(['nginx:1.28' => ['ALPINE-1 <x>']])]);
|
|
|
|
expect(SecurityStatus::forResource($this->application)->why[0]['link'])->toBeNull();
|
|
});
|
|
|
|
it('merges the updates of service images that run one tag and update to one tag into one action', function () {
|
|
$service = statusTestService();
|
|
$main = statusTestServiceApplication($service, 'n8n', 'n8nio/n8n:1.0');
|
|
$runners = statusTestServiceApplication($service, 'n8n runners', 'n8nio/runners:1.0');
|
|
$cache = statusTestServiceApplication($service, 'cache', 'redis:7.0');
|
|
foreach ([[$main, 'n8nio/n8n:1.1'], [$runners, 'n8nio/runners:1.1'], [$cache, 'redis:1.1']] as [$owner, $candidate]) {
|
|
statusTestScan($owner, [statusTestFinding('CVE-2024-0001', ['kev' => true])], [
|
|
'image_ref' => $owner->image,
|
|
'candidates' => statusTestCandidates([$candidate => ['CVE-2024-0001']]),
|
|
]);
|
|
}
|
|
|
|
$security = SecurityStatus::forResource($service);
|
|
|
|
expect($security->status)->toBe(SecurityStatusType::UpdateNow)
|
|
->and(array_map(fn ($action) => $action->sentence, $security->actions))->toBe(['Update n8n and n8n runners to 1.1.', 'Update to 1.1.'])
|
|
->and($security->actions[0]->fixedIds)->toBe(['CVE-2024-0001'])
|
|
->and($security->actions[0]->parts)->toBe([
|
|
['owner_type' => $main->getMorphClass(), 'owner_id' => $main->id, 'image' => 'n8nio/n8n:1.0', 'ref' => 'n8nio/n8n:1.1'],
|
|
['owner_type' => $runners->getMorphClass(), 'owner_id' => $runners->id, 'image' => 'n8nio/runners:1.0', 'ref' => 'n8nio/runners:1.1'],
|
|
])
|
|
->and($security->actions[1]->parts)->toBe([])
|
|
->and($security->actions[1]->image)->toBe('redis:7.0')
|
|
->and($security->problemCount)->toBe(3)
|
|
->and($security->fixedByPrimary)->toBe(2);
|
|
|
|
ResourceScans::refreshSummaries(ResourceScans::current($service));
|
|
expect(SecurityStatus::stored($service->fresh())->actions[0]->parts)->toBe($security->actions[0]->parts);
|
|
});
|
|
|
|
it('gives Coolify its status but no buttons', function () {
|
|
$coolify = statusTestApplication(['id' => 0]);
|
|
statusTestScan($coolify, [statusTestFinding('CVE-2024-0001', ['kev' => true])], ['candidates' => statusTestCandidates(['nginx:1.28' => ['CVE-2024-0001']])]);
|
|
|
|
$security = SecurityStatus::forResource($coolify);
|
|
|
|
expect($security->status)->toBe(SecurityStatusType::UpdateNow)
|
|
->and($security->actions[0]->button)->toBeFalse()
|
|
->and($security->actions[0]->sentence)->toBe('Update Coolify to get a newer version.');
|
|
});
|
|
});
|
|
|
|
describe('stored status', function () {
|
|
it('counts an old row without the stored status as not checked', function () {
|
|
statusTestScan($this->application, [statusTestFinding('CVE-2024-0001', ['kev' => true])]);
|
|
|
|
expect(SecurityStatus::stored($this->application))
|
|
->status->toBe(SecurityStatusType::NotChecked)
|
|
->checkReason->toBe('not_scanned');
|
|
});
|
|
|
|
it('stores the status of each scan with the summaries and reads it back like the resource page', function () {
|
|
$scan = statusTestScan($this->application, [
|
|
statusTestFinding('CVE-2024-0001', ['kev' => true]),
|
|
statusTestFinding('CVE-2024-0002', ['package' => 'curl']),
|
|
], ['candidates' => statusTestCandidates(['nginx:1.28' => ['CVE-2024-0001', 'CVE-2024-0002']])]);
|
|
|
|
ResourceScans::refreshSummaries(ResourceScans::currentFor($this->application));
|
|
|
|
$stored = SecurityStatus::stored($this->application);
|
|
$live = SecurityStatus::forResource($this->application);
|
|
expect($scan->fresh()->summary['security']['status'])->toBe('update_now')
|
|
->and($scan->fresh()->summary)->not->toHaveKey('fixable_now_at_level')
|
|
->and($stored->toArray())->toBe($live->toArray())
|
|
->and($stored->actions[0]->ref)->toBe('nginx:1.28');
|
|
});
|
|
|
|
it('applies the age of the newest result when it reads the stored status', function () {
|
|
statusTestScan($this->application, [], ['scanned_at' => now()]);
|
|
ResourceScans::refreshSummaries(ResourceScans::currentFor($this->application));
|
|
|
|
$this->travel(3)->days();
|
|
|
|
expect(SecurityStatus::stored($this->application))
|
|
->status->toBe(SecurityStatusType::CheckNeeded)
|
|
->checkReason->toBe('stale');
|
|
});
|
|
|
|
it('needs a check for a service with an image that has no stored status', function () {
|
|
$service = statusTestService();
|
|
$web = statusTestServiceApplication($service, 'web', 'nginx:1.27');
|
|
$worker = statusTestServiceApplication($service, 'worker', 'nginx:1.27');
|
|
statusTestScan($web, []);
|
|
statusTestScan($worker, []);
|
|
ResourceScans::refreshSummaries(ResourceScans::currentFor($web));
|
|
|
|
expect(SecurityStatus::stored($service))
|
|
->status->toBe(SecurityStatusType::CheckNeeded)
|
|
->checkReason->toBe('images_unchecked')
|
|
->checkContext->toBe(['checked' => 1, 'total' => 2]);
|
|
});
|
|
|
|
it('stores no status for a preview scan', function () {
|
|
$deployment = ApplicationDeploymentQueue::create([
|
|
'deployment_uuid' => Str::random(12),
|
|
'application_id' => $this->application->id,
|
|
'server_id' => $this->server->id,
|
|
'status' => ApplicationDeploymentStatus::FINISHED->value,
|
|
'pull_request_id' => 3,
|
|
]);
|
|
$scan = statusTestScan($this->application, [], ['deployment_queue_id' => $deployment->id]);
|
|
$scan->setRelation('resource', $this->application);
|
|
|
|
ResourceScans::refreshSummaries(collect([$scan]));
|
|
|
|
expect($scan->fresh()->summary)->not->toHaveKey('security');
|
|
});
|
|
|
|
it('keeps the action data with its action when a stored action cannot be read', function () {
|
|
statusTestScan($this->application, [
|
|
statusTestFinding('CVE-2024-0001', ['kev' => true]),
|
|
statusTestFinding('CVE-2024-0002', ['package' => 'curl']),
|
|
], ['candidates' => statusTestCandidates(['nginx:1.28' => ['CVE-2024-0002'], 'nginx:1.27' => ['CVE-2024-0001']])]);
|
|
$data = SecurityStatus::forResource($this->application)->toArray();
|
|
$data['actions'][0]['type'] = 'unknown';
|
|
|
|
$security = ResourceSecurity::fromArray($data);
|
|
|
|
expect($security->actions)->toHaveCount(1)
|
|
->and($security->actions[0]->type)->toBe(SecurityActionType::UpdateImage)
|
|
->and($security->facts['action_stats'])->toHaveCount(1)
|
|
->and($security->facts['action_stats'][0]['high'])->toBe(1);
|
|
});
|
|
|
|
it('round-trips through the array form', function () {
|
|
statusTestScan($this->application, [statusTestFinding('CVE-2024-0001', ['kev' => true])], ['candidates' => statusTestCandidates(['nginx:1.28' => ['CVE-2024-0001']])]);
|
|
$security = SecurityStatus::forResource($this->application);
|
|
|
|
expect(ResourceSecurity::fromArray($security->toArray())->toArray())->toBe($security->toArray());
|
|
});
|
|
|
|
it('reads the stored status of many Compose apps with the same number of queries', function () {
|
|
$composeApps = function (int $count): Collection {
|
|
return collect(range(1, $count))->map(function (int $index) use ($count): Application {
|
|
$application = statusTestApplication(['build_pack' => 'dockercompose', 'name' => "compose-{$count}-{$index}"]);
|
|
$deployment = ApplicationDeploymentQueue::create([
|
|
'deployment_uuid' => Str::random(12),
|
|
'application_id' => $application->id,
|
|
'server_id' => $this->server->id,
|
|
'status' => ApplicationDeploymentStatus::FINISHED->value,
|
|
'pull_request_id' => 0,
|
|
]);
|
|
$application->forceFill(['docker_compose' => Yaml::dump(['services' => [
|
|
'web' => ['image' => 'nginx:1.27'],
|
|
'db' => ['image' => 'postgres:16'],
|
|
]])])->save();
|
|
statusTestScan($application, [], ['deployment_queue_id' => $deployment->id]);
|
|
if ($index % 2 === 0) {
|
|
statusTestScan($application, [], ['deployment_queue_id' => $deployment->id, 'image_ref' => 'postgres:16']);
|
|
}
|
|
ResourceScans::refreshSummaries(ResourceScans::currentFor($application));
|
|
|
|
return $application->fresh();
|
|
});
|
|
};
|
|
$queries = function (Collection $applications): int {
|
|
DB::flushQueryLog();
|
|
DB::enableQueryLog();
|
|
SecurityStatus::storedMany($applications);
|
|
$count = count(DB::getQueryLog());
|
|
DB::disableQueryLog();
|
|
|
|
return $count;
|
|
};
|
|
|
|
$one = $composeApps(1);
|
|
$four = $composeApps(4);
|
|
|
|
expect($queries($one))->toBe($queries($four));
|
|
|
|
$stored = SecurityStatus::storedMany($four);
|
|
expect($four->map(fn (Application $application): string => $stored[ResourceScans::key($application)]->status->value)->all())
|
|
->toBe(['check_needed', 'ok', 'check_needed', 'ok'])
|
|
->and($stored[ResourceScans::key($four[0])]->checkContext)->toBe(['checked' => 1, 'total' => 2])
|
|
->and($stored[ResourceScans::key($four[0])]->toArray())->toBe(SecurityStatus::stored($four[0])->toArray());
|
|
});
|
|
});
|