mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-16 16:45:07 +00:00
6e47d76ba6
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.
193 lines
8.9 KiB
PHP
193 lines
8.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Files\Access\StaffLibraryScope;
|
|
use App\Modules\Files\Folders\FolderService;
|
|
use App\Modules\Identity\Models\Role;
|
|
use App\Modules\Identity\Permissions\EnsureSystemRoles;
|
|
use App\Modules\Identity\Permissions\SystemRole;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Inertia\Testing\AssertableInertia;
|
|
|
|
beforeEach(function () {
|
|
Storage::fake('files');
|
|
$this->admin = User::factory()->create();
|
|
});
|
|
|
|
/** Upload a file as a given user, optionally into a folder. */
|
|
function clientManager(): User
|
|
{
|
|
return User::factory()->role(SystemRole::ClientManager)->create();
|
|
}
|
|
|
|
test('the Client Manager role is seeded scoped, non-admin, and can upload', function () {
|
|
$role = Role::query()->where('name', 'Client Manager')->sole();
|
|
|
|
expect($role->client_scoped)->toBeTrue()
|
|
->and($role->is_system)->toBeTrue()
|
|
->and($role->is_administrator)->toBeFalse()
|
|
->and($role->permissions()->where('permission', 'upload')->exists())->toBeTrue();
|
|
});
|
|
|
|
test('EnsureSystemRoles is idempotent and repairs a drifted client_scoped flag', function () {
|
|
$role = Role::query()->where('name', 'Client Manager')->sole();
|
|
$role->forceFill(['client_scoped' => false])->save();
|
|
|
|
(new EnsureSystemRoles)->ensure();
|
|
|
|
expect($role->refresh()->client_scoped)->toBeTrue()
|
|
// Still exactly four seeded system roles — no duplicates created
|
|
// (Uploader is legacy and never seeded).
|
|
->and(Role::query()->where('is_system', true)->count())->toBe(4);
|
|
});
|
|
|
|
test('a client-scoped staff member sees only their clients content plus their own uploads', function () {
|
|
$scope = app(StaffLibraryScope::class);
|
|
|
|
$clientA = User::factory()->client()->create();
|
|
$manager = clientManager();
|
|
$manager->assignedClients()->sync([$clientA->id]);
|
|
|
|
// Content shared with A (directly + inside a shared folder), the manager's
|
|
// own upload, and unrelated content nobody scoped should see.
|
|
$sharedFile = uploadNamedFile($this->admin, 'a-direct');
|
|
$this->actingAs($this->admin)->post("/files/{$sharedFile->id}/assignments", ['type' => 'client', 'id' => $clientA->id]);
|
|
|
|
$sharedFolder = app(FolderService::class)->create('A Folder', null);
|
|
$this->actingAs($this->admin)->post("/folders/{$sharedFolder->id}/assignments", ['type' => 'client', 'id' => $clientA->id]);
|
|
$inFolder = uploadNamedFile($this->admin, 'a-in-folder', $sharedFolder->id);
|
|
|
|
$ownFile = uploadNamedFile($manager, 'my-own');
|
|
$unrelated = uploadNamedFile($this->admin, 'secret');
|
|
|
|
$visibleFileIds = $scope->files($manager)->pluck('id')->all();
|
|
|
|
expect($visibleFileIds)->toContain($sharedFile->id, $inFolder->id, $ownFile->id)
|
|
->not->toContain($unrelated->id);
|
|
expect($scope->folders($manager)->pluck('id')->all())->toContain($sharedFolder->id);
|
|
|
|
// A file added to the shared folder AFTER assignment is live-visible.
|
|
$later = uploadNamedFile($this->admin, 'a-later', $sharedFolder->id);
|
|
expect($scope->files($manager)->pluck('id')->all())->toContain($later->id);
|
|
});
|
|
|
|
test('a scoped manager with no assigned clients sees only their own uploads', function () {
|
|
$scope = app(StaffLibraryScope::class);
|
|
$manager = clientManager();
|
|
|
|
$own = uploadNamedFile($manager, 'mine');
|
|
$other = uploadNamedFile($this->admin, 'not-mine');
|
|
|
|
expect($scope->files($manager)->pluck('id')->all())->toBe([$own->id])
|
|
->not->toContain($other->id);
|
|
});
|
|
|
|
test('direct access to out-of-scope files and folders is forbidden for a scoped manager', function () {
|
|
$clientA = User::factory()->client()->create();
|
|
$manager = clientManager();
|
|
$manager->assignedClients()->sync([$clientA->id]);
|
|
|
|
$unrelated = uploadNamedFile($this->admin, 'unrelated');
|
|
|
|
$this->actingAs($manager)->get("/files/{$unrelated->id}/download")->assertForbidden();
|
|
$this->actingAs($manager)->getJson("/files/{$unrelated->id}/details")->assertForbidden();
|
|
$this->actingAs($manager)->get("/files/{$unrelated->id}")->assertForbidden();
|
|
|
|
// In-scope: their own upload is reachable.
|
|
$own = uploadNamedFile($manager, 'own');
|
|
$this->actingAs($manager)->get("/files/{$own->id}/download")->assertOk();
|
|
});
|
|
|
|
// The mirror of the sharing guard below: reaching a file through one of
|
|
// your own clients must not let you revoke a different client's access to
|
|
// it. Un-sharing is destructive, so it needs the same gate as sharing.
|
|
test('a scoped manager cannot un-share a file from a client that is not theirs', function () {
|
|
$clientA = User::factory()->client()->create();
|
|
$clientB = User::factory()->client()->create(['name' => 'Not Mine']);
|
|
$manager = clientManager();
|
|
$manager->assignedClients()->sync([$clientA->id]);
|
|
|
|
// The manager's own file (so Gate::update passes on ownership alone),
|
|
// shared by the admin with both clients — only one of which is theirs.
|
|
$file = uploadNamedFile($manager, 'shared');
|
|
$this->actingAs($this->admin)->post("/files/{$file->id}/assignments", ['type' => 'client', 'id' => $clientA->id]);
|
|
$this->actingAs($this->admin)->post("/files/{$file->id}/assignments", ['type' => 'client', 'id' => $clientB->id]);
|
|
expect($file->assignments()->count())->toBe(2);
|
|
|
|
$this->actingAs($manager)->delete("/files/{$file->id}/assignments", ['type' => 'client', 'id' => $clientB->id])
|
|
->assertSessionHasErrors('id');
|
|
expect($file->assignments()->count())->toBe(2);
|
|
|
|
// Their own client's assignment is still theirs to remove.
|
|
$this->actingAs($manager)->delete("/files/{$file->id}/assignments", ['type' => 'client', 'id' => $clientA->id])
|
|
->assertSessionHasNoErrors();
|
|
expect($file->assignments()->count())->toBe(1);
|
|
});
|
|
|
|
test('a scoped manager can only share with their assigned clients', function () {
|
|
$clientA = User::factory()->client()->create();
|
|
$clientB = User::factory()->client()->create(['name' => 'Not Mine']);
|
|
$manager = clientManager();
|
|
$manager->assignedClients()->sync([$clientA->id]);
|
|
|
|
$file = uploadNamedFile($manager, 'mine');
|
|
|
|
// Sharing with the unassigned client B is rejected.
|
|
$this->actingAs($manager)->post("/files/{$file->id}/assignments", ['type' => 'client', 'id' => $clientB->id])
|
|
->assertSessionHasErrors('id');
|
|
expect($file->assignments()->count())->toBe(0);
|
|
|
|
// Sharing with the assigned client A succeeds.
|
|
$this->actingAs($manager)->post("/files/{$file->id}/assignments", ['type' => 'client', 'id' => $clientA->id])
|
|
->assertSessionHasNoErrors();
|
|
expect($file->assignments()->count())->toBe(1);
|
|
|
|
// The details picker only offers the assigned client.
|
|
$names = collect(
|
|
$this->actingAs($manager)->getJson("/files/{$file->id}/details")->json('shares.available_clients')
|
|
)->pluck('name');
|
|
expect($names)->not->toContain('Not Mine');
|
|
|
|
// The file edit page's picker is filtered the same way.
|
|
$this->actingAs($manager)->get("/files/{$file->id}")->assertInertia(fn (AssertableInertia $page) => $page
|
|
->where('available_clients', fn ($clients) => collect($clients)->doesntContain('name', 'Not Mine')));
|
|
});
|
|
|
|
test('assigning clients to a staff user syncs only for a client-scoped role', function () {
|
|
$clientA = User::factory()->client()->create();
|
|
$scopedRole = Role::query()->where('name', 'Client Manager')->sole();
|
|
$plainRole = Role::query()->where('name', SystemRole::AccountManager->value)->sole();
|
|
|
|
// Create scoped → clients persist.
|
|
$this->actingAs($this->admin)->post('/users', [
|
|
'name' => 'Rep', 'email' => 'rep@example.test', 'role_id' => $scopedRole->id,
|
|
'password' => 'password-123', 'password_confirmation' => 'password-123',
|
|
'assigned_clients' => [$clientA->id],
|
|
])->assertRedirect();
|
|
$rep = User::query()->where('email', 'rep@example.test')->sole();
|
|
expect($rep->assignedClients()->pluck('users.id')->all())->toBe([$clientA->id]);
|
|
|
|
// Switch to a non-scoped role → assignments cleared.
|
|
$this->actingAs($this->admin)->patch("/users/{$rep->id}", [
|
|
'name' => 'Rep', 'email' => 'rep@example.test', 'role_id' => $plainRole->id,
|
|
'active' => true, 'assigned_clients' => [$clientA->id],
|
|
])->assertRedirect();
|
|
expect($rep->refresh()->assignedClients()->count())->toBe(0);
|
|
});
|
|
|
|
test('a custom role can be made client-scoped, but a built-in role cannot change scope', function () {
|
|
// Custom role toggles on.
|
|
$this->actingAs($this->admin)->post('/roles', ['name' => 'Reps', 'client_scoped' => true, 'permissions' => ['upload']])
|
|
->assertRedirect();
|
|
$custom = Role::query()->where('name', 'Reps')->sole();
|
|
expect($custom->client_scoped)->toBeTrue();
|
|
|
|
// Attempting to un-scope the built-in Client Manager via update is ignored.
|
|
$builtIn = Role::query()->where('name', 'Client Manager')->sole();
|
|
$this->actingAs($this->admin)->patch("/roles/{$builtIn->id}", ['name' => 'Client Manager', 'client_scoped' => false, 'permissions' => ['upload']])
|
|
->assertRedirect();
|
|
expect($builtIn->refresh()->client_scoped)->toBeTrue();
|
|
});
|