withoutVite(); InstanceSettings::forceCreate(['id' => 0]); $this->team = Team::factory()->create(); $this->admin = scanSettingsMember($this->team, 'admin'); $this->server = Server::factory()->create(['team_id' => $this->team->id]); $destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail(); $environment = Environment::factory()->create([ 'project_id' => Project::factory()->create(['team_id' => $this->team->id])->id, ]); $this->application = Application::factory()->create([ 'environment_id' => $environment->id, 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass(), ]); }); function scanSettingsMember(Team $team, string $role = 'member', ?array $access = null): User { $user = User::factory()->create(); $user->teams()->attach($team, ['role' => $role, 'access' => $access === null ? null : json_encode($access)]); return $user->fresh(); } function scanSettingsAs(User $user, Team $sessionTeam, string $scopeType, ?string $scopeUuid = null): Testable { test()->actingAs($user); session(['currentTeam' => $sessionTeam]); return Livewire::test(ScanSettings::class, ['scopeType' => $scopeType, 'scopeUuid' => $scopeUuid]); } function scanSettingsRow(Model $scope): ?ImageScanPolicy { return ImageScanPolicy::query() ->where('scope_type', $scope->getMorphClass()) ->where('scope_id', $scope->getKey()) ->first(); } function scanSettingsInstanceRow(): ImageScanPolicy { return ImageScanPolicy::query()->whereNull('scope_type')->firstOrFail(); } function scanSettingsInstanceAdmin(): User { $rootTeam = Team::factory()->create(['id' => 0]); return scanSettingsMember($rootTeam, 'owner'); } describe('instance scope', function () { it('saves the instance defaults', function () { $admin = scanSettingsInstanceAdmin(); scanSettingsAs($admin, Team::find(0), 'instance') ->set('gateMode', 'strict') ->assertHasNoErrors() ->assertDispatched('success') ->set('onScanError', 'block'); $row = scanSettingsInstanceRow(); expect($row->gate_mode)->toBe(ImageScanGateMode::Strict) ->and($row->on_scan_error)->toBe(ImageScanOnError::Block); }); it('never nulls the instance row', function () { $admin = scanSettingsInstanceAdmin(); scanSettingsAs($admin, Team::find(0), 'instance') ->assertDontSee('Use Coolify default') ->set('gateMode', '') ->assertHasErrors('gateMode') ->set('onScanError', '') ->assertHasErrors('onScanError'); $row = scanSettingsInstanceRow(); expect($row->gate_mode)->toBe(ImageScanGateMode::Off) ->and($row->on_scan_error)->toBe(ImageScanOnError::Deploy); }); it('rejects a gate mode that does not exist', function () { $admin = scanSettingsInstanceAdmin(); scanSettingsAs($admin, Team::find(0), 'instance') ->set('gateMode', 'always') ->assertHasErrors('gateMode'); expect(scanSettingsInstanceRow()->gate_mode)->toBe(ImageScanGateMode::Off); }); it('offers Off, On, and Strict with what On and Strict block', function () { scanSettingsAs(scanSettingsInstanceAdmin(), Team::find(0), 'instance') ->assertSee('Block deploys') ->assertSee('Strict') ->assertSee('Blocks a deploy when the new image adds a problem that attackers are using or are likely to use.') ->assertSee('Also blocks critical, high, and counted medium problems that have a fix, and operating systems that no longer get security fixes.') ->assertDontSee('Alert on'); }); it('forbids the instance rules to users who are not instance admins', function () { scanSettingsAs($this->admin, $this->team, 'instance')->assertForbidden(); }); }); describe('instance page', function () { it('redirects users who are not instance admins', function () { $this->actingAs($this->admin); session(['currentTeam' => $this->team]); Livewire::test(InstanceImageScanning::class)->assertRedirect(route('dashboard')); }); it('turns image scanning off for the instance', function () { $admin = scanSettingsInstanceAdmin(); $this->actingAs($admin); session(['currentTeam' => Team::find(0)]); Livewire::test(InstanceImageScanning::class) ->set('isImageScanningAllowed', '0') ->assertDispatched('success'); expect(InstanceSettings::query()->find(0)->is_image_scanning_allowed)->toBeFalse(); }); it('saves an https vulnerability database URL and rejects http and junk', function (string $url, bool $valid) { $admin = scanSettingsInstanceAdmin(); $this->actingAs($admin); session(['currentTeam' => Team::find(0)]); $component = Livewire::test(InstanceImageScanning::class) ->set('imageScanDbUrl', $url) ->call('saveDatabaseUrl'); if ($valid) { $component->assertHasNoErrors(); expect(InstanceSettings::query()->find(0)->image_scan_db_url)->toBe($url); } else { $component->assertHasErrors('imageScanDbUrl'); expect(InstanceSettings::query()->find(0)->image_scan_db_url)->toBeNull(); } })->with([ 'https' => ['https://mirror.example.com/grype/listing.json', true], 'http' => ['http://mirror.example.com/grype/listing.json', false], 'junk' => ['not a url', false], ]); }); describe('team scope', function () { it('saves the team rules and stores null for the Coolify default', function () { scanSettingsAs($this->admin, $this->team, 'team', (string) $this->team->id) ->assertSee('Use Coolify default (Off)') ->set('gateMode', 'on') ->assertDispatched('success'); expect(scanSettingsRow($this->team)->gate_mode)->toBe(ImageScanGateMode::On); scanSettingsAs($this->admin, $this->team, 'team', (string) $this->team->id) ->set('gateMode', '') ->assertHasNoErrors(); expect(scanSettingsRow($this->team)->gate_mode)->toBeNull(); }); it('saves the default for new servers', function () { scanSettingsAs($this->admin, $this->team, 'team', (string) $this->team->id) ->set('scanNewServers', '1') ->assertDispatched('success'); expect($this->team->fresh()->scan_images_on_new_servers)->toBeTrue(); }); it('does not let a member change the team rules', function () { $member = scanSettingsMember($this->team); scanSettingsAs($member, $this->team, 'team', (string) $this->team->id) ->set('gateMode', 'strict') ->assertForbidden(); expect(scanSettingsRow($this->team))->toBeNull(); }); it('edits the team of the page after the session switched to another team', function () { $otherTeam = Team::factory()->create(); $this->admin->teams()->attach($otherTeam, ['role' => 'owner']); $component = scanSettingsAs($this->admin, $this->team, 'team', (string) $this->team->id); session(['currentTeam' => $otherTeam]); $component->set('gateMode', 'strict')->assertHasNoErrors(); expect(scanSettingsRow($this->team)->gate_mode)->toBe(ImageScanGateMode::Strict) ->and(scanSettingsRow($otherTeam))->toBeNull(); }); it('returns not found for a team the user is not a member of', function () { $otherTeam = Team::factory()->create(); scanSettingsAs($this->admin, $this->team, 'team', (string) $otherTeam->id)->assertNotFound(); }); it('shows the team page read-only to members', function () { $member = scanSettingsMember($this->team); $this->actingAs($member); session(['currentTeam' => $this->team]); Livewire::test(TeamImageScanning::class)->assertOk()->assertSee('Rules'); }); }); describe('server scope', function () { it('names the level and value it inherits from', function () { ImageScanPolicy::query()->create([ 'scope_type' => $this->team->getMorphClass(), 'scope_id' => $this->team->id, 'gate_mode' => ImageScanGateMode::Strict, ]); scanSettingsAs($this->admin, $this->team, 'server', $this->server->uuid) ->assertSee('Use team default (Strict)') ->assertSee('Use Coolify default (Deploy anyway)') ->assertSee('Block deploys applies only to apps.'); }); it('saves the server rules and stores null for the inherited value', function () { scanSettingsAs($this->admin, $this->team, 'server', $this->server->uuid) ->set('gateMode', 'on') ->assertDispatched('success'); expect(scanSettingsRow($this->server)->gate_mode)->toBe(ImageScanGateMode::On); scanSettingsAs($this->admin, $this->team, 'server', $this->server->uuid) ->set('gateMode', '') ->assertHasNoErrors(); expect(scanSettingsRow($this->server)->gate_mode)->toBeNull(); }); it('forbids a change after the session switched to a team the user owns', function () { $member = scanSettingsMember($this->team); $ownTeam = Team::factory()->create(); $member->teams()->attach($ownTeam, ['role' => 'owner']); $component = scanSettingsAs($member, $this->team, 'server', $this->server->uuid); session(['currentTeam' => $ownTeam]); $component->set('gateMode', 'strict')->assertForbidden(); expect(scanSettingsRow($this->server))->toBeNull(); }); it('returns not found for a server of another team', function () { $stranger = scanSettingsMember(Team::factory()->create(), 'owner'); scanSettingsAs($stranger, $stranger->teams->first(), 'server', $this->server->uuid)->assertNotFound(); }); }); describe('application scope', function () { it('saves the app rules', function () { scanSettingsAs($this->admin, $this->team, 'application', $this->application->uuid) ->set('onScanError', 'block') ->assertDispatched('success'); expect(scanSettingsRow($this->application)->on_scan_error)->toBe(ImageScanOnError::Block); }); it('does not let a user with a deploy-only grant change the app rules', function () { $deployer = scanSettingsMember($this->team, 'member', [ ['scope' => 'resource', 'uuid' => $this->application->uuid, 'levels' => ['read', 'deploy']], ]); scanSettingsAs($deployer, $this->team, 'application', $this->application->uuid) ->set('gateMode', 'off') ->assertForbidden(); expect(scanSettingsRow($this->application))->toBeNull(); }); it('returns not found for an app of another team', function () { $stranger = scanSettingsMember(Team::factory()->create(), 'owner'); scanSettingsAs($stranger, $stranger->teams->first(), 'application', $this->application->uuid)->assertNotFound(); }); it('disables block deploys while the app scan is off', function () { scanSettingsAs($this->admin, $this->team, 'application', $this->application->uuid) ->set('appScanEnabled', '0') ->assertDispatched('success') ->assertSee('Turn on the scan for this app first.') ->set('gateMode', 'on') ->assertDispatched('error'); expect($this->application->settings->fresh()->is_image_scan_enabled)->toBeFalse() ->and(scanSettingsRow($this->application)?->gate_mode)->toBeNull(); }); it('saves who builds a Docker image app', function () { $this->application->update(['build_pack' => 'dockerimage']); $component = scanSettingsAs($this->admin, $this->team, 'application', $this->application->uuid) ->assertSee('Who builds this image') ->set('buildsByTeam', '0') ->assertDispatched('success'); expect($this->application->settings->fresh()->image_builds_by_team)->toBeFalse(); $component->set('buildsByTeam', ''); expect($this->application->settings->fresh()->image_builds_by_team)->toBeNull(); }); }); describe('ignore list', function () { beforeEach(function () { $this->teamIgnore = ImageScanIgnore::factory()->create(['team_id' => $this->team->id, 'vulnerability_id' => 'CVE-2024-11111']); $this->appIgnore = ImageScanIgnore::factory()->create([ 'team_id' => $this->team->id, 'resource_type' => $this->application->getMorphClass(), 'resource_id' => $this->application->id, 'vulnerability_id' => 'CVE-2024-22222', ]); }); it('lists and removes team-wide ignores on the team scope', function () { scanSettingsAs($this->admin, $this->team, 'team', (string) $this->team->id) ->assertSee('CVE-2024-11111') ->assertDontSee('CVE-2024-22222') ->call('removeIgnore', $this->teamIgnore->uuid) ->assertDispatched('success'); expect(ImageScanIgnore::query()->whereKey($this->teamIgnore->id)->exists())->toBeFalse(); }); it('does not let a member remove a team-wide ignore', function () { $member = scanSettingsMember($this->team); scanSettingsAs($member, $this->team, 'team', (string) $this->team->id) ->call('removeIgnore', $this->teamIgnore->uuid) ->assertForbidden(); expect(ImageScanIgnore::query()->whereKey($this->teamIgnore->id)->exists())->toBeTrue(); }); it('cannot remove an ignore of another team', function () { $otherTeam = Team::factory()->create(); $otherIgnore = ImageScanIgnore::factory()->create(['team_id' => $otherTeam->id]); scanSettingsAs($this->admin, $this->team, 'team', (string) $this->team->id) ->call('removeIgnore', $otherIgnore->uuid) ->assertNotFound(); expect(ImageScanIgnore::query()->whereKey($otherIgnore->id)->exists())->toBeTrue(); }); it('shows team-wide ignores read-only on the app scope and removes app ignores', function () { scanSettingsAs($this->admin, $this->team, 'application', $this->application->uuid) ->assertSee('CVE-2024-11111') ->assertSee('All team resources') ->assertSee('CVE-2024-22222') ->call('removeIgnore', $this->teamIgnore->uuid) ->assertNotFound(); expect(ImageScanIgnore::query()->whereKey($this->teamIgnore->id)->exists())->toBeTrue(); scanSettingsAs($this->admin, $this->team, 'application', $this->application->uuid) ->call('removeIgnore', $this->appIgnore->uuid) ->assertDispatched('success'); expect(ImageScanIgnore::query()->whereKey($this->appIgnore->id)->exists())->toBeFalse(); }); it('links team-wide ignores to the team settings only for the session team with team-wide access', function () { scanSettingsAs($this->admin, $this->team, 'application', $this->application->uuid) ->assertSee('Team settings'); $ownTeam = Team::factory()->create(); $this->admin->teams()->attach($ownTeam, ['role' => 'owner']); scanSettingsAs($this->admin->fresh(), $ownTeam, 'application', $this->application->uuid) ->assertSee('CVE-2024-11111') ->assertDontSee('Team settings'); $limited = scanSettingsMember($this->team, 'member', [ ['scope' => 'resource', 'uuid' => $this->application->uuid, 'levels' => ['read', 'write']], ]); scanSettingsAs($limited, $this->team, 'application', $this->application->uuid) ->assertSee('CVE-2024-11111') ->assertDontSee('Team settings'); }); it('queues a refresh of the team counts after a team-wide ignore is removed', function () { Queue::fake(); scanSettingsAs($this->admin, $this->team, 'team', (string) $this->team->id) ->call('removeIgnore', $this->teamIgnore->uuid); Queue::assertPushed(RefreshImageScanSummariesJob::class, fn (RefreshImageScanSummariesJob $job) => $job->teamId === $this->team->id); }); it('refreshes the stored count of the app after an app ignore is removed', function () { $scan = ImageScan::factory()->create([ 'server_id' => $this->server->id, 'resource_type' => $this->application->getMorphClass(), 'resource_id' => $this->application->id, 'builds_image' => true, 'findings' => [[ 'id' => 'CVE-2024-22222', 'aliases' => [], 'severity' => 'critical', 'package' => 'openssl', 'version' => '1', 'type' => 'npm', 'artifactId' => 'a', 'fixedIn' => '2', 'fixState' => 'fixed', 'kev' => false, 'epss' => null, 'risk' => 1.0, 'summary' => '', 'layer' => 'app', ]], 'summary' => ['ignored' => 1], ]); scanSettingsAs($this->admin, $this->team, 'application', $this->application->uuid) ->call('removeIgnore', $this->appIgnore->uuid); expect(ResourceScans::storedFixedCount($scan->fresh()))->toBe(1) ->and($scan->fresh()->summary['ignored'])->toBe(0); }); it('does not let a deploy-only user remove an app ignore', function () { $deployer = scanSettingsMember($this->team, 'member', [ ['scope' => 'resource', 'uuid' => $this->application->uuid, 'levels' => ['read', 'deploy']], ]); scanSettingsAs($deployer, $this->team, 'application', $this->application->uuid) ->call('removeIgnore', $this->appIgnore->uuid) ->assertForbidden(); expect(ImageScanIgnore::query()->whereKey($this->appIgnore->id)->exists())->toBeTrue(); }); }); describe('ignore list of a database and a service', function () { beforeEach(function () { Queue::fake(); $environment = $this->application->environment; $destination = $this->application->destination; $this->database = create_standalone_postgresql($environment->id, $destination); $this->service = Service::factory()->create([ 'environment_id' => $environment->id, 'server_id' => $this->server->id, 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass(), ]); $this->serviceApp = ServiceApplication::forceCreate(['uuid' => (string) Str::uuid(), 'service_id' => $this->service->id, 'name' => 'web', 'image' => 'nginx:1.27']); $this->serviceDb = ServiceDatabase::forceCreate(['uuid' => (string) Str::uuid(), 'service_id' => $this->service->id, 'name' => 'db', 'image' => 'postgres:16']); $ignore = fn (Model $owner, string $id): ImageScanIgnore => ImageScanIgnore::factory()->create([ 'team_id' => $this->team->id, 'resource_type' => $owner->getMorphClass(), 'resource_id' => $owner->getKey(), 'vulnerability_id' => $id, ]); $this->teamIgnore = ImageScanIgnore::factory()->create(['team_id' => $this->team->id, 'vulnerability_id' => 'CVE-2024-10000']); $this->databaseIgnore = $ignore($this->database, 'CVE-2024-10001'); $this->serviceAppIgnore = $ignore($this->serviceApp, 'CVE-2024-10002'); $this->serviceDbIgnore = $ignore($this->serviceDb, 'CVE-2024-10003'); $this->appIgnore = $ignore($this->application, 'CVE-2024-10004'); }); it('lists the ignores of a database with the team-wide ones and removes a database ignore', function () { scanSettingsAs($this->admin, $this->team, 'database', $this->database->uuid) ->assertSee('Ignored findings') ->assertSeeInOrder(['CVE-2024-10001', 'This resource', 'CVE-2024-10000', 'All team resources']) ->assertDontSee('CVE-2024-10002') ->assertDontSee('CVE-2024-10004') ->assertDontSee('If the scan cannot run') ->call('removeIgnore', $this->databaseIgnore->uuid) ->assertDispatched('success'); expect(ImageScanIgnore::query()->whereKey($this->databaseIgnore->id)->exists())->toBeFalse(); }); it('lists the ignores of the applications and databases of a service with their names and removes them', function () { scanSettingsAs($this->admin, $this->team, 'service', $this->service->uuid) ->assertSee('CVE-2024-10002') ->assertSee('CVE-2024-10003') ->assertSee('CVE-2024-10000') ->assertDontSee('CVE-2024-10001') ->assertSeeInOrder(['CVE-2024-10003', 'db']) ->call('removeIgnore', $this->serviceDbIgnore->uuid) ->assertDispatched('success') ->call('removeIgnore', $this->serviceAppIgnore->uuid) ->assertDispatched('success'); expect(ImageScanIgnore::query()->whereKey([$this->serviceAppIgnore->id, $this->serviceDbIgnore->id])->exists())->toBeFalse(); }); it('does not remove a team-wide ignore or an ignore of another resource from a resource page', function (string $scope) { $uuid = $scope === 'database' ? $this->database->uuid : $this->service->uuid; $other = $scope === 'database' ? $this->serviceAppIgnore : $this->databaseIgnore; scanSettingsAs($this->admin, $this->team, $scope, $uuid)->call('removeIgnore', $this->teamIgnore->uuid)->assertNotFound(); scanSettingsAs($this->admin, $this->team, $scope, $uuid)->call('removeIgnore', $other->uuid)->assertNotFound(); scanSettingsAs($this->admin, $this->team, $scope, $uuid)->call('removeIgnore', $this->appIgnore->uuid)->assertNotFound(); expect(ImageScanIgnore::query()->whereKey([$this->teamIgnore->id, $other->id, $this->appIgnore->id])->count())->toBe(3); })->with(['database', 'service']); it('lets a member with write access remove an ignore, and not a member with read or deploy access', function (string $scope) { [$uuid, $ignore] = $scope === 'database' ? [$this->database->uuid, $this->databaseIgnore] : [$this->service->uuid, $this->serviceAppIgnore]; foreach ([null, [['scope' => 'resource', 'uuid' => $uuid, 'levels' => ['read', 'deploy']]]] as $access) { scanSettingsAs(scanSettingsMember($this->team, 'member', $access), $this->team, $scope, $uuid) ->assertSee($ignore->vulnerability_id) ->assertDontSee('Remove') ->call('removeIgnore', $ignore->uuid) ->assertForbidden(); } expect(ImageScanIgnore::query()->whereKey($ignore->id)->exists())->toBeTrue(); $writer = scanSettingsMember($this->team, 'member', [['scope' => 'resource', 'uuid' => $uuid, 'levels' => ['read', 'write']]]); scanSettingsAs($writer, $this->team, $scope, $uuid) ->call('removeIgnore', $ignore->uuid) ->assertDispatched('success'); expect(ImageScanIgnore::query()->whereKey($ignore->id)->exists())->toBeFalse(); })->with(['database', 'service']); it('decides with the team of the resource after the session switches to a team the user owns', function (string $scope) { [$uuid, $ignore] = $scope === 'database' ? [$this->database->uuid, $this->databaseIgnore] : [$this->service->uuid, $this->serviceAppIgnore]; $user = scanSettingsMember($this->team); $ownTeam = Team::factory()->create(); $user->teams()->attach($ownTeam, ['role' => 'owner']); $user = $user->fresh(); $page = scanSettingsAs($user, $this->team, $scope, $uuid); session(['currentTeam' => $ownTeam]); $page->call('removeIgnore', $ignore->uuid)->assertForbidden(); expect(ImageScanIgnore::query()->whereKey($ignore->id)->exists())->toBeTrue(); })->with(['database', 'service']); it('does not open the ignores of a resource of another team', function (string $scope) { $uuid = $scope === 'database' ? $this->database->uuid : $this->service->uuid; $otherTeam = Team::factory()->create(); $stranger = scanSettingsMember($otherTeam, 'owner'); scanSettingsAs($stranger, $otherTeam, $scope, $uuid)->assertNotFound(); })->with(['database', 'service']); it('refreshes the stored counts of the service after an ignore is removed', function () { $scan = ImageScan::factory()->create([ 'server_id' => $this->server->id, 'resource_type' => $this->serviceApp->getMorphClass(), 'resource_id' => $this->serviceApp->id, 'image_ref' => 'nginx:1.27', 'builds_image' => false, 'findings' => [[ 'id' => 'CVE-2024-10002', 'aliases' => [], 'severity' => 'critical', 'package' => 'openssl', 'version' => '1', 'type' => 'apk', 'artifactId' => 'a', 'fixedIn' => '2', 'fixState' => 'fixed', 'kev' => false, 'epss' => null, 'risk' => 1.0, 'summary' => '', 'layer' => 'app', ]], 'summary' => ['ignored' => 1], ]); scanSettingsAs($this->admin, $this->team, 'service', $this->service->uuid) ->call('removeIgnore', $this->serviceAppIgnore->uuid); expect($scan->fresh()->summary['ignored'])->toBe(0); }); it('has no rules to save on a database or service page', function (string $scope) { $uuid = $scope === 'database' ? $this->database->uuid : $this->service->uuid; scanSettingsAs($this->admin, $this->team, $scope, $uuid)->set('gateMode', 'strict')->assertNotFound(); expect(ImageScanPolicy::query()->whereNotNull('scope_type')->count())->toBe(0); })->with(['database', 'service']); }); describe('stored statuses', function () { it('does not refresh the stored statuses when a rule changes', function (string $property, string $value) { Queue::fake(); scanSettingsAs($this->admin, $this->team, 'server', $this->server->uuid)->set($property, $value)->assertDispatched('success'); Queue::assertNotPushed(RefreshImageScanSummariesJob::class); })->with([ 'block deploys' => ['gateMode', 'strict'], 'if the scan cannot run' => ['onScanError', 'block'], ]); }); describe('count medium problems', function () { it('saves the instance default without an inherit option and refreshes every team with scans', function () { Queue::fake([RefreshImageScanSummariesJob::class]); ImageScan::factory()->create(['server_id' => $this->server->id]); $admin = scanSettingsInstanceAdmin(); scanSettingsAs($admin, Team::find(0), 'instance') ->assertSee('Count medium problems') ->assertSee('Medium problems that an update fixes give a security update. Problems that attackers are likely to use always count.') ->set('countMedium', '') ->assertHasErrors('countMedium') ->set('countMedium', '1') ->assertHasNoErrors() ->assertDispatched('success'); expect(scanSettingsInstanceRow()->count_medium)->toBeTrue(); Queue::assertPushed(RefreshImageScanSummariesJob::class, fn (RefreshImageScanSummariesJob $job): bool => $job->teamId === $this->team->id); Queue::assertPushed(RefreshImageScanSummariesJob::class, 1); }); it('saves the team rule, stores null for the Coolify default, and refreshes the team', function () { Queue::fake([RefreshImageScanSummariesJob::class]); scanSettingsAs($this->admin, $this->team, 'team', (string) $this->team->id) ->assertSee('Use Coolify default (No)') ->set('countMedium', '1') ->assertDispatched('success'); expect(scanSettingsRow($this->team)->count_medium)->toBeTrue(); Queue::assertPushed(RefreshImageScanSummariesJob::class, fn (RefreshImageScanSummariesJob $job): bool => $job->teamId === $this->team->id); scanSettingsAs($this->admin, $this->team, 'team', (string) $this->team->id)->set('countMedium', ''); expect(scanSettingsRow($this->team)->count_medium)->toBeNull(); }); it('names the inherited value on a server and saves the server rule', function () { Queue::fake([RefreshImageScanSummariesJob::class]); ImageScanPolicy::query()->create(['scope_type' => $this->team->getMorphClass(), 'scope_id' => $this->team->id, 'count_medium' => true]); scanSettingsAs($this->admin, $this->team, 'server', $this->server->uuid) ->assertSee('Use team default (Yes)') ->set('countMedium', '0') ->assertDispatched('success'); expect(scanSettingsRow($this->server)->count_medium)->toBeFalse(); Queue::assertPushed(RefreshImageScanSummariesJob::class, fn (RefreshImageScanSummariesJob $job): bool => $job->teamId === $this->team->id); }); it('refreshes the stored status of an app right away', function () { $this->server->settings->forceFill(['is_image_scan_enabled' => true])->saveQuietly(); InstanceSettings::query()->find(0)->forceFill(['is_image_scanning_allowed' => true])->save(); Once::flush(); $scan = ImageScan::factory()->create([ 'server_id' => $this->server->id, 'resource_type' => $this->application->getMorphClass(), 'resource_id' => $this->application->id, 'builds_image' => true, 'findings' => [[ 'id' => 'CVE-2024-0001', 'aliases' => [], 'severity' => 'medium', 'package' => 'lodash', 'version' => '4.0.0', 'type' => 'npm', 'artifactId' => 'a', 'fixedIn' => '4.17.21', 'fixState' => 'fixed', 'kev' => false, 'epss' => null, ]], 'summary' => ['truncated' => false, 'eol_distro' => null], 'scanned_at' => now(), ]); $this->application->update(['build_pack' => 'nixpacks']); ResourceScans::refreshSummaries(collect([$scan->fresh()])); expect(data_get($scan->fresh()->summary, 'security.facts.counts.medium_fixed'))->toBe(0); scanSettingsAs($this->admin, $this->team, 'application', $this->application->uuid) ->set('countMedium', '1') ->assertDispatched('success'); expect(scanSettingsRow($this->application)->count_medium)->toBeTrue() ->and(data_get($scan->fresh()->summary, 'security.facts.counts.medium_fixed'))->toBe(1); }); it('does not let a member or a deploy-only user change the rule', function (string $scope) { Queue::fake([RefreshImageScanSummariesJob::class]); $user = $scope === 'application' ? scanSettingsMember($this->team, 'member', [['scope' => 'resource', 'uuid' => $this->application->uuid, 'levels' => ['read', 'deploy']]]) : scanSettingsMember($this->team); $uuid = match ($scope) { 'team' => (string) $this->team->id, 'server' => $this->server->uuid, 'application' => $this->application->uuid, }; scanSettingsAs($user, $this->team, $scope, $uuid)->set('countMedium', '1')->assertForbidden(); expect(ImageScanPolicy::query()->whereNotNull('scope_type')->count())->toBe(0); Queue::assertNothingPushed(); })->with(['team', 'server', 'application']); it('forbids a member after the session switched to a team the member owns', function () { $member = scanSettingsMember($this->team); $ownTeam = Team::factory()->create(); $member->teams()->attach($ownTeam, ['role' => 'owner']); $component = scanSettingsAs($member, $this->team, 'server', $this->server->uuid); session(['currentTeam' => $ownTeam]); $component->set('countMedium', '1')->assertForbidden(); expect(scanSettingsRow($this->server))->toBeNull(); }); it('returns not found for a server of another team', function () { $stranger = scanSettingsMember(Team::factory()->create(), 'owner'); scanSettingsAs($stranger, $stranger->teams->first(), 'server', $this->server->uuid)->assertNotFound(); }); });