mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-16 16:45:07 +00:00
cad112522d
/users/convert lists the accounts a conversion can be started from. For
the promotion direction those are clients, and the query asked only for
the type:
User::query()->where('type', UserType::Client)
The write beside it does not. AccountConversion::guardToStaff() ends with
abort_unless($this->library->canAssignClient($actor, $target), 404);
and says why: a promotion is the most far-reaching thing that can be done
to a client, so reaching one outside the actor's roster "through this door
and no other is not a rule, it is a gap".
The gap was on the way in. A client-scoped staff member holding
manage_users and edit_users was refused the promotion with a 404 -- the
refusal that is careful not to distinguish a stranger from an account that
is not there -- and then shown that same person's name, email, role,
status and consequence counts in the list the refusal came from,
searchable by name or address and paginated to the end.
StaffLibraryScope::clients() is canAssignClient()'s listing half, written
for this: "so a screen narrows by the same rule its buttons are guarded
with rather than restating it -- which is how ClientsController came to
list every client on the installation, name and email, to a viewer who
could reach nothing of theirs." The picker twenty lines below already went
through the same boundary via assignableClientIds().
Only the client direction is narrowed. The staff direction is left exactly
as it was: whoever may demote a staff member may see the staff roster, and
what limits a demotion is guardTarget() on the write, not the listing.
Tests: the listing half added to AccountConversionScopeTest, which until
now covered only the refusals. Two of the five fail against the unfixed
controller -- the stranger's address in the list, and reaching it by exact
search. The other three pin what must not change: the actor still sees
their own client, unscoped staff still see everybody, and the demotion
list still lists staff.
165 lines
6.5 KiB
PHP
165 lines
6.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Audit\Action;
|
|
use App\Modules\Audit\ActivityLog;
|
|
use App\Modules\Groups\Models\Group;
|
|
use App\Modules\Identity\Models\Role;
|
|
use App\Modules\Identity\Models\RolePermission;
|
|
use App\Modules\Identity\Permissions\Permission;
|
|
use App\Modules\Identity\UserType;
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* Promoting a client to staff binds a client account, so it is inside
|
|
* the same boundary as every other route that binds one: the clients
|
|
* assigned to this staff member. guardToStaff() deliberately skips
|
|
* StaffAccounts::guardTarget, and the reason it gives is about the role
|
|
* being granted -- which leaves the target unasked about.
|
|
*/
|
|
beforeEach(function () {
|
|
$this->admin = User::factory()->create();
|
|
|
|
$this->role = Role::query()->create(['name' => 'Reps '.Str::random(6), 'client_scoped' => true]);
|
|
foreach ([Permission::ManageUsers, Permission::EditUsers, Permission::EditClients, Permission::CreateUsers] as $permission) {
|
|
RolePermission::query()->create(['role_id' => $this->role->id, 'permission' => $permission->value]);
|
|
}
|
|
|
|
$this->rep = User::factory()->create(['role_id' => $this->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']);
|
|
});
|
|
|
|
test('a scoped staff member cannot promote a client outside their roster', function () {
|
|
$this->actingAs($this->rep)->post("/users/convert/{$this->stranger->id}", [
|
|
'direction' => 'to_staff',
|
|
'role_id' => $this->role->id,
|
|
'assigned_clients' => [],
|
|
])->assertNotFound();
|
|
|
|
$after = $this->stranger->fresh();
|
|
|
|
expect($after->type)->toBe(UserType::Client)
|
|
->and($after->role_id)->not->toBe($this->role->id);
|
|
});
|
|
|
|
test('everything a promotion would have done to a stranger client is left alone', function () {
|
|
$colleague = User::factory()->create(['role_id' => $this->role->id]);
|
|
$colleague->assignedClients()->sync([$this->stranger->id]);
|
|
|
|
$group = Group::query()->create(['name' => 'Theirs', 'slug' => 'theirs', 'public' => false]);
|
|
$group->members()->syncWithoutDetaching([$this->stranger->id]);
|
|
|
|
$this->actingAs($this->rep)->post("/users/convert/{$this->stranger->id}", [
|
|
'direction' => 'to_staff',
|
|
'role_id' => $this->role->id,
|
|
'assigned_clients' => [],
|
|
])->assertNotFound();
|
|
|
|
// A promotion clears every roster row pointing at the account and
|
|
// leaves its group memberships inert. Neither happened, and nothing
|
|
// was written to the log.
|
|
expect($colleague->assignedClients()->pluck('users.id')->all())->toBe([$this->stranger->id])
|
|
->and($group->members()->pluck('users.id')->all())->toBe([$this->stranger->id])
|
|
->and(ActivityLog::query()->where('action', Action::AccountConvertedToStaff->value)->count())->toBe(0);
|
|
});
|
|
|
|
test('the refusal does not distinguish a stranger client from one that is not there', function () {
|
|
$this->actingAs($this->rep)->post('/users/convert/999999', [
|
|
'direction' => 'to_staff',
|
|
'role_id' => $this->role->id,
|
|
])->assertNotFound();
|
|
});
|
|
|
|
test('a scoped staff member may still promote a client of their own', function () {
|
|
$this->actingAs($this->rep)->post("/users/convert/{$this->mine->id}", [
|
|
'direction' => 'to_staff',
|
|
'role_id' => $this->role->id,
|
|
'assigned_clients' => [],
|
|
])->assertSessionHasNoErrors();
|
|
|
|
$after = $this->mine->fresh();
|
|
|
|
expect($after->type)->toBe(UserType::Staff)
|
|
->and($after->role_id)->toBe($this->role->id);
|
|
});
|
|
|
|
test('unscoped staff promote any client, as before', function () {
|
|
$this->actingAs($this->admin)->post("/users/convert/{$this->stranger->id}", [
|
|
'direction' => 'to_staff',
|
|
'role_id' => $this->role->id,
|
|
'assigned_clients' => [],
|
|
])->assertSessionHasNoErrors();
|
|
|
|
expect($this->stranger->fresh()->type)->toBe(UserType::Staff);
|
|
});
|
|
|
|
test('the demotion direction keeps answering through guardTarget', function () {
|
|
$colleague = User::factory()->create();
|
|
|
|
$this->actingAs($this->rep)->post("/users/convert/{$colleague->id}", [
|
|
'direction' => 'to_client',
|
|
])->assertForbidden();
|
|
|
|
expect($colleague->fresh()->type)->toBe(UserType::Staff);
|
|
});
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| The listing half of the same boundary
|
|
|--------------------------------------------------------------------------
|
|
| The refusals above are about the write. index() builds the list the
|
|
| write is started from, and a name and an address handed to somebody who
|
|
| may reach nothing of that person is the disclosure the refusal exists to
|
|
| prevent.
|
|
*/
|
|
|
|
test('the promotion list does not show a client outside the roster', function () {
|
|
$response = $this->actingAs($this->rep)->get('/users/convert?direction=to_staff');
|
|
|
|
$response->assertOk();
|
|
|
|
expect($response->getContent())->not->toContain($this->stranger->email)
|
|
->and($response->getContent())->not->toContain('Not Mine');
|
|
});
|
|
|
|
test('the promotion list still shows the scoped staff member their own client', function () {
|
|
$response = $this->actingAs($this->rep)->get('/users/convert?direction=to_staff');
|
|
|
|
expect($response->getContent())->toContain($this->mine->email);
|
|
});
|
|
|
|
test('search cannot reach a client outside the roster', function () {
|
|
// The narrowing is on the query the search filters, not on the result,
|
|
// so naming the account exactly still returns nothing.
|
|
$response = $this->actingAs($this->rep)->get('/users/convert?direction=to_staff&search=Not+Mine');
|
|
|
|
$response->assertOk();
|
|
|
|
expect($response->getContent())->not->toContain($this->stranger->email);
|
|
});
|
|
|
|
test('unscoped staff still see every client in the promotion list', function () {
|
|
$response = $this->actingAs($this->admin)->get('/users/convert?direction=to_staff');
|
|
|
|
expect($response->getContent())->toContain($this->stranger->email)
|
|
->and($response->getContent())->toContain($this->mine->email);
|
|
});
|
|
|
|
test('the demotion list is unchanged, and still lists staff', function () {
|
|
// Only the client direction is narrowed: whoever may demote a staff
|
|
// member may see the staff roster, which guardTarget() decides on the
|
|
// write side rather than the listing.
|
|
$colleague = User::factory()->create(['name' => 'A Colleague']);
|
|
|
|
$response = $this->actingAs($this->rep)->get('/users/convert?direction=to_client');
|
|
|
|
$response->assertOk();
|
|
|
|
expect($response->getContent())->toContain($colleague->email);
|
|
});
|