Files
projectsend/tests/Feature/Files/FileVersionDisclosureTest.php
T
ignacionelson c15c9c48f8 Close the gaps an end-to-end and security pass found in virus scanning
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.
2026-09-17 02:48:03 -03:00

187 lines
7.7 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Files\Models\File;
use App\Modules\Files\Versions\FileVersionLinks;
use App\Modules\Files\Versions\FileVersions;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Inertia\Testing\AssertableInertia;
/**
* The rule everything hangs off: a version link is disclosed only to a
* viewer who can independently see BOTH files.
*
* These assert the rendered props rather than the presenter in isolation,
* because a theme reading `version` off the row is trusting that the
* filtering already happened.
*/
beforeEach(function () {
Storage::fake('files');
$this->admin = User::factory()->create();
$this->versions = app(FileVersions::class);
app(Settings::class)->set(Setting::Theme, 'default');
});
test('a client who can see both files is told about the link', function () {
$client = User::factory()->client()->create();
$original = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'Rev C']);
$revision = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'Rev D']);
shareFileWith($original, $client);
$this->versions->link($revision, $original, $this->admin);
$this->actingAs($client)->get(route('my-files.index'))->assertInertia(
function (AssertableInertia $page) {
$files = collect($page->toArray()['props']['files']);
expect($files->firstWhere('name', 'Rev C')['version']['next']['name'])->toBe('Rev D')
->and($files->firstWhere('name', 'Rev D')['version']['previous']['name'])->toBe('Rev C');
},
);
});
test('a client is never told about a counterpart that is expired for them', function () {
$client = User::factory()->client()->create();
$original = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'Rev C']);
$revision = File::factory()->create([
'uploaded_by' => $this->admin->id,
'name' => 'Rev D',
'expires_at' => now()->subDay(),
]);
shareFileWith($original, $client);
$this->versions->link($revision, $original, $this->admin);
// Rev D is past its expiry, so the client cannot reach it — and must
// not be told it exists either.
$this->actingAs($client)->get(route('my-files.index'))->assertInertia(
function (AssertableInertia $page) {
$files = collect($page->toArray()['props']['files']);
expect($files->pluck('name')->all())->toBe(['Rev C'])
->and($files->firstWhere('name', 'Rev C')['version']['next'])->toBeNull();
},
);
});
test('staff still see the link to a file the client cannot reach', function () {
$original = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'Rev C']);
$revision = File::factory()->create([
'uploaded_by' => $this->admin->id,
'name' => 'Rev D',
'expires_at' => now()->subDay(),
]);
$this->versions->link($revision, $original, $this->admin);
// Expiry hides a file from clients, never from staff — so the two
// audiences legitimately get different answers from the same rule.
$this->actingAs($this->admin)->get(route('files.edit', $original))->assertInertia(
fn (AssertableInertia $page) => $page->where('file.version.next.name', 'Rev D'),
);
});
test('the staff library sends the version links on every row', function () {
$original = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'Rev C']);
$revision = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'Rev D']);
$this->versions->link($revision, $original, $this->admin);
$this->actingAs($this->admin)->get(route('files.index'))->assertInertia(
function (AssertableInertia $page) {
$files = collect($page->toArray()['props']['files']);
expect($files->firstWhere('name', 'Rev C')['version']['next']['name'])->toBe('Rev D')
->and($files->firstWhere('name', 'Rev D')['version']['previous']['name'])->toBe('Rev C');
},
);
});
test('the details panel reports the links and where sharing really lives', function () {
$original = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'Rev C']);
$revision = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'Rev D']);
$this->versions->link($revision, $original, $this->admin);
$response = $this->actingAs($this->admin)->getJson(route('files.details', $revision));
$response->assertOk();
expect($response->json('version.previous.name'))->toBe('Rev C')
->and($response->json('sharing_root.name'))->toBe('Rev C');
});
test('the details panel reports no sharing root for an ordinary file', function () {
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
$response = $this->actingAs($this->admin)->getJson(route('files.details', $file));
expect($response->json('sharing_root'))->toBeNull()
->and($response->json('version.previous'))->toBeNull()
->and($response->json('version.next'))->toBeNull();
});
test('every portal theme receives the version prop on its file rows', function (string $theme) {
app(Settings::class)->set(Setting::Theme, $theme);
$client = User::factory()->client()->create();
$original = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'Rev C']);
$revision = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'Rev D']);
shareFileWith($original, $client);
$this->versions->link($revision, $original, $this->admin);
$this->actingAs($client)->get(route('my-files.index'))->assertInertia(
fn (AssertableInertia $page) => $page->component("portal/themes/{$theme}/my-files")
->has('files.0.version'),
);
})->with(['default', 'compact', 'drive', 'gallery']);
test('resolving version links for a page of files does not scale with the row count', function () {
$client = User::factory()->client()->create();
// 10 linked pairs: a per-row implementation would issue queries in
// proportion to this, a batched one would not.
foreach (range(1, 10) as $i) {
$original = File::factory()->create(['uploaded_by' => $this->admin->id]);
$revision = File::factory()->create(['uploaded_by' => $this->admin->id]);
shareFileWith($original, $client);
$this->versions->link($revision, $original, $this->admin);
}
$links = app(FileVersionLinks::class);
$files = File::query()->limit(20)->get();
DB::enableQueryLog();
$links->forMany($files, $client);
$queries = count(DB::getQueryLog());
DB::disableQueryLog();
// Two for the candidates, one for the visibility filter, plus whatever
// visibleToClient itself needs — the point is that it is a constant,
// not that it is exactly three.
expect($queries)->toBeLessThan(10);
});
test('a guest is not told about a public version that is not available', function () {
$original = File::factory()->public()->create(['uploaded_by' => $this->admin->id, 'name' => 'Rev C']);
$revision = File::factory()->public()->create(['uploaded_by' => $this->admin->id, 'name' => 'Rev D']);
$this->versions->link($revision, $original, $this->admin);
$links = app(FileVersionLinks::class);
expect($links->for($original, null)['next']['name'] ?? null)->toBe('Rev D');
// Still being checked, or quarantined: its public page 404s, so the
// badge would name a file and link to a page that refuses to load.
$revision->forceFill(['scan_status' => App\Modules\Files\Scanning\ScanStatus::Pending])->save();
expect($links->for($original->refresh(), null)['next'])->toBeNull();
});