mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 17:15:08 +00:00
d445b01dd4
"New scan" sits beside Quarantine as the screen's one action, in the primary colour, on every tab. The "Files already here" box it replaces is gone: it held an action under a Save button, a counter that the Activity tab now shows live, and a field that belongs with the other settings, which is where it is now. The button says why when it cannot be pressed — scanning is off, every file has already been checked, or a scan is already running — rather than sitting grey with no explanation. It refuses a second scan while one is working through the queue, which is a thing somebody would otherwise do by clicking twice. Starting one lands on the Activity tab. A button whose screen looks unchanged afterwards reads as a button that did nothing, and this one has somewhere worth looking. Checked against a real ClamAV: 42 files queued, 31 came back clean, the rest were the ones whose bytes are missing. The button then disabled itself, because there was nothing left to scan.
411 lines
18 KiB
PHP
411 lines
18 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')));
|
|
|
|
$this->actingAs($this->admin)->post('/system/settings/virus-scanning/test')
|
|
->assertSessionHas('scanner_test_result', fn (array $result): bool => $result['ok'] === true);
|
|
});
|
|
|
|
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');
|
|
|
|
expect($scanner->scans)->toBe(1)
|
|
->and($scanner->sizes[0])->toBe(68);
|
|
});
|
|
|
|
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),
|
|
);
|
|
});
|