mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-19 10:05:11 +00:00
c15c9c48f8
Run against the dev stack with real ClamAV and queue workers, and a code review looking for ways around the scanner. Quarantine now stays quarantined until somebody releases the file. A rescan only touches files people can download, and changes nothing when the scanner cannot answer or scanning is off. Before, an old infected file rescanned while clamd restarted went through the "allow" policy and became downloadable. The daily missing-files check leaves quarantined files alone, so a storage outage no longer brings one back as a fresh upload. A file longer than clamd's StreamMaxLength is "too large" again. clamd answers and hangs up; the next write raised a warning that became an exception before the answer was read, so the file was recorded as "scanner down" and retried past the unscannable policy. The production compose example gives clamd the settings it needs. On its own defaults an encrypted zip comes back clean. The Test button now sends a password-protected zip and fails when it is called clean, and says when an address answers but is not ClamAV. Saving the settings restarts the queue workers, which kept the old values in memory. New scan runs --all, as its name says, and is refused while scans are queued. A retry scheduled for later no longer counts as a scan in progress. Also: quarantine respects client scope for listing, release and notifications; a zip built before a file was quarantined is refused; public comments and version links skip unavailable files; a client no longer sees their own quarantined or missing upload; a file whose bytes return is scanned at once; clamd listens on IPv6 too, so its container health check passes.
737 lines
31 KiB
PHP
737 lines
31 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]);
|
|
$infected = scannableFile(['scan_status' => ScanStatus::Infected, 'scan_note' => 'X']);
|
|
$blocked = scannableFile(['scan_status' => ScanStatus::UnscannableBlocked, 'scan_note' => NotScannedReason::Encrypted->value]);
|
|
$released = scannableFile(['scan_status' => ScanStatus::Released, 'scan_note' => 'X']);
|
|
|
|
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. Nor about one in
|
|
// quarantine, which leaves it by being released and not by a scan, or
|
|
// one somebody released, which a scan does not undo.
|
|
foreach ([$gone, $infected, $blocked, $released] as $file) {
|
|
Illuminate\Support\Facades\Queue::assertNotPushed(ScanFileJob::class, fn (ScanFileJob $job): bool => $job->fileId === $file->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);
|
|
});
|
|
|
|
test('the library does not list a file nobody can use', function () {
|
|
// Every button on such a row leads somewhere that refuses, and the
|
|
// download leads to an error page. They live on the two screens that
|
|
// exist to act on them.
|
|
$clean = scannableFile(['uploaded_by' => $this->admin->id, 'name' => 'Usable', 'scan_status' => ScanStatus::Clean]);
|
|
$quarantined = scannableFile(['uploaded_by' => $this->admin->id, 'name' => 'Infectado', 'scan_status' => ScanStatus::Infected, 'scan_note' => 'X']);
|
|
$gone = scannableFile(['uploaded_by' => $this->admin->id, 'name' => 'Sin bytes', 'scan_status' => ScanStatus::Missing]);
|
|
$waiting = scannableFile(['uploaded_by' => $this->admin->id, 'name' => 'Esperando', 'scan_status' => ScanStatus::Pending]);
|
|
|
|
$names = collect($this->actingAs($this->admin)->get('/files')->viewData('page')['props']['files'])->pluck('name');
|
|
|
|
expect($names)->toContain('Usable')
|
|
// Still listed: it is about to be usable, and its uploader should
|
|
// see where it went.
|
|
->toContain('Esperando')
|
|
->not->toContain('Infectado')
|
|
->not->toContain('Sin bytes');
|
|
|
|
expect([$clean->id, $quarantined->id, $gone->id, $waiting->id])->toHaveCount(4);
|
|
});
|
|
|
|
test('the file editor says why a quarantined file refuses everything', function () {
|
|
$file = scannableFile(['uploaded_by' => $this->admin->id, 'scan_status' => ScanStatus::Infected, 'scan_note' => 'Eicar-Test-Signature']);
|
|
|
|
$this->actingAs($this->admin)->get("/files/{$file->id}")->assertInertia(
|
|
fn (Inertia\Testing\AssertableInertia $page) => $page
|
|
->where('file.scan_status', 'infected')
|
|
->where('file.scan_note', 'Eicar-Test-Signature'),
|
|
);
|
|
});
|
|
|
|
test('the editor offers no download for a file it cannot produce', function () {
|
|
$quarantined = scannableFile(['uploaded_by' => $this->admin->id, 'scan_status' => ScanStatus::Infected, 'scan_note' => 'X']);
|
|
$clean = scannableFile(['uploaded_by' => $this->admin->id, 'scan_status' => ScanStatus::Clean]);
|
|
|
|
$this->actingAs($this->admin)->get("/files/{$quarantined->id}")->assertInertia(
|
|
fn (Inertia\Testing\AssertableInertia $page) => $page->where('file.scan_available', false),
|
|
);
|
|
|
|
$this->actingAs($this->admin)->get("/files/{$clean->id}")->assertInertia(
|
|
fn (Inertia\Testing\AssertableInertia $page) => $page->where('file.scan_available', true),
|
|
);
|
|
});
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Nothing but a release takes a file out of quarantine
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
/** Run the job as a rescan, the way New scan and the hourly sweep queue it. */
|
|
function runRescan(File $file): void
|
|
{
|
|
(new ScanFileJob($file->id, true))->handle(
|
|
app(VirusScanner::class),
|
|
app(App\Modules\Files\Scanning\ScanPolicy::class),
|
|
app(App\Modules\Files\Scanning\ScanningConfig::class),
|
|
);
|
|
}
|
|
|
|
test('a rescan leaves a quarantined file in quarantine, whatever the scanner says', function () {
|
|
// The case that got out: an old infected file rescanned while clamd
|
|
// was restarting. Its wait window had long passed, so "unavailable"
|
|
// went straight to the allow policy and the file became downloadable.
|
|
foreach ([
|
|
ScanVerdict::unavailable('down'),
|
|
ScanVerdict::tooLarge(),
|
|
ScanVerdict::encrypted(),
|
|
ScanVerdict::clean(),
|
|
] as $verdict) {
|
|
foreach ([ScanStatus::Infected, ScanStatus::UnscannableBlocked, ScanStatus::Released] as $state) {
|
|
$scanner = fakeScanner($verdict);
|
|
$file = scannableFile(['scan_status' => $state, 'scan_note' => 'Eicar-Test-Signature', 'created_at' => now()->subYear()]);
|
|
|
|
runRescan($file);
|
|
|
|
expect($file->refresh()->scan_status)->toBe($state, "{$state->value} after {$verdict->outcome->name}")
|
|
->and($scanner->scans)->toBe(0);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('a rescan the scanner cannot answer leaves the file as it was', function () {
|
|
fakeScanner(ScanVerdict::unavailable('down'));
|
|
$file = scannableFile(['scan_status' => ScanStatus::Clean, 'created_at' => now()->subYear()]);
|
|
|
|
runRescan($file);
|
|
|
|
expect($file->refresh()->scan_status)->toBe(ScanStatus::Clean);
|
|
});
|
|
|
|
test('a rescan that finds scanning switched off changes nothing', function () {
|
|
app(Settings::class)->set(Setting::VirusScanningEnabled, false);
|
|
$scanner = fakeScanner(ScanVerdict::clean());
|
|
$clean = scannableFile(['scan_status' => ScanStatus::Clean]);
|
|
$infected = scannableFile(['scan_status' => ScanStatus::Infected, 'scan_note' => 'X']);
|
|
|
|
runRescan($clean);
|
|
runRescan($infected);
|
|
|
|
expect($clean->refresh()->scan_status)->toBe(ScanStatus::Clean)
|
|
->and($infected->refresh()->scan_status)->toBe(ScanStatus::Infected)
|
|
->and($scanner->scans)->toBe(0);
|
|
});
|
|
|
|
test('a client does not see their own upload once it is quarantined or gone', function () {
|
|
// Listed, it offered a download that answered with an error page. The
|
|
// uploader hears about a blocked upload by notification.
|
|
$client = User::factory()->client()->create();
|
|
$waiting = scannableFile(['uploaded_by' => $client->id]);
|
|
scannableFile(['uploaded_by' => $client->id, 'scan_status' => ScanStatus::Infected, 'scan_note' => 'X']);
|
|
scannableFile(['uploaded_by' => $client->id, 'scan_status' => ScanStatus::UnscannableBlocked]);
|
|
scannableFile(['uploaded_by' => $client->id, 'scan_status' => ScanStatus::Missing]);
|
|
|
|
expect(File::query()->visibleToClient($client)->pluck('id')->all())->toBe([$waiting->id]);
|
|
});
|
|
|
|
test('an archive built before one of its files was quarantined is refused', function () {
|
|
$file = scannableFile(['uploaded_by' => $this->admin->id, 'scan_status' => ScanStatus::Clean]);
|
|
|
|
$zipId = $this->actingAs($this->admin)->postJson('/zip-downloads', ['file_ids' => [$file->id]])->assertOk()->json('id');
|
|
$this->actingAs($this->admin)->getJson("/zip-downloads/{$zipId}")->assertJsonPath('status', 'ready');
|
|
|
|
// A rescan with newer definitions, after the zip was ready.
|
|
$file->forceFill(['scan_status' => ScanStatus::Infected, 'scan_note' => 'Newly.Known'])->save();
|
|
|
|
$this->actingAs($this->admin)->get("/zip-downloads/{$zipId}/download")->assertStatus(423);
|
|
});
|
|
|
|
test('nobody can comment on a public file that is not available', function () {
|
|
app(Settings::class)->set(Setting::PublicListingEnabled, true);
|
|
app(Settings::class)->set(Setting::PublicListingSlug, 'public');
|
|
app(Settings::class)->set(Setting::CommentsScope, 'all');
|
|
app(Settings::class)->set(Setting::CommentsAuthors, 'everyone');
|
|
app(Settings::class)->set(Setting::PublicCommentsEnabled, true);
|
|
app(Settings::class)->set(Setting::CaptchaProvider, 'none');
|
|
|
|
$group = App\Modules\Groups\Models\Group::query()->create(['name' => 'Showcase', 'public' => true]);
|
|
$file = File::factory()->public()->create(['uploaded_by' => $this->admin->id, 'scan_status' => ScanStatus::Clean]);
|
|
shareFileWithGroup($file, $group);
|
|
|
|
$this->getJson("/public/files/{$file->slug}/comments")->assertOk();
|
|
|
|
$file->forceFill(['scan_status' => ScanStatus::Infected, 'scan_note' => 'X'])->save();
|
|
|
|
// Same answer as the file's own public page, which does not exist for
|
|
// a file nobody can have.
|
|
$this->getJson("/public/files/{$file->slug}/comments")->assertNotFound();
|
|
});
|