Files
projectsend/tests/Feature/Groups/GroupMembershipScopeTest.php
T
ignacionelson f1b35cc9f6 Stop a deleted file locking a scoped staff member out of a group for good
#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.
2026-08-26 15:21:30 -03:00

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();
});