mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-18 01:25:09 +00:00
f1b35cc9f6
#1701 closed a real hole: group membership decides what a client reaches, and through File::scopeVisibleToClient it decides what the staff member holding that client reaches, so `edit_groups` alone was never a boundary. The predicate it added asks whether everything shared with a group is already inside the actor's library. It asked by counting: pluck the group's assignment rows, count how many of those ids the library query returns, and require the two to match. An assignment row outlives the thing it points at — nothing clears them when a file or folder is deleted — while files() and folders() exclude trashed rows by construction. So one deleted file left a count that could never balance again, and the group closed permanently: the scoped staff member could no longer add their own client to it, or remove anybody from it, with a 403 and nothing to explain it. Every group accumulates dead assignments over time, so groups would have gone quiet one at a time. Asked the other way round — is there anything live, shared with this group, that is outside my library — the dead rows drop out by construction, because the query starts from File/Folder rather than from the assignment. That is also the truer question: a deleted file is not reach, since nobody can reach it. Three tests. A group stays usable after a file shared with it is deleted, including removing a member; the same for a deleted folder assignment; and the half that must not soften — a live file still out of reach is still refused, deleted siblings or not.
325 lines
13 KiB
PHP
325 lines
13 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\Files\Models\FolderAssignment;
|
|
use App\Modules\Groups\Models\Group;
|
|
use App\Modules\Groups\Models\MembershipRequest;
|
|
use App\Modules\Identity\Models\Role;
|
|
use App\Modules\Identity\Models\RolePermission;
|
|
use App\Modules\Identity\Permissions\Permission;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use Inertia\Testing\AssertableInertia;
|
|
|
|
/**
|
|
* Group membership decides what a client can reach, and through
|
|
* File::scopeVisibleToClient it decides what the staff member holding
|
|
* that client can reach too. The routes that edit it were gated on
|
|
* edit_groups and nothing else.
|
|
*/
|
|
beforeEach(function () {
|
|
Storage::fake('files');
|
|
$this->admin = User::factory()->create();
|
|
|
|
$role = Role::query()->create(['name' => 'Reps '.Str::random(6), 'client_scoped' => true]);
|
|
foreach ([Permission::EditGroups, Permission::CreateGroups, Permission::Upload, Permission::EditFiles] as $permission) {
|
|
RolePermission::query()->create(['role_id' => $role->id, 'permission' => $permission->value]);
|
|
}
|
|
|
|
$this->rep = User::factory()->create(['role_id' => $role->id]);
|
|
$this->mine = User::factory()->client()->create(['name' => 'Mine']);
|
|
$this->rep->assignedClients()->sync([$this->mine->id]);
|
|
|
|
$this->stranger = User::factory()->client()->create(['name' => 'Not Mine']);
|
|
|
|
$this->strangerGroup = Group::query()->create(['name' => 'Theirs', 'slug' => 'theirs', 'public' => false]);
|
|
$this->strangerGroup->members()->syncWithoutDetaching([$this->stranger->id]);
|
|
|
|
$this->secret = uploadNamedFile($this->admin, 'stranger-secret');
|
|
shareFileWithGroup($this->secret, $this->strangerGroup);
|
|
});
|
|
|
|
function libraryHolds(User $rep, int $fileId): bool
|
|
{
|
|
return in_array($fileId, app(StaffLibraryScope::class)->files($rep)->pluck('id')->all(), true);
|
|
}
|
|
|
|
test('a scoped staff member cannot widen their own library through a group', function () {
|
|
expect(libraryHolds($this->rep, $this->secret->id))->toBeFalse();
|
|
$this->actingAs($this->rep)->get("/files/{$this->secret->id}/download")->assertForbidden();
|
|
|
|
$this->actingAs($this->rep)
|
|
->post("/groups/{$this->strangerGroup->id}/members", ['user_id' => $this->mine->id])
|
|
->assertForbidden();
|
|
|
|
expect($this->strangerGroup->members()->count())->toBe(1)
|
|
->and(libraryHolds($this->rep, $this->secret->id))->toBeFalse();
|
|
|
|
$this->actingAs($this->rep)->get("/files/{$this->secret->id}/download")->assertForbidden();
|
|
});
|
|
|
|
test('the API twin refuses it too', function () {
|
|
$token = $this->rep->createToken('t', [Permission::EditGroups->value])->plainTextToken;
|
|
|
|
$this->withToken($token)
|
|
->postJson("/api/v1/groups/{$this->strangerGroup->id}/members", ['user_id' => $this->mine->id])
|
|
->assertForbidden();
|
|
|
|
expect($this->strangerGroup->members()->count())->toBe(1);
|
|
});
|
|
|
|
test('a scoped staff member cannot hand somebody else client the files of their own', function () {
|
|
$ours = Group::query()->create(['name' => 'Ours', 'slug' => 'ours', 'public' => false]);
|
|
$ours->members()->syncWithoutDetaching([$this->mine->id]);
|
|
|
|
$this->actingAs($this->rep)
|
|
->post("/groups/{$ours->id}/members", ['user_id' => $this->stranger->id])
|
|
->assertForbidden();
|
|
|
|
expect($ours->members()->pluck('users.id')->all())->toBe([$this->mine->id]);
|
|
});
|
|
|
|
test('a scoped staff member cannot pull somebody else client out of a group', function () {
|
|
$this->actingAs($this->rep)
|
|
->delete("/groups/{$this->strangerGroup->id}/members/{$this->stranger->id}")
|
|
->assertForbidden();
|
|
|
|
$token = $this->rep->createToken('t', [Permission::EditGroups->value])->plainTextToken;
|
|
$this->withToken($token)
|
|
->deleteJson("/api/v1/groups/{$this->strangerGroup->id}/members/{$this->stranger->id}")
|
|
->assertForbidden();
|
|
|
|
expect($this->strangerGroup->members()->count())->toBe(1);
|
|
});
|
|
|
|
test('approving a membership request is held to the same boundary', function () {
|
|
$role = $this->rep->role;
|
|
RolePermission::query()->create([
|
|
'role_id' => $role->id,
|
|
'permission' => Permission::ApproveGroupsMembershipsRequests->value,
|
|
]);
|
|
|
|
$request = MembershipRequest::query()->create([
|
|
'group_id' => $this->strangerGroup->id,
|
|
'user_id' => $this->mine->id,
|
|
'status' => MembershipRequest::STATUS_PENDING,
|
|
]);
|
|
|
|
$this->actingAs($this->rep)->post("/membership-requests/{$request->id}/approve")->assertNotFound();
|
|
|
|
expect($this->strangerGroup->members()->count())->toBe(1)
|
|
->and(libraryHolds($this->rep, $this->secret->id))->toBeFalse();
|
|
});
|
|
|
|
test('denying somebody else client request is held to the same boundary', function () {
|
|
RolePermission::query()->create([
|
|
'role_id' => $this->rep->role_id,
|
|
'permission' => Permission::ApproveGroupsMembershipsRequests->value,
|
|
]);
|
|
|
|
$request = MembershipRequest::query()->create([
|
|
'group_id' => $this->strangerGroup->id,
|
|
'user_id' => $this->stranger->id,
|
|
'status' => MembershipRequest::STATUS_PENDING,
|
|
]);
|
|
|
|
$this->actingAs($this->rep)->delete("/membership-requests/{$request->id}")->assertNotFound();
|
|
|
|
expect($request->fresh()->status)->toBe(MembershipRequest::STATUS_PENDING);
|
|
});
|
|
|
|
test('the queue stops naming clients this viewer has no business hearing about', function () {
|
|
RolePermission::query()->create([
|
|
'role_id' => $this->rep->role_id,
|
|
'permission' => Permission::ApproveGroupsMembershipsRequests->value,
|
|
]);
|
|
|
|
$ours = Group::query()->create(['name' => 'Ours', 'slug' => 'ours', 'public' => true]);
|
|
MembershipRequest::query()->create(['group_id' => $ours->id, 'user_id' => $this->mine->id, 'status' => MembershipRequest::STATUS_PENDING]);
|
|
MembershipRequest::query()->create(['group_id' => $ours->id, 'user_id' => $this->stranger->id, 'status' => MembershipRequest::STATUS_PENDING]);
|
|
|
|
$body = $this->actingAs($this->rep)->get('/membership-requests')->getContent();
|
|
|
|
// The row carries client_name and client_email, so an unnarrowed
|
|
// queue hands over both for a client outside the roster.
|
|
expect(str_contains($body, 'Not Mine'))->toBeFalse()
|
|
->and(str_contains($body, $this->stranger->email))->toBeFalse()
|
|
->and(str_contains($body, 'Mine'))->toBeTrue();
|
|
});
|
|
|
|
test('the sidebar badge counts what the queue lists', function () {
|
|
RolePermission::query()->create([
|
|
'role_id' => $this->rep->role_id,
|
|
'permission' => Permission::ApproveGroupsMembershipsRequests->value,
|
|
]);
|
|
|
|
$ours = Group::query()->create(['name' => 'Ours', 'slug' => 'ours', 'public' => true]);
|
|
MembershipRequest::query()->create(['group_id' => $ours->id, 'user_id' => $this->mine->id, 'status' => MembershipRequest::STATUS_PENDING]);
|
|
MembershipRequest::query()->create(['group_id' => $ours->id, 'user_id' => $this->stranger->id, 'status' => MembershipRequest::STATUS_PENDING]);
|
|
|
|
$this->actingAs($this->rep)->get('/dashboard')->assertInertia(
|
|
fn (AssertableInertia $page) => $page->where('pending.membership_requests', 1),
|
|
);
|
|
|
|
// Unscoped staff are told about both, and see both.
|
|
$wide = Role::query()->create(['name' => 'Wide '.Str::random(6), 'client_scoped' => false]);
|
|
RolePermission::query()->create(['role_id' => $wide->id, 'permission' => Permission::ApproveGroupsMembershipsRequests->value]);
|
|
$manager = User::factory()->create(['role_id' => $wide->id]);
|
|
|
|
$this->actingAs($manager)->get('/dashboard')->assertInertia(
|
|
fn (AssertableInertia $page) => $page->where('pending.membership_requests', 2),
|
|
);
|
|
});
|
|
|
|
test('a group nobody has shared anything with can still be populated', function () {
|
|
$fresh = Group::query()->create(['name' => 'Brand New', 'slug' => 'brand-new', 'public' => false]);
|
|
|
|
$this->actingAs($this->rep)
|
|
->post("/groups/{$fresh->id}/members", ['user_id' => $this->mine->id])
|
|
->assertRedirect();
|
|
|
|
expect($fresh->members()->pluck('users.id')->all())->toBe([$this->mine->id]);
|
|
});
|
|
|
|
test('a group already holding the actor own client stays editable', function () {
|
|
$ours = Group::query()->create(['name' => 'Ours', 'slug' => 'ours', 'public' => false]);
|
|
$ours->members()->syncWithoutDetaching([$this->mine->id]);
|
|
|
|
$ourFile = uploadNamedFile($this->admin, 'our-brochure');
|
|
shareFileWithGroup($ourFile, $ours);
|
|
|
|
$second = User::factory()->client()->create(['name' => 'Also Mine']);
|
|
$this->rep->assignedClients()->sync([$this->mine->id, $second->id]);
|
|
|
|
$this->actingAs($this->rep)
|
|
->post("/groups/{$ours->id}/members", ['user_id' => $second->id])
|
|
->assertRedirect();
|
|
|
|
$this->actingAs($this->rep)
|
|
->delete("/groups/{$ours->id}/members/{$second->id}")
|
|
->assertRedirect();
|
|
|
|
expect($ours->members()->pluck('users.id')->all())->toBe([$this->mine->id]);
|
|
});
|
|
|
|
test('unscoped staff manage membership exactly as before', function () {
|
|
$role = Role::query()->create(['name' => 'Wide '.Str::random(6), 'client_scoped' => false]);
|
|
RolePermission::query()->create(['role_id' => $role->id, 'permission' => Permission::EditGroups->value]);
|
|
$manager = User::factory()->create(['role_id' => $role->id]);
|
|
|
|
$this->actingAs($manager)
|
|
->post("/groups/{$this->strangerGroup->id}/members", ['user_id' => $this->mine->id])
|
|
->assertRedirect();
|
|
|
|
expect($this->strangerGroup->members()->count())->toBe(2);
|
|
|
|
$this->actingAs($manager)
|
|
->delete("/groups/{$this->strangerGroup->id}/members/{$this->stranger->id}")
|
|
->assertRedirect();
|
|
|
|
expect($this->strangerGroup->members()->pluck('users.id')->all())->toBe([$this->mine->id]);
|
|
});
|
|
|
|
test('a folder shared with a group counts as reach too', function () {
|
|
$folder = app(FolderService::class)->create('Their Folder', null);
|
|
FolderAssignment::query()->create([
|
|
'folder_id' => $folder->id,
|
|
'assignable_type' => $this->strangerGroup->getMorphClass(),
|
|
'assignable_id' => $this->strangerGroup->id,
|
|
]);
|
|
|
|
$bare = Group::query()->create(['name' => 'Folder Only', 'slug' => 'folder-only', 'public' => false]);
|
|
FolderAssignment::query()->create([
|
|
'folder_id' => $folder->id,
|
|
'assignable_type' => $bare->getMorphClass(),
|
|
'assignable_id' => $bare->id,
|
|
]);
|
|
|
|
$this->actingAs($this->rep)
|
|
->post("/groups/{$bare->id}/members", ['user_id' => $this->mine->id])
|
|
->assertForbidden();
|
|
|
|
expect($bare->members()->count())->toBe(0);
|
|
});
|
|
|
|
// An assignment row outlives the file it points at — nothing clears them
|
|
// on delete — and a trashed file can never appear in files(). Asking
|
|
// "is anything outside my library" from the live row rather than counting
|
|
// assignment rows is what keeps a group usable after somebody deletes a
|
|
// file that was once shared with it.
|
|
test('a group is not locked shut by a file that has since been deleted', function () {
|
|
$group = Group::query()->create(['name' => 'Newsletter', 'slug' => 'newsletter', 'public' => false]);
|
|
$group->members()->syncWithoutDetaching([$this->mine->id]);
|
|
|
|
// Shared with the group, and reachable by this rep because their own
|
|
// client is a member — so the group is theirs to manage.
|
|
$file = uploadNamedFile($this->admin, 'seasonal-offer');
|
|
shareFileWithGroup($file, $group);
|
|
|
|
$second = User::factory()->client()->create(['name' => 'Also Mine']);
|
|
$this->rep->assignedClients()->attach($second->id);
|
|
|
|
$this->actingAs($this->rep)
|
|
->post("/groups/{$group->id}/members", ['user_id' => $second->id])
|
|
->assertRedirect();
|
|
|
|
// The uploader deletes it. The assignment row stays behind.
|
|
$file->delete();
|
|
|
|
$third = User::factory()->client()->create(['name' => 'Mine Too']);
|
|
$this->rep->assignedClients()->attach($third->id);
|
|
|
|
$this->actingAs($this->rep)
|
|
->post("/groups/{$group->id}/members", ['user_id' => $third->id])
|
|
->assertRedirect();
|
|
|
|
expect($group->members()->count())->toBe(3);
|
|
|
|
// And taking somebody out again still works, which the count form
|
|
// also blocked.
|
|
$this->actingAs($this->rep)
|
|
->delete("/groups/{$group->id}/members/{$third->id}")
|
|
->assertRedirect();
|
|
|
|
expect($group->members()->count())->toBe(2);
|
|
});
|
|
|
|
test('a deleted folder assignment does not lock a group either', function () {
|
|
$group = Group::query()->create(['name' => 'Bulletin', 'slug' => 'bulletin', 'public' => false]);
|
|
$group->members()->syncWithoutDetaching([$this->mine->id]);
|
|
|
|
$folder = app(FolderService::class)->create('Seasonal', null);
|
|
FolderAssignment::query()->create([
|
|
'folder_id' => $folder->id,
|
|
'assignable_type' => $group->getMorphClass(),
|
|
'assignable_id' => $group->id,
|
|
]);
|
|
|
|
$folder->delete();
|
|
|
|
$second = User::factory()->client()->create(['name' => 'Second']);
|
|
$this->rep->assignedClients()->attach($second->id);
|
|
|
|
$this->actingAs($this->rep)
|
|
->post("/groups/{$group->id}/members", ['user_id' => $second->id])
|
|
->assertRedirect();
|
|
|
|
expect($group->members()->count())->toBe(2);
|
|
});
|
|
|
|
// The half that must not soften: a live file outside the library is still
|
|
// reach, deleted siblings or not.
|
|
test('a deleted file does not excuse a live one that is still out of reach', function () {
|
|
$dead = uploadNamedFile($this->admin, 'was-shared');
|
|
shareFileWithGroup($dead, $this->strangerGroup);
|
|
$dead->delete();
|
|
|
|
$this->actingAs($this->rep)
|
|
->post("/groups/{$this->strangerGroup->id}/members", ['user_id' => $this->mine->id])
|
|
->assertForbidden();
|
|
});
|