Files
projectsend/tests/Feature/Files/QuarantineTest.php
T
ignacionelson e9496dc357 Give quarantined files a screen, an owner, and somebody to tell
An infected file now goes somewhere rather than nowhere. Staff holding
the new release_quarantined_files permission get a Quarantine screen
listing what was refused, who uploaded it, and what the scanner called
it. They can delete it as they always could, or release it — which
needs a written reason, a password confirmation on top of the
permission, and lands in the activity log under their name.

Only the administrator role holds that permission by default. Deciding
a threat report is wrong is a different judgement from deciding a file
is no longer needed, which is why it is not delete_files.

Two notifications, two audiences: staff who can act on it, and the
person who uploaded it — for whom this is how they learn their own
machine has something on it. The people the file was shared with are
deliberately not told about a file they never received.

`projectsend:scan-files` runs hourly: it re-queues files still waiting,
and re-scans the ones that went out unscanned while the scanner was
unreachable, since it may be back. With --existing it also works
through a library uploaded before scanning was switched on, paced by a
setting so it does not starve today's uploads.

A file that was downloadable before it was caught says so on the
screen, with its download count, because that is the case where
somebody may already have a copy.
2026-09-16 14:29:41 -03:00

174 lines
6.9 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\Models\File;
use App\Modules\Files\Scanning\ScanStatus;
use App\Modules\Files\Scanning\ScanVerdict;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Models\RolePermission;
use App\Modules\Identity\Permissions\Permission;
use App\Modules\Notifications\InAppNotification;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia;
beforeEach(function () {
Storage::fake('files');
$this->admin = User::factory()->create();
app(Settings::class)->set(Setting::VirusScanningEnabled, true);
app(Settings::class)->set(Setting::VirusScannerAddress, 'tcp://scanner.test:3310');
app(Settings::class)->set(Setting::VirusUnscannablePolicy, 'allow');
app(Settings::class)->set(Setting::VirusScannerDownPolicy, 'allow');
app(Settings::class)->set(Setting::EmailNotificationsEnabled, false);
});
function quarantined(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::Infected,
'scan_note' => 'Eicar-Test-Signature',
'scanned_at' => now(),
], $overrides));
}
/*
|--------------------------------------------------------------------------
| Who may open it
|--------------------------------------------------------------------------
*/
test('the quarantine screen needs its own permission', function () {
$role = Role::query()->create(['name' => 'No release '.Str::random(4)]);
RolePermission::query()->create(['role_id' => $role->id, 'permission' => Permission::DeleteOthersFiles->value]);
$staff = User::factory()->create(['role_id' => $role->id]);
$this->actingAs($staff)->get('/files/quarantine')->assertForbidden();
// Deleting a file is not the same judgement as deciding the scanner
// was wrong, which is why this permission exists separately.
$file = quarantined();
$this->actingAs($staff)->post("/files/{$file->id}/release", ['reason' => 'looks fine'])->assertForbidden();
expect($file->refresh()->scan_status)->toBe(ScanStatus::Infected);
});
test('a client cannot reach it at all', function () {
$client = User::factory()->client()->create();
// EnsureStaff sends a client to their own dashboard rather than
// answering 403 — what matters here is that the screen is not served.
$this->actingAs($client)->get('/files/quarantine')->assertRedirect(route('dashboard'));
});
test('an administrator sees what is quarantined, and what got out first', function () {
$uploader = User::factory()->client()->create(['name' => 'Cliente Uno']);
$file = quarantined(['name' => 'Factura', 'uploaded_by' => $uploader->id, 'scan_was_available' => true]);
$this->actingAs($this->admin)->get('/files/quarantine')->assertInertia(
fn (AssertableInertia $page) => $page
->component('files/quarantine')
->where('files.0.name', 'Factura')
->where('files.0.uploader', 'Cliente Uno')
->where('files.0.threat', 'Eicar-Test-Signature')
->where('files.0.was_available', true),
);
});
/*
|--------------------------------------------------------------------------
| Releasing
|--------------------------------------------------------------------------
*/
test('releasing needs a reason, and records who gave it', function () {
$file = quarantined();
confirmPassword($this->admin);
$this->actingAs($this->admin)->post("/files/{$file->id}/release", ['reason' => ''])
->assertSessionHasErrors('reason');
expect($file->refresh()->scan_status)->toBe(ScanStatus::Infected);
$this->actingAs($this->admin)->post("/files/{$file->id}/release", ['reason' => 'False positive, reported upstream'])
->assertSessionHasNoErrors();
$file->refresh();
expect($file->scan_status)->toBe(ScanStatus::Released)
->and($file->released_by)->toBe($this->admin->id)
->and($file->released_at)->not->toBeNull();
$entry = ActivityLog::query()->where('action', Action::FileReleased)->sole();
expect($entry->actor_id)->toBe($this->admin->id)
->and($entry->context['reason'])->toBe('False positive, reported upstream');
});
test('a released file downloads again', function () {
$file = quarantined(['uploaded_by' => $this->admin->id]);
$this->actingAs($this->admin)->get("/files/{$file->id}/download")->assertStatus(423);
confirmPassword($this->admin);
$this->actingAs($this->admin)->post("/files/{$file->id}/release", ['reason' => 'Known false positive']);
$this->actingAs($this->admin)->get("/files/{$file->id}/download")->assertOk();
});
test('releasing asks for the password first', function () {
$file = quarantined();
// No confirmPassword() here: the middleware should send the request
// to the confirmation screen rather than release the file.
$this->actingAs($this->admin)->post("/files/{$file->id}/release", ['reason' => 'sure'])
->assertRedirect(route('password.confirm'));
expect($file->refresh()->scan_status)->toBe(ScanStatus::Infected);
});
test('a file that is not quarantined cannot be released', function () {
$file = quarantined(['scan_status' => ScanStatus::Clean, 'scan_note' => null]);
confirmPassword($this->admin);
$this->actingAs($this->admin)->post("/files/{$file->id}/release", ['reason' => 'why not'])->assertNotFound();
});
/*
|--------------------------------------------------------------------------
| Who is told
|--------------------------------------------------------------------------
*/
test('a quarantined file tells the administrators and the uploader, and nobody else', function () {
$uploader = User::factory()->client()->create();
$bystander = User::factory()->client()->create();
$file = quarantined(['scan_status' => ScanStatus::Pending, 'scan_note' => null, 'uploaded_by' => $uploader->id]);
app(App\Modules\Files\Scanning\ScanPolicy::class)->record($file, ScanVerdict::infected('Eicar-Test-Signature'));
$told = InAppNotification::query()->pluck('type', 'user_id');
expect($told[$this->admin->id] ?? null)->toBe('file_quarantined')
->and($told[$uploader->id] ?? null)->toBe('upload_blocked')
->and($told->has($bystander->id))->toBeFalse();
});
test('a staff member who uploaded it is told once, as staff', function () {
$file = quarantined(['scan_status' => ScanStatus::Pending, 'scan_note' => null, 'uploaded_by' => $this->admin->id]);
app(App\Modules\Files\Scanning\ScanPolicy::class)->record($file, ScanVerdict::infected('Eicar-Test-Signature'));
expect(InAppNotification::query()->where('user_id', $this->admin->id)->count())->toBe(1);
});