mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-21 02:53:24 +00:00
7119435c3c
The button was disabled on a library that had already been scanned once, which is most of the time and exactly when somebody would press it — after updating definitions, say. It now re-checks everything rather than only what was never looked at, which is what its name says. A rescan keeps each file available until its new verdict arrives, so a full pass takes nothing offline. The two states it skips are a file already waiting for its first verdict and one whose bytes are gone. It is disabled for two honest reasons now — scanning is off, or a scan is already running — and says which. Results are green, amber and red: checked and fine, checked and could not be read, checked and something was found. The badge gained a warning variant to say the middle one, matching the amber the warning alert already uses; before this a missing file wore the same red as a virus. A file found missing is also stamped with the time it was checked, so it appears in the Activity list. It is a verdict like any other, and without the stamp it was decided somewhere nobody could see.
580 lines
23 KiB
PHP
580 lines
23 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Audit\Action;
|
|
use App\Modules\Audit\ActivityLog;
|
|
use App\Modules\Files\Jobs\ScanFileJob;
|
|
use App\Modules\Files\Models\File;
|
|
use App\Modules\Files\Scanning\NotScannedReason;
|
|
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 Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use Tests\Support\FakeVirusScanner;
|
|
|
|
beforeEach(function () {
|
|
Storage::fake('files');
|
|
$this->admin = User::factory()->create();
|
|
|
|
// Settings survive RefreshDatabase's rollback in the cache, so every
|
|
// value this file depends on is stated rather than assumed.
|
|
app(Settings::class)->set(Setting::VirusScanningEnabled, true);
|
|
app(Settings::class)->set(Setting::VirusScannerAddress, 'tcp://scanner.test:3310');
|
|
app(Settings::class)->set(Setting::VirusScanMaxSizeMb, 512);
|
|
app(Settings::class)->set(Setting::VirusUnscannablePolicy, 'allow');
|
|
app(Settings::class)->set(Setting::VirusScannerDownPolicy, 'allow');
|
|
app(Settings::class)->set(Setting::VirusScannerWaitMinutes, 10);
|
|
});
|
|
|
|
function fakeScanner(?ScanVerdict $verdict = null): FakeVirusScanner
|
|
{
|
|
$scanner = new FakeVirusScanner($verdict);
|
|
app()->instance(VirusScanner::class, $scanner);
|
|
|
|
return $scanner;
|
|
}
|
|
|
|
/** A stored file with real bytes, as an upload would leave it. */
|
|
function scannableFile(array $overrides = []): File
|
|
{
|
|
$path = 'uploads/'.Str::uuid()->toString().'.pdf';
|
|
Storage::disk('files')->put($path, 'some bytes');
|
|
|
|
return File::factory()->create(array_merge([
|
|
'path' => $path,
|
|
'disk' => 'files',
|
|
'size' => 10,
|
|
'scan_status' => ScanStatus::Pending,
|
|
'checksum' => hash('sha256', $path),
|
|
], $overrides));
|
|
}
|
|
|
|
/** Run the job the way the queue would, with this test's fake scanner. */
|
|
function runScan(File $file): void
|
|
{
|
|
(new ScanFileJob($file->id))->handle(
|
|
app(VirusScanner::class),
|
|
app(App\Modules\Files\Scanning\ScanPolicy::class),
|
|
app(App\Modules\Files\Scanning\ScanningConfig::class),
|
|
);
|
|
}
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| A verdict, and what this installation does with it
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
test('a clean file becomes available', function () {
|
|
fakeScanner(ScanVerdict::clean('FakeAV 1.0'));
|
|
$file = scannableFile();
|
|
|
|
runScan($file);
|
|
|
|
$file->refresh();
|
|
expect($file->scan_status)->toBe(ScanStatus::Clean)
|
|
->and($file->scan_status->isAvailable())->toBeTrue()
|
|
->and($file->scanned_at)->not->toBeNull()
|
|
->and($file->scan_engine)->toBe('FakeAV 1.0');
|
|
});
|
|
|
|
test('an infected file is quarantined and logged', function () {
|
|
fakeScanner(ScanVerdict::infected('Eicar-Test-Signature'));
|
|
$file = scannableFile();
|
|
|
|
runScan($file);
|
|
|
|
$file->refresh();
|
|
expect($file->scan_status)->toBe(ScanStatus::Infected)
|
|
->and($file->scan_status->isAvailable())->toBeFalse()
|
|
->and($file->scan_note)->toBe('Eicar-Test-Signature');
|
|
|
|
$entry = ActivityLog::query()->where('action', Action::FileQuarantined)->sole();
|
|
expect($entry->context['threat'])->toBe('Eicar-Test-Signature')
|
|
->and($entry->context['was_available'])->toBeFalse();
|
|
});
|
|
|
|
test('a quarantined file loses the thumbnails already rendered from it', function () {
|
|
fakeScanner(ScanVerdict::infected('Some.Threat'));
|
|
$file = scannableFile(['mime_type' => 'image/png']);
|
|
|
|
$paths = App\Modules\Files\Thumbnails\ThumbnailGenerator::pathsFor($file->id, 'image/png');
|
|
expect($paths)->not->toBeEmpty();
|
|
|
|
foreach ($paths as $path) {
|
|
Storage::disk('files')->put($path, 'rendered');
|
|
}
|
|
|
|
runScan($file);
|
|
|
|
foreach ($paths as $path) {
|
|
expect(Storage::disk('files')->exists($path))->toBeFalse();
|
|
}
|
|
});
|
|
|
|
test('a file the scanner cannot open is allowed through, marked, and logged', function () {
|
|
fakeScanner(ScanVerdict::encrypted());
|
|
$file = scannableFile();
|
|
|
|
runScan($file);
|
|
|
|
$file->refresh();
|
|
expect($file->scan_status)->toBe(ScanStatus::NotScanned)
|
|
->and($file->scan_note)->toBe(NotScannedReason::Encrypted->value)
|
|
->and($file->scan_status->isAvailable())->toBeTrue();
|
|
|
|
expect(ActivityLog::query()->where('action', Action::FileNotScanned)->count())->toBe(1);
|
|
});
|
|
|
|
test('the same file is blocked when this installation says to block', function () {
|
|
app(Settings::class)->set(Setting::VirusUnscannablePolicy, 'block');
|
|
fakeScanner(ScanVerdict::encrypted());
|
|
$file = scannableFile();
|
|
|
|
runScan($file);
|
|
|
|
expect($file->refresh()->scan_status)->toBe(ScanStatus::UnscannableBlocked)
|
|
->and($file->scan_status->isAvailable())->toBeFalse();
|
|
});
|
|
|
|
test('a file larger than the maximum never reaches the scanner at all', function () {
|
|
app(Settings::class)->set(Setting::VirusScanMaxSizeMb, 1);
|
|
|
|
// The real client, deliberately: refusing an oversized file before a
|
|
// socket is opened is its job, and a fake that answered anyway would
|
|
// hide the day that check moves or disappears. There is no scanner at
|
|
// the configured address, so anything but an early refusal here would
|
|
// come back as "unavailable" instead.
|
|
$stream = fopen('php://memory', 'r+');
|
|
$verdict = app(App\Modules\Files\Scanning\ClamAvScanner::class)->scan($stream, 2 * 1024 * 1024);
|
|
fclose($stream);
|
|
|
|
expect($verdict->outcome)->toBe(App\Modules\Files\Scanning\ScanOutcome::TooLarge);
|
|
});
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| A scanner that is not answering
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
test('a file waits while the scanner is down, then goes through', function () {
|
|
fakeScanner(ScanVerdict::unavailable('connection refused'));
|
|
$file = scannableFile();
|
|
|
|
runScan($file);
|
|
expect($file->refresh()->scan_status)->toBe(ScanStatus::Pending);
|
|
|
|
// Past the ten minutes this installation is willing to wait.
|
|
$this->travel(11)->minutes();
|
|
|
|
runScan($file);
|
|
|
|
$file->refresh();
|
|
expect($file->scan_status)->toBe(ScanStatus::NotScanned)
|
|
->and($file->scan_note)->toBe(NotScannedReason::ScannerUnavailable->value);
|
|
});
|
|
|
|
test('an installation set to hold keeps waiting however long it takes', function () {
|
|
app(Settings::class)->set(Setting::VirusScannerDownPolicy, 'hold');
|
|
fakeScanner(ScanVerdict::unavailable('connection refused'));
|
|
$file = scannableFile();
|
|
|
|
runScan($file);
|
|
$this->travel(3)->days();
|
|
runScan($file);
|
|
|
|
expect($file->refresh()->scan_status)->toBe(ScanStatus::Pending);
|
|
});
|
|
|
|
test('an upload identical to a quarantined file is quarantined without a scan', function () {
|
|
$scanner = fakeScanner(ScanVerdict::clean());
|
|
|
|
$known = scannableFile(['scan_status' => ScanStatus::Infected, 'scan_note' => 'Known.Threat']);
|
|
$copy = scannableFile(['checksum' => $known->checksum]);
|
|
|
|
runScan($copy);
|
|
|
|
expect($copy->refresh()->scan_status)->toBe(ScanStatus::Infected)
|
|
->and($copy->scan_note)->toBe('Known.Threat')
|
|
->and($scanner->scans)->toBe(0);
|
|
});
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Uploads
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
test('a new upload is pending while scanning is on', function () {
|
|
fakeScanner();
|
|
|
|
$file = app(App\Modules\Files\Uploads\StoreUploadedFile::class)->create(
|
|
uploader: $this->admin,
|
|
originalName: 'report.pdf',
|
|
path: 'uploads/report.pdf',
|
|
mimeType: 'application/pdf',
|
|
size: 10,
|
|
checksum: str_repeat('b', 64),
|
|
);
|
|
|
|
expect($file->scan_status)->toBe(ScanStatus::Pending);
|
|
});
|
|
|
|
test('a new upload is marked never scanned while scanning is off', function () {
|
|
app(Settings::class)->set(Setting::VirusScanningEnabled, false);
|
|
app(Settings::class)->set(Setting::VirusScannerAddress, '');
|
|
|
|
$file = app(App\Modules\Files\Uploads\StoreUploadedFile::class)->create(
|
|
uploader: $this->admin,
|
|
originalName: 'report.pdf',
|
|
path: 'uploads/report.pdf',
|
|
mimeType: 'application/pdf',
|
|
size: 10,
|
|
checksum: str_repeat('c', 64),
|
|
);
|
|
|
|
expect($file->scan_status)->toBe(ScanStatus::NotScanned)
|
|
->and($file->scan_note)->toBe(NotScannedReason::BeforeScanning->value);
|
|
});
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Nothing unchecked leaves the server
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
test('no route serves the bytes of a file that is still being checked', function () {
|
|
$file = scannableFile(['uploaded_by' => $this->admin->id, 'mime_type' => 'image/png']);
|
|
|
|
foreach ([
|
|
"/files/{$file->id}/download",
|
|
"/files/{$file->id}/thumbnail",
|
|
"/files/{$file->id}/preview",
|
|
] as $path) {
|
|
$this->actingAs($this->admin)->get($path)->assertStatus(423, "{$path} served a pending file");
|
|
}
|
|
});
|
|
|
|
test('a quarantined file is refused for the same routes', function () {
|
|
$file = scannableFile(['uploaded_by' => $this->admin->id, 'scan_status' => ScanStatus::Infected, 'scan_note' => 'X']);
|
|
|
|
$this->actingAs($this->admin)->get("/files/{$file->id}/download")->assertStatus(423);
|
|
});
|
|
|
|
test('a clean file downloads normally', function () {
|
|
$file = scannableFile(['uploaded_by' => $this->admin->id, 'scan_status' => ScanStatus::Clean]);
|
|
|
|
$this->actingAs($this->admin)->get("/files/{$file->id}/download")->assertOk();
|
|
});
|
|
|
|
test('a share link says the file is still being checked', function () {
|
|
$file = scannableFile();
|
|
$link = App\Modules\Files\Models\ShareLink::query()->create([
|
|
'shareable_type' => $file->getMorphClass(),
|
|
'shareable_id' => $file->id,
|
|
'token' => Str::random(32),
|
|
'created_by' => $this->admin->id,
|
|
]);
|
|
|
|
$this->get("/s/{$link->token}")->assertInertia(
|
|
fn (Inertia\Testing\AssertableInertia $page) => $page->component('share/show')->where('status', 'checking'),
|
|
);
|
|
|
|
$this->get("/s/{$link->token}/download")->assertRedirect(route('share.show', $link->token));
|
|
});
|
|
|
|
test('a pending file is not visible to the client it was shared with', function () {
|
|
$client = User::factory()->client()->create();
|
|
$file = scannableFile(['uploaded_by' => $this->admin->id]);
|
|
app(App\Modules\Files\Sharing\FileSharing::class)->assign($file, $client, $client->name);
|
|
|
|
expect(File::query()->visibleToClient($client)->count())->toBe(0);
|
|
|
|
$file->forceFill(['scan_status' => ScanStatus::Clean])->save();
|
|
|
|
expect(File::query()->visibleToClient($client)->count())->toBe(1);
|
|
});
|
|
|
|
test('a client still sees their own upload while it is being checked', function () {
|
|
$client = User::factory()->client()->create();
|
|
$file = scannableFile(['uploaded_by' => $client->id]);
|
|
|
|
expect(File::query()->visibleToClient($client)->pluck('id')->all())->toBe([$file->id]);
|
|
});
|
|
|
|
test('a pending file is left out of a zip', function () {
|
|
$pending = scannableFile(['uploaded_by' => $this->admin->id]);
|
|
$clean = scannableFile(['uploaded_by' => $this->admin->id, 'scan_status' => ScanStatus::Clean]);
|
|
|
|
$this->actingAs($this->admin)
|
|
->postJson('/zip-downloads', ['file_ids' => [$pending->id, $clean->id], 'folder_ids' => []])
|
|
->assertOk();
|
|
|
|
$zip = App\Modules\Files\Models\ZipDownload::query()->latest('id')->sole();
|
|
expect($zip->file_ids)->toBe([$clean->id]);
|
|
});
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Nobody is told about a file they cannot have yet
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
test('sharing a file still being checked tells nobody, and tells them when it clears', function () {
|
|
fakeScanner(ScanVerdict::clean());
|
|
$client = User::factory()->client()->create();
|
|
$file = scannableFile(['uploaded_by' => $this->admin->id]);
|
|
|
|
app(App\Modules\Files\Sharing\FileSharing::class)->assign($file, $client, $client->name);
|
|
|
|
expect(App\Modules\Notifications\InAppNotification::query()->where('user_id', $client->id)->count())->toBe(0);
|
|
|
|
runScan($file);
|
|
|
|
expect(App\Modules\Notifications\InAppNotification::query()->where('user_id', $client->id)->where('type', 'file_shared')->count())->toBe(1);
|
|
});
|
|
|
|
test('a file found infected is never announced', function () {
|
|
fakeScanner(ScanVerdict::infected('Some.Threat'));
|
|
$client = User::factory()->client()->create();
|
|
$file = scannableFile(['uploaded_by' => $this->admin->id]);
|
|
|
|
app(App\Modules\Files\Sharing\FileSharing::class)->assign($file, $client, $client->name);
|
|
runScan($file);
|
|
|
|
expect(App\Modules\Notifications\InAppNotification::query()->where('user_id', $client->id)->count())->toBe(0);
|
|
});
|
|
|
|
test('a share taken back while the file was being checked produces no email afterwards', function () {
|
|
fakeScanner(ScanVerdict::clean());
|
|
$client = User::factory()->client()->create();
|
|
$file = scannableFile(['uploaded_by' => $this->admin->id]);
|
|
|
|
$sharing = app(App\Modules\Files\Sharing\FileSharing::class);
|
|
$sharing->assign($file, $client, $client->name);
|
|
$sharing->unassign($file, $client, $client->name);
|
|
|
|
runScan($file);
|
|
|
|
expect(App\Modules\Notifications\InAppNotification::query()->where('user_id', $client->id)->count())->toBe(0);
|
|
});
|
|
|
|
test('re-scanning a file that was let through keeps it available, and does not announce it twice', function () {
|
|
// The hourly command asks again about files that went out unchecked
|
|
// while the scanner was down. Their recipients already have them, so
|
|
// they must not lose access while the answer comes back, and must not
|
|
// be told a second time when it does.
|
|
$client = User::factory()->client()->create();
|
|
$file = scannableFile([
|
|
'uploaded_by' => $this->admin->id,
|
|
'scan_status' => ScanStatus::NotScanned,
|
|
'scan_note' => NotScannedReason::ScannerUnavailable->value,
|
|
]);
|
|
|
|
app(App\Modules\Files\Sharing\FileSharing::class)->assign($file, $client, $client->name);
|
|
expect(App\Modules\Notifications\InAppNotification::query()->where('user_id', $client->id)->count())->toBe(1);
|
|
|
|
fakeScanner(ScanVerdict::clean());
|
|
$this->artisan('projectsend:scan-files')->assertSuccessful();
|
|
|
|
// Still theirs throughout, and now actually checked.
|
|
expect(File::query()->visibleToClient($client)->count())->toBe(1)
|
|
->and($file->refresh()->scan_status)->toBe(ScanStatus::Clean)
|
|
->and(App\Modules\Notifications\InAppNotification::query()->where('user_id', $client->id)->count())->toBe(1);
|
|
});
|
|
|
|
test('a backfill never hides the library it is working through', function () {
|
|
$client = User::factory()->client()->create();
|
|
$file = scannableFile([
|
|
'uploaded_by' => $this->admin->id,
|
|
'scan_status' => ScanStatus::NotScanned,
|
|
'scan_note' => NotScannedReason::BeforeScanning->value,
|
|
]);
|
|
app(App\Modules\Files\Sharing\FileSharing::class)->assign($file, $client, $client->name);
|
|
|
|
// Queued rather than run, which is the state a real backfill spends
|
|
// almost all of its time in: dispatched, not yet scanned.
|
|
Illuminate\Support\Facades\Queue::fake();
|
|
fakeScanner(ScanVerdict::clean());
|
|
|
|
$this->artisan('projectsend:scan-files', ['--existing' => true])->assertSuccessful();
|
|
|
|
expect($file->refresh()->scan_status)->toBe(ScanStatus::NotScanned)
|
|
->and(File::query()->visibleToClient($client)->count())->toBe(1);
|
|
});
|
|
|
|
test('a released file is announced then, not before', function () {
|
|
$client = User::factory()->client()->create();
|
|
$file = scannableFile(['uploaded_by' => $this->admin->id, 'scan_status' => ScanStatus::Infected, 'scan_note' => 'X']);
|
|
|
|
app(App\Modules\Files\Sharing\FileSharing::class)->assign($file, $client, $client->name);
|
|
expect(App\Modules\Notifications\InAppNotification::query()->where('user_id', $client->id)->count())->toBe(0);
|
|
|
|
confirmPassword($this->admin);
|
|
$this->actingAs($this->admin)->post("/files/{$file->id}/release", ['reason' => 'False positive']);
|
|
|
|
expect(App\Modules\Notifications\InAppNotification::query()->where('user_id', $client->id)->where('type', 'file_shared')->count())->toBe(1);
|
|
});
|
|
|
|
test('the activity log names the file it quarantined', function () {
|
|
// The scan job has no actor and attaches no subject, so a template
|
|
// written with :subject renders 'The file "" was quarantined'. Caught
|
|
// on a real dashboard, not by a test, which is why there is one now.
|
|
fakeScanner(ScanVerdict::infected('Eicar-Test-Signature'));
|
|
$file = scannableFile(['name' => 'Contrato firmado']);
|
|
|
|
runScan($file);
|
|
|
|
$entry = ActivityLog::query()->where('action', Action::FileQuarantined)->sole();
|
|
|
|
$presented = app(App\Modules\Audit\ActivityPresenter::class)->present($entry);
|
|
$line = strtr($presented['template'], collect($presented['replacements'])
|
|
->mapWithKeys(fn (string $value, string $key): array => [":{$key}" => $value])
|
|
->all());
|
|
|
|
expect($line)->toContain('Contrato firmado')
|
|
->toContain('Eicar-Test-Signature');
|
|
});
|
|
|
|
test('a library from before the scanner is what "scan existing files" actually finds', function () {
|
|
// The migration gives the column its default and writes no reason, so
|
|
// every file on every upgraded installation has scan_note = null. A
|
|
// backfill that looked for the reason found none of them — the whole
|
|
// feature was inert on exactly the libraries it exists for.
|
|
$old = File::factory()->create(['scan_status' => ScanStatus::NotScanned, 'scan_note' => null]);
|
|
$stated = File::factory()->create([
|
|
'scan_status' => ScanStatus::NotScanned,
|
|
'scan_note' => NotScannedReason::BeforeScanning->value,
|
|
]);
|
|
$letThrough = File::factory()->create([
|
|
'scan_status' => ScanStatus::NotScanned,
|
|
'scan_note' => NotScannedReason::ScannerUnavailable->value,
|
|
]);
|
|
|
|
$found = File::query()->neverScanned()->pluck('id')->all();
|
|
|
|
expect($found)->toContain($old->id)
|
|
->toContain($stated->id)
|
|
// Not this one: it was offered to a scanner that could not answer,
|
|
// and the hourly sweep already re-scans those.
|
|
->not->toContain($letThrough->id);
|
|
});
|
|
|
|
test('the backfill queues those files', function () {
|
|
fakeScanner(ScanVerdict::clean());
|
|
$old = scannableFile(['scan_status' => ScanStatus::NotScanned, 'scan_note' => null]);
|
|
|
|
Illuminate\Support\Facades\Queue::fake();
|
|
|
|
$this->artisan('projectsend:scan-files', ['--existing' => true])->assertSuccessful();
|
|
|
|
Illuminate\Support\Facades\Queue::assertPushed(ScanFileJob::class, fn (ScanFileJob $job): bool => $job->fileId === $old->id);
|
|
});
|
|
|
|
test('a file whose bytes are gone says so, and is not retried forever', function () {
|
|
// An orphaned row, or storage that moved. It used to be recorded as
|
|
// "the scanner could not be reached" — wrong on screen, and wrong in
|
|
// behaviour: that is the one reason the hourly sweep re-queues, so
|
|
// every missing file would have been rescanned every hour for good.
|
|
$scanner = fakeScanner(ScanVerdict::clean());
|
|
$file = scannableFile();
|
|
Storage::disk('files')->delete($file->path);
|
|
|
|
runScan($file);
|
|
|
|
$file->refresh();
|
|
expect($file->scan_status)->toBe(ScanStatus::Missing)
|
|
// Withheld: there is nothing to serve, and a client shown a file
|
|
// whose download fails is worse off than one who never saw it.
|
|
->and($file->scan_status->isAvailable())->toBeFalse()
|
|
// Never offered to the scanner: there was nothing to offer.
|
|
->and($scanner->scans)->toBe(0);
|
|
|
|
Illuminate\Support\Facades\Queue::fake();
|
|
$this->artisan('projectsend:scan-files')->assertSuccessful();
|
|
Illuminate\Support\Facades\Queue::assertNothingPushed();
|
|
});
|
|
|
|
test('a missing file is missing whatever the unscannable policy says', function () {
|
|
// "Allow files nobody could scan" is a decision about risk, and there
|
|
// is no risk in a file that cannot be served — only a problem
|
|
// somebody has to look at.
|
|
app(App\Modules\Platform\Settings\Settings::class)->set(Setting::VirusUnscannablePolicy, 'allow');
|
|
fakeScanner(ScanVerdict::clean());
|
|
$file = scannableFile();
|
|
Storage::disk('files')->delete($file->path);
|
|
|
|
runScan($file);
|
|
|
|
expect($file->refresh()->scan_status)->toBe(ScanStatus::Missing);
|
|
});
|
|
|
|
test('a new scan checks files that already have a verdict', function () {
|
|
// "New scan" after an engine update means "check my library again".
|
|
// The first version only queued files nothing had ever looked at, so
|
|
// on a library already scanned once the button did nothing — and was
|
|
// disabled for saying so.
|
|
fakeScanner(ScanVerdict::clean());
|
|
$clean = scannableFile(['scan_status' => ScanStatus::Clean]);
|
|
$letThrough = scannableFile([
|
|
'scan_status' => ScanStatus::NotScanned,
|
|
'scan_note' => NotScannedReason::TooLarge->value,
|
|
]);
|
|
$waiting = scannableFile(['scan_status' => ScanStatus::Pending]);
|
|
$gone = scannableFile(['scan_status' => ScanStatus::Missing]);
|
|
|
|
Illuminate\Support\Facades\Queue::fake();
|
|
|
|
$this->artisan('projectsend:scan-files', ['--all' => true])->assertSuccessful();
|
|
|
|
foreach ([$clean, $letThrough] as $file) {
|
|
Illuminate\Support\Facades\Queue::assertPushed(ScanFileJob::class, fn (ScanFileJob $job): bool => $job->fileId === $file->id);
|
|
}
|
|
|
|
// Nothing to re-ask about a file with no bytes.
|
|
Illuminate\Support\Facades\Queue::assertNotPushed(ScanFileJob::class, fn (ScanFileJob $job): bool => $job->fileId === $gone->id);
|
|
|
|
// The one already waiting is picked up by the ordinary sweep at the
|
|
// top of the command, as a first scan rather than as a rescan — the
|
|
// distinction the job itself turns on.
|
|
Illuminate\Support\Facades\Queue::assertPushed(
|
|
ScanFileJob::class,
|
|
fn (ScanFileJob $job): bool => $job->fileId === $waiting->id && $job->rescan === false,
|
|
);
|
|
});
|
|
|
|
test('a rescan of a clean file re-checks it, and finds what is there now', function () {
|
|
// What an engine update is for: the same bytes, a newer opinion.
|
|
fakeScanner(ScanVerdict::infected('Newly.Known.Threat'));
|
|
$file = scannableFile(['scan_status' => ScanStatus::Clean]);
|
|
|
|
(new ScanFileJob($file->id, true))->handle(
|
|
app(VirusScanner::class),
|
|
app(App\Modules\Files\Scanning\ScanPolicy::class),
|
|
app(App\Modules\Files\Scanning\ScanningConfig::class),
|
|
);
|
|
|
|
expect($file->refresh()->scan_status)->toBe(ScanStatus::Infected)
|
|
->and($file->scan_note)->toBe('Newly.Known.Threat');
|
|
});
|
|
|
|
test('a rescan leaves a file that is waiting for its first verdict alone', function () {
|
|
$scanner = fakeScanner(ScanVerdict::clean());
|
|
$file = scannableFile(['scan_status' => ScanStatus::Pending]);
|
|
|
|
(new ScanFileJob($file->id, true))->handle(
|
|
app(VirusScanner::class),
|
|
app(App\Modules\Files\Scanning\ScanPolicy::class),
|
|
app(App\Modules\Files\Scanning\ScanningConfig::class),
|
|
);
|
|
|
|
expect($scanner->scans)->toBe(0)
|
|
->and($file->refresh()->scan_status)->toBe(ScanStatus::Pending);
|
|
});
|