Files
projectsend/tests/Feature/Groups/GroupMembershipScopeTest.php
T
ignacionelson eb2917f5ff Hold a group object to the same boundary its membership already has
This overturns something #1701 decided, so it should say so. That PR
closed the membership hole and left GroupsController::update and
destroy installation-wide on purpose, on the grounds that managing the
group object is a different question from managing who is in it.

What decides it is a measurement that was not in front of that decision.
An assignment to a group is how its members reach a file, so deleting a
group revokes that access for every member. Measured before this guard,
with a client-scoped role holding the group permissions:

  stranger client can read the shared file   true
  PATCH /groups/{stranger group}             302, renamed
  DELETE /groups/{stranger group}            302, group gone
  stranger client can read the shared file   false

So a staff member who may not add somebody to a group out of their reach
could delete it out from under the people already in it. That is not a
gentler version of the membership rule, it is a harder one, and the two
sitting on opposite sides of the same boundary was the odd part.

StaffLibraryScope::allowsGroupChange is the reach half of
allowsGroupMembership on its own, since no client appears in this
question -- one predicate, two callers, rather than a second statement of
it. Both surfaces take it, at 404, matching the membership guards.

A group that shares nothing beyond the actor's library still passes, so a
group they created or one holding their own clients stays theirs, and
unscoped staff are unaffected by construction.

The API document moves a 404 above a 422 on two paths. Both already
documented the 404 -- route model binding produced one -- and Scramble
orders responses by where they appear in the method, so the guard landing
before the validate() call is the whole of the change.
2026-08-26 18:04:41 -03:00

423 lines
17 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\File;
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 Laravel\Sanctum\Sanctum;
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();
});
/**
* The rep role ships without delete_groups, and these cases are about the
* boundary rather than the permission: grant it so the route lets the
* request through and the guard is what answers.
*/
function grantGroupDeletion(User $rep): void
{
RolePermission::query()->firstOrCreate([
'role_id' => $rep->role_id,
'permission' => Permission::DeleteGroups->value,
]);
}
// #1701 drew the line for membership and left the group object
// installation-wide. Deleting one is the sharper end of the same
// question: an assignment to a group is how its members reach a file, so
// removing the group takes that access away from every member — measured
// before this guard, a scoped role deleted a stranger's group and the
// stranger's client stopped seeing the file it carried.
test('a scoped staff member cannot rename or delete a group out of their reach', function () {
grantGroupDeletion($this->rep);
$this->actingAs($this->rep)
->patch("/groups/{$this->strangerGroup->id}", [
'name' => 'Renamed By Somebody Else',
'slug' => 'theirs',
'description' => null,
'public' => false,
])
->assertNotFound();
$this->actingAs($this->rep)
->delete("/groups/{$this->strangerGroup->id}")
->assertNotFound();
expect($this->strangerGroup->fresh()->name)->toBe('Theirs')
->and(Group::query()->whereKey($this->strangerGroup->id)->exists())->toBeTrue();
});
test('deleting a stranger group would have cost its members their access', function () {
grantGroupDeletion($this->rep);
$stranger = $this->strangerGroup->members()->first();
expect(File::query()->visibleToClient($stranger)->whereKey($this->secret->id)->exists())->toBeTrue();
$this->actingAs($this->rep)->delete("/groups/{$this->strangerGroup->id}")->assertNotFound();
// Still theirs to read, because the group is still there.
expect(File::query()->visibleToClient($stranger)->whereKey($this->secret->id)->exists())->toBeTrue();
});
test('the API refuses the same two', function () {
grantGroupDeletion($this->rep);
Sanctum::actingAs($this->rep, ['edit_groups', 'delete_groups']);
$this->patchJson("/api/v1/groups/{$this->strangerGroup->id}", ['name' => 'Nope'])->assertNotFound();
$this->deleteJson("/api/v1/groups/{$this->strangerGroup->id}")->assertNotFound();
expect(Group::query()->whereKey($this->strangerGroup->id)->exists())->toBeTrue();
});
test('a group inside their reach stays theirs to rename and delete', function () {
grantGroupDeletion($this->rep);
$mine = Group::query()->create(['name' => 'Mine', 'slug' => 'mine', 'public' => false]);
$mine->members()->syncWithoutDetaching([$this->mine->id]);
$this->actingAs($this->rep)
->patch("/groups/{$mine->id}", ['name' => 'Mine, Renamed', 'slug' => 'mine', 'description' => null, 'public' => false])
->assertRedirect();
expect($mine->fresh()->name)->toBe('Mine, Renamed');
$this->actingAs($this->rep)->delete("/groups/{$mine->id}")->assertRedirect();
expect(Group::query()->whereKey($mine->id)->exists())->toBeFalse();
});
test('an unscoped administrator manages every group exactly as before', function () {
$this->actingAs($this->admin)
->patch("/groups/{$this->strangerGroup->id}", [
'name' => 'Renamed By An Admin',
'slug' => 'theirs',
'description' => null,
'public' => false,
])
->assertRedirect();
$this->actingAs($this->admin)->delete("/groups/{$this->strangerGroup->id}")->assertRedirect();
expect(Group::query()->whereKey($this->strangerGroup->id)->exists())->toBeFalse();
});