mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-19 01:55:08 +00:00
3b5352d886
"Detected the test file" left it open what the file was, which reads like ProjectSend carrying something malicious. The Test button's description and both of its results now name EICAR and say it is a harmless file made only for testing, as do DOCKER.md and INSTALL.md.
629 lines
28 KiB
PHP
629 lines
28 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Files\Models\File;
|
|
use App\Modules\Files\Scanning\NotScannedReason;
|
|
use App\Modules\Files\Scanning\ScannerStatus;
|
|
use App\Modules\Files\Scanning\ScanStatus;
|
|
use App\Modules\Files\Scanning\ScanVerdict;
|
|
use App\Modules\Files\Scanning\VirusScanner;
|
|
use App\Modules\Platform\Settings\Setting;
|
|
use App\Modules\Platform\Settings\Settings;
|
|
use Inertia\Testing\AssertableInertia;
|
|
use Tests\Support\FakeVirusScanner;
|
|
|
|
beforeEach(function () {
|
|
$this->admin = User::factory()->create();
|
|
|
|
app(Settings::class)->set(Setting::VirusScanningEnabled, false);
|
|
app(Settings::class)->set(Setting::VirusScannerAddress, '');
|
|
app(Settings::class)->set(Setting::VirusUnscannablePolicy, 'allow');
|
|
app(Settings::class)->set(Setting::VirusScannerDownPolicy, 'allow');
|
|
|
|
config()->set('projectsend.scanning.address', null);
|
|
|
|
// The dashboard test below lands on the greeting instead of the
|
|
// dashboard otherwise — and settings survive RefreshDatabase's
|
|
// rollback in the cache, so both markers are stated rather than
|
|
// assumed.
|
|
app(Settings::class)->set(Setting::GettingStartedPending, false);
|
|
app(Settings::class)->set(Setting::UpdateWelcomeTo, '');
|
|
});
|
|
|
|
test('the screen shows what is configured and what is outstanding', function () {
|
|
File::factory()->create(['scan_status' => ScanStatus::NotScanned, 'scan_note' => NotScannedReason::ScannerUnavailable->value]);
|
|
File::factory()->create(['scan_status' => ScanStatus::NotScanned, 'scan_note' => NotScannedReason::BeforeScanning->value]);
|
|
// The shape every upgraded installation is actually in: the status
|
|
// from the column default, and no reason beside it. Counted, or the
|
|
// "Scan existing files" button sits disabled on a library of
|
|
// thousands.
|
|
File::factory()->create(['scan_status' => ScanStatus::NotScanned, 'scan_note' => null]);
|
|
File::factory()->create(['scan_status' => ScanStatus::Infected, 'scan_note' => 'X']);
|
|
|
|
$this->actingAs($this->admin)->get('/system/settings/virus-scanning')->assertInertia(
|
|
fn (AssertableInertia $page) => $page
|
|
->component('system/settings/virus-scanning')
|
|
->where('managed', false)
|
|
->where('counts.let_through', 1)
|
|
->where('counts.never_scanned', 2)
|
|
->where('counts.quarantined', 1),
|
|
);
|
|
});
|
|
|
|
test('scanning cannot be switched on without an address', function () {
|
|
$this->actingAs($this->admin)->patch('/system/settings/virus-scanning', [
|
|
'enabled' => true,
|
|
'address' => '',
|
|
'max_size_mb' => 512,
|
|
'unscannable_policy' => 'allow',
|
|
'scanner_down_policy' => 'allow',
|
|
'wait_minutes' => 10,
|
|
'existing_rate_per_minute' => 60,
|
|
])->assertSessionHasErrors('address');
|
|
|
|
expect(app(Settings::class)->get(Setting::VirusScanningEnabled))->toBeFalse();
|
|
});
|
|
|
|
test('an address and the policies are saved', function () {
|
|
$this->actingAs($this->admin)->patch('/system/settings/virus-scanning', [
|
|
'enabled' => true,
|
|
'address' => 'tcp://clamav:3310',
|
|
'max_size_mb' => 256,
|
|
'unscannable_policy' => 'block',
|
|
'scanner_down_policy' => 'hold',
|
|
'wait_minutes' => 20,
|
|
'existing_rate_per_minute' => 30,
|
|
])->assertSessionHasNoErrors();
|
|
|
|
$settings = app(Settings::class);
|
|
expect($settings->get(Setting::VirusScanningEnabled))->toBeTrue()
|
|
->and($settings->get(Setting::VirusScannerAddress))->toBe('tcp://clamav:3310')
|
|
->and($settings->get(Setting::VirusUnscannablePolicy))->toBe('block')
|
|
->and($settings->get(Setting::VirusScannerDownPolicy))->toBe('hold');
|
|
});
|
|
|
|
test('a managed installation keeps its policies but not the connection', function () {
|
|
config()->set('projectsend.scanning.address', 'tcp://fleet-scanner:3310');
|
|
|
|
$this->actingAs($this->admin)->get('/system/settings/virus-scanning')->assertInertia(
|
|
fn (AssertableInertia $page) => $page->where('managed', true)->where('enabled', true)->where('address', ''),
|
|
);
|
|
|
|
// Sending the fields anyway changes nothing about the connection, and
|
|
// cannot switch scanning off.
|
|
$this->actingAs($this->admin)->patch('/system/settings/virus-scanning', [
|
|
'enabled' => false,
|
|
'address' => 'tcp://somewhere-else:3310',
|
|
'max_size_mb' => 100,
|
|
'unscannable_policy' => 'block',
|
|
'scanner_down_policy' => 'hold',
|
|
'wait_minutes' => 10,
|
|
'existing_rate_per_minute' => 60,
|
|
])->assertSessionHasNoErrors();
|
|
|
|
$settings = app(Settings::class);
|
|
expect($settings->get(Setting::VirusScannerAddress))->toBe('')
|
|
->and(app(App\Modules\Files\Scanning\ScanningConfig::class)->enabled())->toBeTrue()
|
|
->and($settings->get(Setting::VirusUnscannablePolicy))->toBe('block');
|
|
});
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| The test button
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
test('the test button says when the scanner cannot be reached', function () {
|
|
app()->instance(VirusScanner::class, (new FakeVirusScanner)->reports(ScannerStatus::unreachable('No answer from tcp://clamav:3310.')));
|
|
|
|
$this->actingAs($this->admin)->post('/system/settings/virus-scanning/test')
|
|
->assertSessionHas('scanner_test_result', fn (array $result): bool => $result['ok'] === false);
|
|
});
|
|
|
|
test('the answer actually reaches the screen', function () {
|
|
// It did not, at first: the page read a flash prop that nothing
|
|
// shares, so the button appeared to do nothing at all. The result is
|
|
// handed over as a page prop, like the CAPTCHA screen's.
|
|
app()->instance(VirusScanner::class, new FakeVirusScanner(ScanVerdict::infected('Eicar-Test-Signature')));
|
|
|
|
$this->actingAs($this->admin)->post('/system/settings/virus-scanning/test');
|
|
|
|
$this->actingAs($this->admin)->get('/system/settings/virus-scanning')->assertInertia(
|
|
fn (AssertableInertia $page) => $page->where('test_result.ok', true),
|
|
);
|
|
});
|
|
|
|
test('the screen opens on the scanner tab, and the other one is a link away', function () {
|
|
$this->actingAs($this->admin)->get('/system/settings/virus-scanning')->assertInertia(
|
|
fn (AssertableInertia $page) => $page->where('tab', 'scanner'),
|
|
);
|
|
|
|
$this->actingAs($this->admin)->get('/system/settings/virus-scanning?tab=options')->assertInertia(
|
|
fn (AssertableInertia $page) => $page->where('tab', 'options'),
|
|
);
|
|
|
|
// Anything else is the default rather than an error: a stale
|
|
// bookmark should open the page, not break it.
|
|
$this->actingAs($this->admin)->get('/system/settings/virus-scanning?tab=nonsense')->assertInertia(
|
|
fn (AssertableInertia $page) => $page->where('tab', 'scanner'),
|
|
);
|
|
});
|
|
|
|
test('the test button says when the scanner answers but detects nothing', function () {
|
|
// The failure that looks like success: reachable, and blind. Empty or
|
|
// broken virus definitions do exactly this.
|
|
app()->instance(VirusScanner::class, new FakeVirusScanner(ScanVerdict::clean('FakeAV 1.0')));
|
|
|
|
$this->actingAs($this->admin)->post('/system/settings/virus-scanning/test')
|
|
->assertSessionHas('scanner_test_result', fn (array $result): bool => $result['ok'] === false
|
|
&& str_contains($result['message'], 'did not detect'));
|
|
});
|
|
|
|
test('the test button confirms a working scanner', function () {
|
|
app()->instance(VirusScanner::class, new FakeVirusScanner(ScanVerdict::infected('Eicar-Test-Signature')));
|
|
|
|
// Named, and said to be harmless: "detected the test file" alone
|
|
// reads like the application shipping something malicious.
|
|
$this->actingAs($this->admin)->post('/system/settings/virus-scanning/test')
|
|
->assertSessionHas('scanner_test_result', fn (array $result): bool => $result['ok'] === true
|
|
&& str_contains($result['message'], 'EICAR')
|
|
&& str_contains($result['message'], 'harmless'));
|
|
});
|
|
|
|
test('the test button sends the standard test file, not an empty stream', function () {
|
|
$scanner = new FakeVirusScanner(ScanVerdict::infected('Eicar-Test-Signature'));
|
|
app()->instance(VirusScanner::class, $scanner);
|
|
|
|
$this->actingAs($this->admin)->post('/system/settings/virus-scanning/test');
|
|
|
|
// Then the encrypted archive, once the test file was detected.
|
|
expect($scanner->scans)->toBe(2)
|
|
->and($scanner->sizes[0])->toBe(68)
|
|
->and($scanner->sizes[1])->toBe(206);
|
|
});
|
|
|
|
test('the test button fails a scanner that calls an encrypted archive clean', function () {
|
|
// clamd on its own defaults: it detects the test file, and answers
|
|
// "OK" for an archive it cannot open. Every password-protected zip
|
|
// would have been recorded as clean while this button said "Working".
|
|
$scanner = (new FakeVirusScanner)->willAnswer(ScanVerdict::infected('Eicar-Test-Signature'), ScanVerdict::clean('FakeAV 1.0'));
|
|
app()->instance(VirusScanner::class, $scanner);
|
|
|
|
$this->actingAs($this->admin)->post('/system/settings/virus-scanning/test')
|
|
->assertSessionHas('scanner_test_result', fn (array $result): bool => $result['ok'] === false
|
|
&& str_contains($result['message'], 'AlertEncryptedArchive'));
|
|
});
|
|
|
|
test('saving tells the queue workers to pick the new settings up', function () {
|
|
// The scans worker holds settings in memory from the job it started
|
|
// on. Pointing it at another scanner, or switching scanning off,
|
|
// changed the screen and nothing else until it was restarted.
|
|
Illuminate\Support\Facades\Cache::forget('illuminate:queue:restart');
|
|
|
|
$this->actingAs($this->admin)->patch('/system/settings/virus-scanning', [
|
|
'enabled' => true,
|
|
'address' => 'tcp://clamav:3310',
|
|
'max_size_mb' => 512,
|
|
'unscannable_policy' => 'allow',
|
|
'scanner_down_policy' => 'allow',
|
|
'wait_minutes' => 10,
|
|
'existing_rate_per_minute' => 60,
|
|
])->assertSessionHasNoErrors();
|
|
|
|
expect(Illuminate\Support\Facades\Cache::get('illuminate:queue:restart'))->not->toBeNull();
|
|
});
|
|
|
|
/** The arguments a queued artisan command was queued with. */
|
|
function queuedCommandArguments(Illuminate\Foundation\Console\QueuedCommand $job): array
|
|
{
|
|
return (fn (): array => $this->data)->call($job);
|
|
}
|
|
|
|
test('new scan checks the library again, not only what was never scanned', function () {
|
|
app(Settings::class)->set(Setting::VirusScanningEnabled, true);
|
|
app(Settings::class)->set(Setting::VirusScannerAddress, 'tcp://scanner.test:3310');
|
|
Illuminate\Support\Facades\Queue::fake();
|
|
|
|
$this->actingAs($this->admin)->post('/system/settings/virus-scanning/scan-existing');
|
|
|
|
// --existing, which it ran before, finds nothing on a library that
|
|
// was scanned once: the button was enabled and did nothing.
|
|
Illuminate\Support\Facades\Queue::assertPushed(
|
|
Illuminate\Foundation\Console\QueuedCommand::class,
|
|
fn ($job): bool => queuedCommandArguments($job) === ['projectsend:scan-files', ['--all' => true]],
|
|
);
|
|
});
|
|
|
|
test('new scan is refused while a scan is still working through the queue', function () {
|
|
app(Settings::class)->set(Setting::VirusScanningEnabled, true);
|
|
app(Settings::class)->set(Setting::VirusScannerAddress, 'tcp://scanner.test:3310');
|
|
Illuminate\Support\Facades\Queue::fake();
|
|
App\Modules\Files\Jobs\ScanFileJob::dispatch(1, true);
|
|
|
|
$this->actingAs($this->admin)->post('/system/settings/virus-scanning/scan-existing')
|
|
->assertSessionHas('error');
|
|
|
|
// Each press queued the whole library again; only the screen stopped
|
|
// a second one.
|
|
Illuminate\Support\Facades\Queue::assertNotPushed(Illuminate\Foundation\Console\QueuedCommand::class);
|
|
});
|
|
|
|
test('only somebody who can edit settings may test or save', function () {
|
|
$staff = User::factory()->role(App\Modules\Identity\Permissions\SystemRole::Uploader)->create();
|
|
|
|
$this->actingAs($staff)->get('/system/settings/virus-scanning')->assertForbidden();
|
|
$this->actingAs($staff)->post('/system/settings/virus-scanning/test')->assertForbidden();
|
|
});
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Saying so where nobody is looking
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
/** The scanning block of the status document, as the fleet probe reads it. */
|
|
function scanningStatus(): array
|
|
{
|
|
Illuminate\Support\Facades\Artisan::call('projectsend:status', ['--json' => true]);
|
|
|
|
/** @var array<string, mixed> $document */
|
|
$document = json_decode(Illuminate\Support\Facades\Artisan::output(), true);
|
|
|
|
return $document['scanning'];
|
|
}
|
|
|
|
test('the status command reports scanning as off when it is off', function () {
|
|
$this->artisan('projectsend:status')->assertSuccessful();
|
|
|
|
// statusJson() lives in StatusCommandTest — Pest loads every test
|
|
// file into one process, so a second copy here would be a redeclare.
|
|
$status = scanningStatus();
|
|
|
|
expect($status['enabled'])->toBeFalse()
|
|
// Null, not false: there is nothing to reach. A watcher must be
|
|
// able to tell that from a scanner that should answer and does not.
|
|
->and($status['reachable'])->toBeNull();
|
|
});
|
|
|
|
test('the status command reports an unreachable scanner and what got through', function () {
|
|
app(Settings::class)->set(Setting::VirusScanningEnabled, true);
|
|
app(Settings::class)->set(Setting::VirusScannerAddress, 'tcp://nowhere.test:3310');
|
|
app()->instance(VirusScanner::class, (new FakeVirusScanner)->reports(ScannerStatus::unreachable('no answer')));
|
|
|
|
app(App\Modules\Audit\ActivityLogger::class)->logSystem(App\Modules\Audit\Action::FileNotScanned, [
|
|
'id' => 1, 'name' => 'x', 'reason' => 'scanner_unavailable',
|
|
]);
|
|
|
|
// statusJson() lives in StatusCommandTest — Pest loads every test
|
|
// file into one process, so a second copy here would be a redeclare.
|
|
$status = scanningStatus();
|
|
|
|
expect($status['enabled'])->toBeTrue()
|
|
->and($status['reachable'])->toBeFalse()
|
|
->and($status['let_through_24h'])->toBe(1);
|
|
});
|
|
|
|
test('the dashboard reports a healthy scanner, and says so when it stops answering', function () {
|
|
app(Settings::class)->set(Setting::VirusScanningEnabled, true);
|
|
app(Settings::class)->set(Setting::VirusScannerAddress, 'tcp://scanner.test:3310');
|
|
app()->instance(VirusScanner::class, new FakeVirusScanner);
|
|
|
|
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
|
|
fn (AssertableInertia $page) => $page->where('system.scanning.reachable', true),
|
|
);
|
|
|
|
app()->instance(VirusScanner::class, (new FakeVirusScanner)->reports(ScannerStatus::unreachable('no answer')));
|
|
|
|
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
|
|
fn (AssertableInertia $page) => $page->where('system.scanning.reachable', false),
|
|
);
|
|
});
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Nothing is checking what this installation accepts
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
test('an installation with no scanner is told so on the dashboard', function () {
|
|
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
|
|
fn (AssertableInertia $page) => $page
|
|
->where('system.scanning.configured', false)
|
|
->where('system.scanning.reachable', false),
|
|
);
|
|
});
|
|
|
|
test('a hosted installation is not told: the scanner is not its job', function () {
|
|
// The capability, not an edition check — see
|
|
// Capability::VirusScanningConnect. The System card is community-only
|
|
// in its own right, so on a hosted installation the whole card is
|
|
// absent and the notice with it; the assertion below is about the
|
|
// card, and the one on the settings screen (further down) is what
|
|
// pins the capability itself.
|
|
config(['projectsend.edition' => App\Modules\Platform\Capabilities\Edition::Cloud]);
|
|
forgetRequestState();
|
|
|
|
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
|
|
fn (AssertableInertia $page) => $page->where('system', null),
|
|
);
|
|
});
|
|
|
|
test('a working scanner is still reported, by name', function () {
|
|
// The row is always there, like the delivery and storage rows beside
|
|
// it: "my uploads are checked by ClamAV" is worth confirming at a
|
|
// glance, not only worth saying when it is false.
|
|
app(Settings::class)->set(Setting::VirusScanningEnabled, true);
|
|
app(Settings::class)->set(Setting::VirusScannerAddress, 'tcp://scanner.test:3310');
|
|
app()->instance(VirusScanner::class, new FakeVirusScanner);
|
|
|
|
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
|
|
fn (AssertableInertia $page) => $page
|
|
->where('system.scanning.configured', true)
|
|
->where('system.scanning.reachable', true)
|
|
->where('system.scanning.engine', 'FakeAV 1.0')
|
|
->where('system.scanning.let_through_24h', 0),
|
|
);
|
|
});
|
|
|
|
test('a hosted installation cannot connect a scanner of its own, but keeps its policies', function () {
|
|
config(['projectsend.edition' => App\Modules\Platform\Capabilities\Edition::Cloud]);
|
|
forgetRequestState();
|
|
|
|
$this->actingAs($this->admin)->get('/system/settings/virus-scanning')->assertInertia(
|
|
fn (AssertableInertia $page) => $page->where('managed', true),
|
|
);
|
|
|
|
$this->actingAs($this->admin)->post('/system/settings/virus-scanning/test')->assertForbidden();
|
|
|
|
$this->actingAs($this->admin)->patch('/system/settings/virus-scanning', [
|
|
'enabled' => true,
|
|
'address' => 'tcp://mine:3310',
|
|
'max_size_mb' => 256,
|
|
'unscannable_policy' => 'block',
|
|
'scanner_down_policy' => 'hold',
|
|
'wait_minutes' => 10,
|
|
'existing_rate_per_minute' => 60,
|
|
])->assertSessionHasNoErrors();
|
|
|
|
expect(app(Settings::class)->get(Setting::VirusScannerAddress))->toBe('')
|
|
->and(app(Settings::class)->get(Setting::VirusUnscannablePolicy))->toBe('block');
|
|
});
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Watching a scan happen
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
test('the activity endpoint says what is running and what was decided', function () {
|
|
$waiting = File::factory()->create(['scan_status' => ScanStatus::Pending]);
|
|
$done = File::factory()->create([
|
|
'name' => 'Contrato',
|
|
'scan_status' => ScanStatus::Infected,
|
|
'scan_note' => 'Eicar-Test-Signature',
|
|
'scanned_at' => now()->subMinute(),
|
|
]);
|
|
|
|
$body = $this->actingAs($this->admin)->getJson('/system/settings/virus-scanning/activity')->assertOk()->json();
|
|
|
|
expect($body['running'])->toBeTrue()
|
|
->and($body['waiting'])->toBe(1)
|
|
->and($body['checked_last_hour'])->toBe(1)
|
|
->and($body['quarantined'])->toBe(1)
|
|
->and($body['recent'][0]['name'])->toBe('Contrato')
|
|
->and($body['recent'][0]['note'])->toBe('Eicar-Test-Signature');
|
|
|
|
expect($waiting->fresh()->scan_status)->toBe(ScanStatus::Pending)
|
|
->and($done->fresh()->scan_status)->toBe(ScanStatus::Infected);
|
|
});
|
|
|
|
test('with nothing waiting it reports the last run rather than nothing at all', function () {
|
|
File::factory()->create([
|
|
'scan_status' => ScanStatus::Clean,
|
|
'scanned_at' => now()->subDays(2),
|
|
]);
|
|
|
|
$body = $this->actingAs($this->admin)->getJson('/system/settings/virus-scanning/activity')->assertOk()->json();
|
|
|
|
expect($body['running'])->toBeFalse()
|
|
->and($body['last_scanned_at'])->not->toBeNull()
|
|
->and($body['recent'])->toHaveCount(1);
|
|
});
|
|
|
|
test('a file that was never scanned reads as such rather than as a bare "not scanned"', function () {
|
|
File::factory()->create(['scan_status' => ScanStatus::NotScanned, 'scan_note' => null, 'scanned_at' => now()]);
|
|
|
|
$body = $this->actingAs($this->admin)->getJson('/system/settings/virus-scanning/activity')->assertOk()->json();
|
|
|
|
expect($body['recent'][0]['note'])->toContain('before virus scanning');
|
|
});
|
|
|
|
test('watching a scan needs the same permission as changing its settings', function () {
|
|
$staff = User::factory()->role(App\Modules\Identity\Permissions\SystemRole::Uploader)->create();
|
|
|
|
$this->actingAs($staff)->getJson('/system/settings/virus-scanning/activity')->assertForbidden();
|
|
});
|
|
|
|
test('a backfill counts as running even though it holds nothing back', function () {
|
|
// The case the first version of this screen got wrong: re-scanning
|
|
// files that already went out deliberately leaves them available, so
|
|
// nothing is "pending" and a screen watching only that said nothing
|
|
// was happening while the queue worked through a whole library.
|
|
Illuminate\Support\Facades\Queue::fake();
|
|
|
|
App\Modules\Files\Jobs\ScanFileJob::dispatch(1, true);
|
|
|
|
$body = $this->actingAs($this->admin)->getJson('/system/settings/virus-scanning/activity')->assertOk()->json();
|
|
|
|
expect($body['waiting'])->toBe(0)
|
|
->and($body['queued'])->toBe(1)
|
|
->and($body['running'])->toBeTrue();
|
|
});
|
|
|
|
test('starting a scan lands on the tab that shows it happening', function () {
|
|
// A button whose screen looks unchanged afterwards reads as a button
|
|
// that did nothing.
|
|
app(Settings::class)->set(Setting::VirusScanningEnabled, true);
|
|
app(Settings::class)->set(Setting::VirusScannerAddress, 'tcp://scanner.test:3310');
|
|
|
|
$this->actingAs($this->admin)->post('/system/settings/virus-scanning/scan-existing')
|
|
->assertRedirect(route('system-settings.virus-scanning.edit', ['tab' => 'activity']));
|
|
});
|
|
|
|
test('the screen says whether a scan is already under way', function () {
|
|
Illuminate\Support\Facades\Queue::fake();
|
|
App\Modules\Files\Jobs\ScanFileJob::dispatch(1, true);
|
|
|
|
$this->actingAs($this->admin)->get('/system/settings/virus-scanning')->assertInertia(
|
|
fn (AssertableInertia $page) => $page->where('counts.queued', 1),
|
|
);
|
|
});
|
|
|
|
test('a hosted installation is not offered a test it cannot run', function () {
|
|
config(['projectsend.edition' => App\Modules\Platform\Capabilities\Edition::Cloud]);
|
|
forgetRequestState();
|
|
|
|
$this->actingAs($this->admin)->get('/system/settings/virus-scanning')->assertInertia(
|
|
fn (AssertableInertia $page) => $page->where('can_test', false),
|
|
);
|
|
});
|
|
|
|
test('an installation that connects its own scanner is', function () {
|
|
$this->actingAs($this->admin)->get('/system/settings/virus-scanning')->assertInertia(
|
|
fn (AssertableInertia $page) => $page->where('can_test', true),
|
|
);
|
|
});
|
|
|
|
test('the test button tries the address on screen, not the one on file', function () {
|
|
// The real client, deliberately: a fake would answer whatever it was
|
|
// told and prove nothing about which address was used. Neither
|
|
// address has a scanner behind it, so the answer names the one it
|
|
// actually tried.
|
|
app(Settings::class)->set(Setting::VirusScannerAddress, 'tcp://saved.invalid:3310');
|
|
|
|
$this->actingAs($this->admin)
|
|
->post('/system/settings/virus-scanning/test', ['address' => 'tcp://typed.invalid:3310'])
|
|
->assertSessionHas('scanner_test_result', fn (array $result): bool => $result['ok'] === false
|
|
&& str_contains($result['message'], 'typed.invalid'));
|
|
|
|
// And the stored address is untouched: testing is not saving.
|
|
expect(app(Settings::class)->get(Setting::VirusScannerAddress))->toBe('tcp://saved.invalid:3310');
|
|
});
|
|
|
|
test('an empty field falls back to the address on file', function () {
|
|
app(Settings::class)->set(Setting::VirusScannerAddress, 'tcp://saved.invalid:3310');
|
|
|
|
$this->actingAs($this->admin)
|
|
->post('/system/settings/virus-scanning/test', ['address' => ''])
|
|
->assertSessionHas('scanner_test_result', fn (array $result): bool => str_contains($result['message'], 'saved.invalid'));
|
|
});
|
|
|
|
test('the sidebar carries a count of what is in quarantine', function () {
|
|
App\Modules\Files\Models\File::factory()->create(['scan_status' => ScanStatus::Infected, 'scan_note' => 'X']);
|
|
App\Modules\Files\Models\File::factory()->create(['scan_status' => ScanStatus::Clean]);
|
|
|
|
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
|
|
fn (AssertableInertia $page) => $page->where('pending.quarantine', 1),
|
|
);
|
|
});
|
|
|
|
test('somebody who cannot release is not shown the count', function () {
|
|
$staff = User::factory()->role(App\Modules\Identity\Permissions\SystemRole::Uploader)->create();
|
|
App\Modules\Files\Models\File::factory()->create(['scan_status' => ScanStatus::Infected, 'scan_note' => 'X']);
|
|
|
|
$this->actingAs($staff)->get('/dashboard')->assertInertia(
|
|
fn (AssertableInertia $page) => $page->missing('pending.quarantine'),
|
|
);
|
|
});
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| An address that only looks like one
|
|
|--------------------------------------------------------------------------
|
|
|
|
|
| stream_socket_client() reads a port the way atoi does — digits at the
|
|
| front, the rest ignored — so tcp://clamav:3310djlkasjdlk connects
|
|
| happily to 3310. An address with a typo on the end would be saved,
|
|
| tested, and reported as working.
|
|
|
|
|
*/
|
|
|
|
test('a malformed address is refused when saving', function () {
|
|
$this->actingAs($this->admin)->patch('/system/settings/virus-scanning', [
|
|
'enabled' => true,
|
|
'address' => 'tcp://clamav:3310djlkasjdlk',
|
|
'max_size_mb' => 512,
|
|
'unscannable_policy' => 'allow',
|
|
'scanner_down_policy' => 'allow',
|
|
'wait_minutes' => 10,
|
|
'existing_rate_per_minute' => 60,
|
|
])->assertSessionHasErrors('address');
|
|
|
|
expect(app(Settings::class)->get(Setting::VirusScannerAddress))->toBe('');
|
|
});
|
|
|
|
test('a malformed address is refused when testing, without dialling anything', function () {
|
|
$scanner = new FakeVirusScanner(ScanVerdict::infected('Eicar-Test-Signature'));
|
|
app()->instance(VirusScanner::class, $scanner);
|
|
|
|
$this->actingAs($this->admin)
|
|
->post('/system/settings/virus-scanning/test', ['address' => 'tcp://clamav:3310djlkasjdlk'])
|
|
->assertSessionHas('scanner_test_result', fn (array $result): bool => $result['ok'] === false);
|
|
|
|
expect($scanner->scans)->toBe(0);
|
|
});
|
|
|
|
test('the real client refuses a malformed address rather than connecting anyway', function () {
|
|
// The socket would take this one: PHP reads 3310 and drops the rest.
|
|
app(Settings::class)->set(Setting::VirusScannerAddress, 'tcp://clamav:3310djlkasjdlk');
|
|
app(Settings::class)->set(Setting::VirusScanningEnabled, true);
|
|
|
|
$status = app(App\Modules\Files\Scanning\ClamAvScanner::class)->status();
|
|
|
|
// Says which problem it is. "No answer" would be true of any
|
|
// unreachable scanner and would prove nothing about this guard — and
|
|
// would send an operator looking at their network for a typo.
|
|
expect($status->reachable)->toBeFalse()
|
|
->and($status->error)->toContain('tcp://host:3310');
|
|
});
|
|
|
|
test('the addresses people actually type are accepted', function () {
|
|
foreach (['tcp://clamav:3310', 'unix:///var/run/clamav/clamd.ctl', 'tcp://[::1]:3310', 'tcp://10.0.0.5:3310'] as $address) {
|
|
$this->actingAs($this->admin)->patch('/system/settings/virus-scanning', [
|
|
'enabled' => true,
|
|
'address' => $address,
|
|
'max_size_mb' => 512,
|
|
'unscannable_policy' => 'allow',
|
|
'scanner_down_policy' => 'allow',
|
|
'wait_minutes' => 10,
|
|
'existing_rate_per_minute' => 60,
|
|
])->assertSessionHasNoErrors("{$address} was refused");
|
|
|
|
expect(app(Settings::class)->get(Setting::VirusScannerAddress))->toBe($address);
|
|
}
|
|
});
|
|
|
|
test('a retry scheduled for later is not a scan in progress', function () {
|
|
// What a scanner outage leaves behind: a held file's next attempt,
|
|
// minutes away. Counted, it showed "Scanning now" and refused a new
|
|
// scan while nothing at all was running.
|
|
config(['queue.default' => 'database']);
|
|
app(Settings::class)->set(Setting::VirusScanningEnabled, true);
|
|
app(Settings::class)->set(Setting::VirusScannerAddress, 'tcp://scanner.test:3310');
|
|
|
|
App\Modules\Files\Jobs\ScanFileJob::dispatch(1)->delay(now()->addMinutes(5));
|
|
|
|
$this->actingAs($this->admin)->getJson('/system/settings/virus-scanning/activity')
|
|
->assertJsonPath('queued', 0)
|
|
->assertJsonPath('running', false);
|
|
|
|
App\Modules\Files\Jobs\ScanFileJob::dispatch(1);
|
|
|
|
$this->actingAs($this->admin)->getJson('/system/settings/virus-scanning/activity')
|
|
->assertJsonPath('queued', 1)
|
|
->assertJsonPath('running', true);
|
|
});
|
|
|