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