Files
projectsend/tests/Feature/Files/DetailsPanelTest.php
T
ignacionelson 6e47d76ba6 ProjectSend 2.0.0
Client file sharing, rebuilt from the ground up: a private area per
client, resumable uploads, folders, groups and categories, sharing with
expiry dates and download limits, comments, file versions, an activity
log, a REST API, and sixteen languages.

This repository begins here. ProjectSend 2 was developed privately, and
that development history is not published — the previous generation
remains available, with its own history, at projectsend/legacy.

Free software under the GNU General Public License v2, or (at your
option) any later version.
2026-08-14 01:38:12 -03:00

239 lines
10 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\Folders\FolderService;
use App\Modules\Files\Models\Category;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\ShareLink;
use App\Modules\Groups\Models\Group;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Models\RolePermission;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
beforeEach(function () {
Storage::fake('files');
$this->admin = User::factory()->create();
});
test('the file details endpoint returns metadata and current shares', function () {
$client = User::factory()->client()->create(['name' => 'Shared Client']);
$this->actingAs($this->admin)->post('/files', [
'file' => UploadedFile::fake()->create('report.pdf', 20, 'application/pdf'),
'name' => 'Report', 'description' => 'A test',
]);
$file = File::query()->sole();
$this->actingAs($this->admin)->post("/files/{$file->id}/assignments", ['type' => 'client', 'id' => $client->id]);
$this->actingAs($this->admin)->getJson("/files/{$file->id}/details")->assertOk()->assertJson([
'type' => 'file',
'name' => 'Report',
'original_name' => 'report.pdf',
'can_view_activity' => true,
'categories' => [],
'shares' => ['clients' => [['name' => 'Shared Client']]],
]);
$category = Category::query()->create(['name' => 'Invoices', 'color' => 'red']);
$file->categories()->attach($category->id);
$this->actingAs($this->admin)->getJson("/files/{$file->id}/details")->assertOk()
->assertJson(['categories' => [['id' => $category->id, 'name' => 'Invoices', 'color' => 'red']]]);
});
test('the file activity endpoint returns the file log and honors the permission', function () {
$this->actingAs($this->admin)->post('/files', [
'file' => UploadedFile::fake()->create('a.pdf', 10, 'application/pdf'), 'name' => '', 'description' => '',
]);
$file = File::query()->sole();
$this->actingAs($this->admin)->getJson("/files/{$file->id}/activity")->assertOk()->assertJsonPath('entries.0.template', 'Uploaded the file ":subject"');
// An uploader has view_actions_log too; a role without it is 403.
$role = Role::query()->create(['name' => 'No Log']);
RolePermission::query()->create(['role_id' => $role->id, 'permission' => 'upload']);
$noLog = User::factory()->create(['role_id' => $role->id]);
$this->actingAs($noLog)->getJson("/files/{$file->id}/activity")->assertForbidden();
});
test('the folder details endpoint returns counts and shares', function () {
$folder = app(FolderService::class)->create('Docs', null);
$this->actingAs($this->admin)->getJson("/folders/{$folder->id}/details")->assertOk()->assertJson([
'type' => 'folder',
'name' => 'Docs',
'files_count' => 0,
]);
});
test('the folder details endpoint is read-only: it links to the edit page instead of exposing assignment endpoints', function () {
$folder = app(FolderService::class)->create('Docs', null);
// Sharing (and every other editable field) is only ever changed from
// the folder's own edit page (files/folder.tsx) — the info panel must
// not carry assign/unassign URLs or the "available to share with"
// rosters that would let it render live editing controls, matching
// how a file's details response already behaves.
$this->actingAs($this->admin)->getJson("/folders/{$folder->id}/details")
->assertOk()
->assertJson(['edit_url' => route('folders.share', $folder, false)])
->assertJsonMissingPath('assign_url')
->assertJsonMissingPath('unassign_url')
->assertJsonMissingPath('shares.available_clients')
->assertJsonMissingPath('shares.available_groups');
});
test('the file downloads endpoint groups downloads by actor with per-download IPs', function () {
$client = User::factory()->client()->create(['name' => 'Shared Client']);
$this->actingAs($this->admin)->post('/files', [
'file' => UploadedFile::fake()->create('report.pdf', 20, 'application/pdf'),
'name' => 'Report', 'description' => '',
]);
$file = File::query()->sole();
$this->actingAs($this->admin)->post("/files/{$file->id}/assignments", ['type' => 'client', 'id' => $client->id]);
$this->actingAs($this->admin)->get("/files/{$file->id}/download", ['REMOTE_ADDR' => '203.0.113.1']);
$this->actingAs($client)->get("/files/{$file->id}/download", ['REMOTE_ADDR' => '198.51.100.2']);
$this->actingAs($client)->get("/files/{$file->id}/download", ['REMOTE_ADDR' => '198.51.100.3']);
$response = $this->actingAs($this->admin)->getJson("/files/{$file->id}/downloads")->assertOk();
$response->assertJson(['total' => 3]);
$downloaders = $response->json('downloaders');
expect($downloaders)->toHaveCount(2);
$clientEntry = collect($downloaders)->firstWhere('actor_name', 'Shared Client');
expect($clientEntry['count'])->toBe(2)
->and($clientEntry['actor_type'])->toBe('client')
->and(collect($clientEntry['downloads'])->pluck('ip_address')->all())->toBe(['198.51.100.3', '198.51.100.2']);
$adminEntry = collect($downloaders)->firstWhere('actor_name', $this->admin->name);
expect($adminEntry['count'])->toBe(1)
->and($adminEntry['downloads'][0]['ip_address'])->toBe('203.0.113.1');
});
test('a public share-link download is bucketed as "Public link", not "deleted account"', function () {
$file = File::factory()->create([
'uploaded_by' => $this->admin->id,
'name' => 'Report',
'original_name' => 'report.pdf',
'mime_type' => 'application/pdf',
'size' => 1024,
]);
$link = ShareLink::query()->create(['shareable_type' => $file->getMorphClass(), 'shareable_id' => $file->id, 'token' => Str::random(32)]);
// No actingAs() at all — this must be a genuine guest request.
$this->get("/s/{$link->token}/download")->assertOk();
$downloaders = $this->actingAs($this->admin)->getJson("/files/{$file->id}/downloads")->assertOk()->json('downloaders');
expect($downloaders)->toHaveCount(1);
expect($downloaders[0]['actor_id'])->toBeNull()
->and($downloaders[0]['actor_name'])->toBe('Public link')
->and($downloaders[0]['actor_type'])->toBeNull()
->and($downloaders[0]['count'])->toBe(1);
});
test('a public group listing download is bucketed as "Public listing", not missing entirely', function () {
$group = Group::query()->create(['name' => 'Open Group', 'public' => true]);
$file = File::factory()->create([
'uploaded_by' => $this->admin->id,
'name' => 'Report',
'original_name' => 'report.pdf',
'mime_type' => 'application/pdf',
'size' => 1024,
'public' => true,
]);
$file->assignments()->create(['assignable_type' => Group::class, 'assignable_id' => $group->id]);
app(Settings::class)->set(Setting::PublicListingSlug, 'public');
// No actingAs() at all — this must be a genuine guest request.
$this->get("/public/files/{$file->slug}/download")->assertOk();
$response = $this->actingAs($this->admin)->getJson("/files/{$file->id}/downloads")->assertOk();
$response->assertJson(['total' => 1]);
$downloaders = $response->json('downloaders');
expect($downloaders)->toHaveCount(1);
expect($downloaders[0]['actor_id'])->toBeNull()
->and($downloaders[0]['actor_name'])->toBe('Public listing')
->and($downloaders[0]['actor_type'])->toBeNull()
->and($downloaders[0]['count'])->toBe(1);
});
test('a role without view_actions_log cannot see the downloads tab', function () {
$this->actingAs($this->admin)->post('/files', [
'file' => UploadedFile::fake()->create('a.pdf', 10, 'application/pdf'), 'name' => '', 'description' => '',
]);
$file = File::query()->sole();
$role = Role::query()->create(['name' => 'No Log']);
RolePermission::query()->create(['role_id' => $role->id, 'permission' => 'upload']);
$noLog = User::factory()->create(['role_id' => $role->id]);
$this->actingAs($noLog)->getJson("/files/{$file->id}/downloads")->assertForbidden();
});
test('clients cannot reach the details endpoints', function () {
$folder = app(FolderService::class)->create('X', null);
$client = User::factory()->client()->create();
// Staff-gated: client GET redirects home.
$this->actingAs($client)->get("/folders/{$folder->id}/details")->assertRedirect(route('dashboard'));
});
test('the details endpoint carries expiry and the download limit', function () {
// Both were missing from this payload, so the panel that exists to
// answer "why can this client not download it?" could not.
$file = File::factory()->create([
'uploaded_by' => $this->admin->id,
'expires_at' => now()->addWeek(),
'download_limit' => 3,
]);
$this->actingAs($this->admin)->getJson("/files/{$file->id}/details")->assertOk()->assertJson([
'expired' => false,
'download_limit' => 3,
'download_limit_scope' => 'total',
'downloads_used' => 0,
// The uploader is exempt from their own file's limit, so their
// own button stays live.
'download_allowance' => ['limit' => null, 'left' => null, 'blocked' => false],
])->assertJsonPath('expires_at', fn (?string $value): bool => $value !== null);
});
test('the details endpoint reports a limit that is spent for the viewer', function () {
$file = File::factory()->create(['uploaded_by' => $this->admin->id, 'download_limit' => 1]);
$staff = staffWithPermissions(['upload', 'edit_files', 'edit_others_files']);
ActivityLog::query()->create([
'actor_id' => $staff->id,
'action' => Action::FileDownloaded,
'subject_type' => $file->getMorphClass(),
'subject_id' => $file->id,
'created_at' => now(),
]);
$this->actingAs($staff)->getJson("/files/{$file->id}/details")->assertOk()->assertJson([
'download_limit' => 1,
'downloads_used' => 1,
'download_allowance' => ['limit' => 1, 'left' => 0, 'blocked' => true],
]);
});
test('a file with no limit says so rather than reporting a zero', function () {
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
$this->actingAs($this->admin)->getJson("/files/{$file->id}/details")->assertOk()
->assertJsonPath('download_limit', null)
->assertJsonPath('expires_at', null)
->assertJsonPath('expired', false);
});