mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-23 20:06:20 +00:00
305c79bc96
Reported by @hackchang (GHSA-c6h9-hcm7-j3x9). GHSA-r3hg-3fxw-rcmr scoped the group controllers; invitations were written afterwards and were not, so the same reach was open through a different door. A client-scoped staff member with `create_clients` could read every group's id and name off the invitation form, name any of them on an invitation, and have the invited client added to it at redemption — a group whose files they cannot see and whose members are not theirs. The ordinary way to do that, adding a member to a group, refuses on StaffLibraryScope::allowsGroupMembership(); the invitation path never asked. Three places, because the hole had three halves: - the form lists `$this->scope->groups($viewer)`, as GroupsController already does; - the request is validated against those groups rather than every group there is, since a request need not come from the form; - redemption asks allowsGroupMembership() of the invitation's sender before writing the membership. The last one is the one that matters. An invitation is a grant that lands days later, when the sender is not present to be checked, and the ones written before today are still outstanding. A refused membership is dropped and logged rather than failing the redemption: the account is what the person holding the link came for, and it is theirs either way. An invitation whose sender has since been deleted keeps its group — there is no longer a reach to exceed.
627 lines
27 KiB
PHP
627 lines
27 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Audit\Action;
|
|
use App\Modules\Audit\ActivityLog;
|
|
use App\Modules\Clients\Models\Invitation;
|
|
use App\Modules\Clients\Notifications\ClientInvitationNotification;
|
|
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 App\Modules\Notifications\InAppNotification;
|
|
use App\Modules\Platform\Settings\Setting;
|
|
use App\Modules\Platform\Settings\Settings;
|
|
use Illuminate\Routing\Middleware\ThrottleRequests;
|
|
use Illuminate\Support\Facades\Notification;
|
|
use Illuminate\Support\Str;
|
|
use Inertia\Testing\AssertableInertia;
|
|
|
|
beforeEach(function () {
|
|
$this->admin = User::factory()->create();
|
|
});
|
|
|
|
test('staff can send an invitation and it emails the invited address', function () {
|
|
Notification::fake();
|
|
|
|
$this->actingAs($this->admin)->post('/clients/invitations', [
|
|
'email' => 'invited@example.com',
|
|
'name' => 'Invited Person',
|
|
'group_id' => 0,
|
|
])->assertRedirect(route('invitations.index'));
|
|
|
|
$invitation = Invitation::query()->where('email', 'invited@example.com')->sole();
|
|
expect($invitation->name)->toBe('Invited Person')
|
|
->and($invitation->status)->toBe(Invitation::STATUS_PENDING)
|
|
->and($invitation->invited_by_id)->toBe($this->admin->id)
|
|
->and(ActivityLog::query()->where('action', Action::ClientInvited)->exists())->toBeTrue();
|
|
|
|
Notification::assertSentOnDemand(
|
|
ClientInvitationNotification::class,
|
|
fn (ClientInvitationNotification $n, array $channels, $notifiable): bool => $notifiable->routes['mail'] === 'invited@example.com',
|
|
);
|
|
});
|
|
|
|
test('a storage quota set on the invitation carries through to the account it creates', function () {
|
|
app(Settings::class)->set(Setting::ClientsAutoApprove, true);
|
|
|
|
// A string, deliberately: a real form field arrives as one, and
|
|
// $this->post() otherwise preserves whatever PHP type the test itself
|
|
// wrote — hiding exactly the mismatch a browser's actual POST would
|
|
// hit against a strictly-typed collaborator.
|
|
$this->actingAs($this->admin)->post('/clients/invitations', [
|
|
'email' => 'invited@example.com',
|
|
'group_id' => 0,
|
|
'storage_quota_mb' => '500',
|
|
]);
|
|
|
|
$invitation = Invitation::query()->where('email', 'invited@example.com')->sole();
|
|
expect($invitation->storage_quota_mb)->toBe(500);
|
|
|
|
$this->post('/logout');
|
|
|
|
$this->post("/invite/{$invitation->token}", [
|
|
'token' => $invitation->token,
|
|
'name' => 'Invited Person',
|
|
'password' => 'super-secret-password',
|
|
'password_confirmation' => 'super-secret-password',
|
|
]);
|
|
|
|
$client = User::query()->where('email', 'invited@example.com')->sole();
|
|
expect($client->storage_quota_mb)->toBe(500);
|
|
});
|
|
|
|
test('leaving the storage quota blank inherits the site default, same as self-registration', function () {
|
|
$this->actingAs($this->admin)->post('/clients/invitations', [
|
|
'email' => 'invited@example.com',
|
|
'group_id' => 0,
|
|
]);
|
|
|
|
$invitation = Invitation::query()->where('email', 'invited@example.com')->sole();
|
|
expect($invitation->storage_quota_mb)->toBe(0);
|
|
});
|
|
|
|
test('inviting an already-invited address supersedes the earlier invitation instead of leaving two live tokens', function () {
|
|
$first = Invitation::issue('invited@example.com', null, null, $this->admin, now()->addDay());
|
|
|
|
$this->actingAs($this->admin)->post('/clients/invitations', [
|
|
'email' => 'invited@example.com',
|
|
'group_id' => 0,
|
|
])->assertRedirect(route('invitations.index'));
|
|
|
|
expect($first->fresh()->status)->toBe(Invitation::STATUS_SUPERSEDED)
|
|
->and(Invitation::query()->pending()->where('email', 'invited@example.com')->count())->toBe(1);
|
|
|
|
$this->post('/logout');
|
|
|
|
$this->get("/invite/{$first->token}")->assertInertia(
|
|
fn (AssertableInertia $page) => $page->where('expired', true),
|
|
);
|
|
});
|
|
|
|
test('an invitation cannot be sent to an address that already has an account', function () {
|
|
$existing = User::factory()->client()->create(['email' => 'taken@example.com']);
|
|
|
|
$this->actingAs($this->admin)->post('/clients/invitations', [
|
|
'email' => 'taken@example.com',
|
|
'group_id' => 0,
|
|
])->assertSessionHasErrors('email');
|
|
|
|
expect(Invitation::query()->where('email', 'taken@example.com')->exists())->toBeFalse();
|
|
|
|
$existing->delete();
|
|
});
|
|
|
|
test('clients cannot send invitations', function () {
|
|
$this->actingAs(User::factory()->client()->create());
|
|
|
|
$this->get('/clients/invitations')->assertRedirect(route('dashboard'));
|
|
$this->post('/clients/invitations', ['email' => 'x@example.com', 'group_id' => 0])->assertForbidden();
|
|
});
|
|
|
|
test('a valid invitation link shows the redemption form with the email locked', function () {
|
|
$invitation = Invitation::issue('invited@example.com', 'Invited Person', null, $this->admin, now()->addDay());
|
|
|
|
$this->get("/invite/{$invitation->token}")->assertInertia(
|
|
fn (AssertableInertia $page) => $page
|
|
->component('auth/invite')
|
|
->where('email', 'invited@example.com')
|
|
->where('name', 'Invited Person')
|
|
->where('expired', false),
|
|
);
|
|
});
|
|
|
|
test('an unknown token reads as expired rather than a 404', function () {
|
|
$this->get('/invite/not-a-real-token')->assertInertia(
|
|
fn (AssertableInertia $page) => $page->where('expired', true),
|
|
);
|
|
});
|
|
|
|
test('an expired invitation reads as expired', function () {
|
|
$invitation = Invitation::issue('invited@example.com', null, null, $this->admin, now()->subMinute());
|
|
|
|
$this->get("/invite/{$invitation->token}")->assertInertia(
|
|
fn (AssertableInertia $page) => $page->where('expired', true),
|
|
);
|
|
});
|
|
|
|
test('redeeming a valid invitation creates an active client and marks it redeemed, regardless of the self-registration auto-approve setting', function () {
|
|
app(Settings::class)->set(Setting::ClientsAutoApprove, false);
|
|
|
|
$invitation = Invitation::issue('invited@example.com', null, null, $this->admin, now()->addDay());
|
|
|
|
$this->post("/invite/{$invitation->token}", [
|
|
'token' => $invitation->token,
|
|
'name' => 'Invited Person',
|
|
'password' => 'super-secret-password',
|
|
'password_confirmation' => 'super-secret-password',
|
|
])->assertRedirect(route('login'));
|
|
|
|
$client = User::query()->where('email', 'invited@example.com')->sole();
|
|
expect($client->type)->toBe(UserType::Client)
|
|
->and($client->active)->toBeTrue()
|
|
->and($client->account_requested)->toBeFalse()
|
|
->and(ActivityLog::query()->where('action', Action::ClientInvitationRedeemed)->exists())->toBeTrue();
|
|
|
|
expect($invitation->fresh()->status)->toBe(Invitation::STATUS_REDEEMED);
|
|
|
|
$this->post('/login', ['email' => 'invited@example.com', 'password' => 'super-secret-password']);
|
|
$this->assertAuthenticated();
|
|
});
|
|
|
|
test('redeeming joins the group the invitation named', function () {
|
|
$group = Group::query()->create(['name' => 'Invited Clients']);
|
|
|
|
$invitation = Invitation::issue('invited@example.com', null, $group, $this->admin, now()->addDay());
|
|
|
|
$this->post("/invite/{$invitation->token}", [
|
|
'token' => $invitation->token,
|
|
'name' => 'Invited Person',
|
|
'password' => 'super-secret-password',
|
|
'password_confirmation' => 'super-secret-password',
|
|
]);
|
|
|
|
$client = User::query()->where('email', 'invited@example.com')->sole();
|
|
expect($group->members()->where('users.id', $client->id)->exists())->toBeTrue();
|
|
});
|
|
|
|
test('a redeemed invitation cannot be used again', function () {
|
|
$invitation = Invitation::issue('invited@example.com', null, null, $this->admin, now()->addDay());
|
|
$invitation->forceFill(['status' => Invitation::STATUS_REDEEMED])->save();
|
|
|
|
$this->post("/invite/{$invitation->token}", [
|
|
'token' => $invitation->token,
|
|
'name' => 'Second Attempt',
|
|
'password' => 'super-secret-password',
|
|
'password_confirmation' => 'super-secret-password',
|
|
])->assertSessionHasErrors('token');
|
|
|
|
expect(User::query()->where('email', 'invited@example.com')->exists())->toBeFalse();
|
|
});
|
|
|
|
test('an expired invitation cannot be redeemed even by posting the right token', function () {
|
|
$invitation = Invitation::issue('invited@example.com', null, null, $this->admin, now()->subMinute());
|
|
|
|
$this->post("/invite/{$invitation->token}", [
|
|
'token' => $invitation->token,
|
|
'name' => 'Too Late',
|
|
'password' => 'super-secret-password',
|
|
'password_confirmation' => 'super-secret-password',
|
|
])->assertSessionHasErrors('token');
|
|
|
|
expect(User::query()->where('email', 'invited@example.com')->exists())->toBeFalse();
|
|
});
|
|
|
|
test('resending an expired invitation issues a fresh token and emails it, without exposing whether the old one was real', function () {
|
|
Notification::fake();
|
|
|
|
$invitation = Invitation::issue('invited@example.com', null, null, $this->admin, now()->subMinute());
|
|
|
|
$this->post("/invite/{$invitation->token}/resend")->assertRedirect();
|
|
|
|
$fresh = Invitation::query()->pending()->where('email', 'invited@example.com')->sole();
|
|
expect($fresh->token)->not->toBe($invitation->token)
|
|
->and($fresh->isExpired())->toBeFalse()
|
|
->and($invitation->fresh()->status)->toBe(Invitation::STATUS_SUPERSEDED);
|
|
|
|
// The spent link is retired along with the expired one: resending
|
|
// does not leave two working tokens for the same address.
|
|
$this->get("/invite/{$invitation->token}")->assertInertia(
|
|
fn (AssertableInertia $page) => $page->where('expired', true),
|
|
);
|
|
|
|
Notification::assertSentOnDemand(
|
|
ClientInvitationNotification::class,
|
|
fn (ClientInvitationNotification $n, array $channels, $notifiable): bool => $notifiable->routes['mail'] === 'invited@example.com',
|
|
);
|
|
|
|
// A token that was never real answers exactly the same way — no
|
|
// notification, but also no error revealing that.
|
|
Notification::fake();
|
|
$this->post('/invite/not-a-real-token/resend')->assertRedirect();
|
|
Notification::assertNothingSent();
|
|
});
|
|
|
|
test('an invitation whose address was taken while the link was live refuses rather than failing on the unique index', function () {
|
|
$invitation = Invitation::issue('invited@example.com', null, null, $this->admin, now()->addDay());
|
|
|
|
// Staff got impatient, or the person used the public form instead.
|
|
User::factory()->client()->create(['email' => 'invited@example.com']);
|
|
|
|
$this->post("/invite/{$invitation->token}", [
|
|
'token' => $invitation->token,
|
|
'name' => 'Invited Person',
|
|
'password' => 'super-secret-password',
|
|
'password_confirmation' => 'super-secret-password',
|
|
])->assertSessionHasErrors('token');
|
|
|
|
expect(User::query()->where('email', 'invited@example.com')->count())->toBe(1)
|
|
->and($invitation->fresh()->status)->toBe(Invitation::STATUS_PENDING);
|
|
});
|
|
|
|
test('an address held by a deleted account is still taken, the same as it is everywhere else', function () {
|
|
$invitation = Invitation::issue('invited@example.com', null, null, $this->admin, now()->addDay());
|
|
|
|
User::factory()->client()->create(['email' => 'invited@example.com'])->delete();
|
|
|
|
$this->post("/invite/{$invitation->token}", [
|
|
'token' => $invitation->token,
|
|
'name' => 'Invited Person',
|
|
'password' => 'super-secret-password',
|
|
'password_confirmation' => 'super-secret-password',
|
|
])->assertSessionHasErrors('token');
|
|
|
|
expect(User::query()->where('email', 'invited@example.com')->exists())->toBeFalse();
|
|
});
|
|
|
|
test('a full installation refuses to send an invitation it could not honour', function () {
|
|
config()->set('projectsend.platform.max_clients', 1);
|
|
User::factory()->client()->create();
|
|
|
|
$this->actingAs($this->admin)->post('/clients/invitations', [
|
|
'email' => 'invited@example.com',
|
|
'group_id' => 0,
|
|
])->assertSessionHasErrors('email');
|
|
|
|
expect(Invitation::query()->where('email', 'invited@example.com')->exists())->toBeFalse();
|
|
});
|
|
|
|
test('a seat taken between invitation and redemption refuses on a field the form can show', function () {
|
|
$invitation = Invitation::issue('invited@example.com', null, null, $this->admin, now()->addDay());
|
|
|
|
// The last seat goes while the link is in somebody's inbox.
|
|
config()->set('projectsend.platform.max_clients', 1);
|
|
User::factory()->client()->create();
|
|
|
|
$this->post("/invite/{$invitation->token}", [
|
|
'token' => $invitation->token,
|
|
'name' => 'Invited Person',
|
|
'password' => 'super-secret-password',
|
|
'password_confirmation' => 'super-secret-password',
|
|
])->assertSessionHasErrors('email');
|
|
|
|
expect(User::query()->where('email', 'invited@example.com')->exists())->toBeFalse()
|
|
->and($invitation->fresh()->status)->toBe(Invitation::STATUS_PENDING);
|
|
});
|
|
|
|
test('the invitations screen is a history: every invitation ever sent, whatever became of it', function () {
|
|
$group = Group::query()->create(['name' => 'Invited Clients']);
|
|
|
|
Invitation::issue('live@example.com', 'Live Person', $group, $this->admin, now()->addDay());
|
|
Invitation::issue('stale@example.com', null, null, $this->admin, now()->subDay());
|
|
Invitation::issue('spent@example.com', null, null, $this->admin, now()->addDay())
|
|
->forceFill(['status' => Invitation::STATUS_REDEEMED])->save();
|
|
Invitation::issue('gone@example.com', null, null, $this->admin, now()->addDay())
|
|
->forceFill(['status' => Invitation::STATUS_REVOKED])->save();
|
|
Invitation::issue('replaced@example.com', null, null, $this->admin, now()->addDay())
|
|
->forceFill(['status' => Invitation::STATUS_SUPERSEDED])->save();
|
|
|
|
$this->actingAs($this->admin)->get('/clients/invitations')->assertInertia(
|
|
fn (AssertableInertia $page) => $page
|
|
->component('clients/invitations')
|
|
->has('invitations', 5)
|
|
->where('filters.status', null),
|
|
);
|
|
|
|
// Newest first, and each row carries the state the screen labels it by.
|
|
$states = collect($this->actingAs($this->admin)->get('/clients/invitations')->viewData('page')['props']['invitations'])
|
|
->pluck('state', 'email');
|
|
|
|
expect($states->all())->toBe([
|
|
'replaced@example.com' => 'superseded',
|
|
'gone@example.com' => 'revoked',
|
|
'spent@example.com' => 'redeemed',
|
|
'stale@example.com' => 'expired',
|
|
'live@example.com' => 'pending',
|
|
]);
|
|
});
|
|
|
|
test('the history can be filtered down to one status', function () {
|
|
Invitation::issue('live@example.com', null, null, $this->admin, now()->addDay());
|
|
Invitation::issue('stale@example.com', null, null, $this->admin, now()->subDay());
|
|
Invitation::issue('gone@example.com', null, null, $this->admin, now()->addDay())
|
|
->forceFill(['status' => Invitation::STATUS_REVOKED])->save();
|
|
|
|
// "Waiting" and "Expired" are the same stored status told apart by the
|
|
// clock, which is the pair worth proving the filter gets right.
|
|
$this->actingAs($this->admin)->get('/clients/invitations?status=pending')->assertInertia(
|
|
fn (AssertableInertia $page) => $page
|
|
->has('invitations', 1)
|
|
->where('invitations.0.email', 'live@example.com')
|
|
->where('filters.status', 'pending'),
|
|
);
|
|
|
|
$this->actingAs($this->admin)->get('/clients/invitations?status=expired')->assertInertia(
|
|
fn (AssertableInertia $page) => $page->has('invitations', 1)->where('invitations.0.email', 'stale@example.com'),
|
|
);
|
|
|
|
$this->actingAs($this->admin)->get('/clients/invitations?status=revoked')->assertInertia(
|
|
fn (AssertableInertia $page) => $page->has('invitations', 1)->where('invitations.0.email', 'gone@example.com'),
|
|
);
|
|
});
|
|
|
|
test('an unknown status filter is refused rather than quietly ignored', function () {
|
|
$this->actingAs($this->admin)->get('/clients/invitations?status=whatever')->assertSessionHasErrors('status');
|
|
});
|
|
|
|
test('staff can revoke an invitation, and the revoked link is dead for good', function () {
|
|
Notification::fake();
|
|
|
|
$invitation = Invitation::issue('invited@example.com', null, null, $this->admin, now()->addDay());
|
|
|
|
$this->actingAs($this->admin)
|
|
->delete(route('invitations.destroy', $invitation))
|
|
->assertRedirect();
|
|
|
|
expect($invitation->fresh()->status)->toBe(Invitation::STATUS_REVOKED)
|
|
->and(ActivityLog::query()->where('action', Action::ClientInvitationRevoked)->exists())->toBeTrue();
|
|
|
|
$this->post('/logout');
|
|
|
|
// The three things a live token could do, all refused.
|
|
$this->get("/invite/{$invitation->token}")->assertInertia(
|
|
fn (AssertableInertia $page) => $page->where('expired', true),
|
|
);
|
|
|
|
$this->post("/invite/{$invitation->token}", [
|
|
'token' => $invitation->token,
|
|
'name' => 'Invited Person',
|
|
'password' => 'super-secret-password',
|
|
'password_confirmation' => 'super-secret-password',
|
|
])->assertSessionHasErrors('token');
|
|
|
|
$this->post("/invite/{$invitation->token}/resend")->assertRedirect();
|
|
Notification::assertNothingSent();
|
|
|
|
expect(Invitation::query()->pending()->where('email', 'invited@example.com')->exists())->toBeFalse()
|
|
->and(User::query()->where('email', 'invited@example.com')->exists())->toBeFalse();
|
|
});
|
|
|
|
test('an invitation that is not outstanding cannot be revoked', function () {
|
|
$invitation = Invitation::issue('invited@example.com', null, null, $this->admin, now()->addDay());
|
|
$invitation->forceFill(['status' => Invitation::STATUS_REDEEMED])->save();
|
|
|
|
$this->actingAs($this->admin)
|
|
->delete(route('invitations.destroy', $invitation))
|
|
->assertNotFound();
|
|
|
|
expect($invitation->fresh()->status)->toBe(Invitation::STATUS_REDEEMED);
|
|
});
|
|
|
|
test('clients cannot revoke invitations', function () {
|
|
$invitation = Invitation::issue('invited@example.com', null, null, $this->admin, now()->addDay());
|
|
|
|
$this->actingAs(User::factory()->client()->create())
|
|
->delete(route('invitations.destroy', $invitation))
|
|
->assertForbidden();
|
|
|
|
expect($invitation->fresh()->status)->toBe(Invitation::STATUS_PENDING);
|
|
});
|
|
|
|
test('an invitation can only be renewed by the person holding it so many times', function () {
|
|
Notification::fake();
|
|
|
|
// The route's own throttle allows three of these a minute, which is
|
|
// the fourth request this test needs to make. It is a separate limit
|
|
// with a separate job — this test is about the one that does not reset
|
|
// after sixty seconds.
|
|
$this->withoutMiddleware(ThrottleRequests::class);
|
|
|
|
Invitation::issue('invited@example.com', null, null, $this->admin, now()->subMinute());
|
|
|
|
// Three renewals, each on the link the previous one issued, which is
|
|
// what somebody following the emails would actually do.
|
|
foreach (range(1, 3) as $round) {
|
|
$current = Invitation::query()->pending()->where('email', 'invited@example.com')->sole();
|
|
$this->post("/invite/{$current->token}/resend")->assertRedirect();
|
|
|
|
expect(Invitation::query()->pending()->where('email', 'invited@example.com')->sole()->resends)->toBe($round);
|
|
}
|
|
|
|
Notification::assertSentOnDemandTimes(ClientInvitationNotification::class, 3);
|
|
expect(ActivityLog::query()->where('action', Action::ClientInvitationResent)->count())->toBe(3);
|
|
|
|
// The fourth is refused, in the same words as every other refusal this
|
|
// door gives, and sends nothing.
|
|
Notification::fake();
|
|
$fourth = Invitation::query()->pending()->where('email', 'invited@example.com')->sole();
|
|
$this->post("/invite/{$fourth->token}/resend")->assertRedirect();
|
|
|
|
Notification::assertNothingSent();
|
|
expect(Invitation::query()->pending()->where('email', 'invited@example.com')->sole()->token)->toBe($fourth->token);
|
|
});
|
|
|
|
test('a staff member sending a new invitation starts the renewal allowance again', function () {
|
|
Notification::fake();
|
|
|
|
$spent = Invitation::issue('invited@example.com', null, null, $this->admin, now()->addDay(), resends: 3);
|
|
|
|
$this->actingAs($this->admin)->post('/clients/invitations', [
|
|
'email' => 'invited@example.com',
|
|
'group_id' => 0,
|
|
])->assertRedirect(route('invitations.index'));
|
|
|
|
$fresh = Invitation::query()->pending()->where('email', 'invited@example.com')->sole();
|
|
expect($fresh->resends)->toBe(0)
|
|
->and($spent->fresh()->status)->toBe(Invitation::STATUS_SUPERSEDED);
|
|
|
|
$this->post('/logout');
|
|
$this->post("/invite/{$fresh->token}/resend")->assertRedirect();
|
|
|
|
expect(Invitation::query()->pending()->where('email', 'invited@example.com')->sole()->resends)->toBe(1);
|
|
});
|
|
|
|
test('the invite form and the history are separate screens', function () {
|
|
$this->actingAs($this->admin)->get('/clients/invitations/create')->assertInertia(
|
|
fn (AssertableInertia $page) => $page->component('clients/invite')->has('groups')->missing('invitations'),
|
|
);
|
|
|
|
$this->actingAs($this->admin)->get('/clients/invitations')->assertInertia(
|
|
fn (AssertableInertia $page) => $page->component('clients/invitations')->has('invitations')->missing('groups'),
|
|
);
|
|
});
|
|
|
|
test('redeeming an invitation puts a notification in front of the staff who administer clients', function () {
|
|
$invitation = Invitation::issue('invited@example.com', null, null, $this->admin, now()->addDay());
|
|
|
|
$this->post("/invite/{$invitation->token}", [
|
|
'token' => $invitation->token,
|
|
'name' => 'Invited Person',
|
|
'password' => 'super-secret-password',
|
|
'password_confirmation' => 'super-secret-password',
|
|
])->assertRedirect(route('login'));
|
|
|
|
$client = User::query()->where('email', 'invited@example.com')->sole();
|
|
|
|
$notification = InAppNotification::query()->where('type', 'client_registered')->sole();
|
|
expect($notification->user_id)->toBe($this->admin->id)
|
|
->and($notification->subject_id)->toBe($client->id)
|
|
->and($notification->data['clientName'])->toBe('Invited Person')
|
|
->and($notification->data['clientEmail'])->toBe('invited@example.com');
|
|
});
|
|
|
|
test('a client-scoped staff member is not told about an account assigned to nobody', function () {
|
|
$role = Role::query()->create(['name' => 'Scoped Manager', 'client_scoped' => true]);
|
|
RolePermission::query()->insert([
|
|
['role_id' => $role->id, 'permission' => Permission::ManageClients->value],
|
|
]);
|
|
$scoped = User::factory()->create(['role_id' => $role->id]);
|
|
|
|
$invitation = Invitation::issue('invited@example.com', null, null, $this->admin, now()->addDay());
|
|
|
|
$this->post("/invite/{$invitation->token}", [
|
|
'token' => $invitation->token,
|
|
'name' => 'Invited Person',
|
|
'password' => 'super-secret-password',
|
|
'password_confirmation' => 'super-secret-password',
|
|
]);
|
|
|
|
expect(InAppNotification::query()->where('type', 'client_registered')->pluck('user_id')->all())
|
|
->toBe([$this->admin->id])
|
|
->and(InAppNotification::query()->where('user_id', $scoped->id)->exists())->toBeFalse();
|
|
});
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| The group boundary — GHSA-c6h9-hcm7-j3x9
|
|
|--------------------------------------------------------------------------
|
|
|
|
|
| Reported by @hackchang. The group controllers were scoped when
|
|
| GHSA-r3hg-3fxw-rcmr was fixed; invitations were written afterwards and
|
|
| were not, so a client-scoped staff member could read every group's name
|
|
| off the invitation form and seed an invited client into a group outside
|
|
| their library — the reach StaffLibraryScope::allowsGroupMembership()
|
|
| exists to refuse, reached by a door that never asked it.
|
|
*/
|
|
|
|
/** A client-scoped staff member who may create clients, with one client of their own. */
|
|
function scopedInviter(): User
|
|
{
|
|
$role = Role::query()->create(['name' => 'Reps '.Str::random(6), 'client_scoped' => true]);
|
|
|
|
foreach ([Permission::CreateClients, Permission::ManageGroups, Permission::EditGroups] as $permission) {
|
|
RolePermission::query()->create(['role_id' => $role->id, 'permission' => $permission->value]);
|
|
}
|
|
|
|
$staff = User::factory()->create(['role_id' => $role->id]);
|
|
$staff->assignedClients()->sync([User::factory()->client()->create(['name' => 'Their client'])->id]);
|
|
|
|
return $staff;
|
|
}
|
|
|
|
test('the invitation form lists only the groups this staff member may reach', function () {
|
|
$rep = scopedInviter();
|
|
$mine = Group::query()->create(['name' => 'Mine']);
|
|
$mine->members()->sync([$rep->assignedClients()->value('users.id')]);
|
|
Group::query()->create(['name' => 'Somebody else\'s']);
|
|
|
|
$this->actingAs($rep)->get('/clients/invitations/create')->assertInertia(
|
|
fn (AssertableInertia $page) => $page
|
|
->component('clients/invite')
|
|
->has('groups', 1)
|
|
->where('groups.0.name', 'Mine'),
|
|
);
|
|
|
|
// An unscoped administrator still sees both.
|
|
$this->actingAs($this->admin)->get('/clients/invitations/create')->assertInertia(
|
|
fn (AssertableInertia $page) => $page->has('groups', 2),
|
|
);
|
|
});
|
|
|
|
test('an invitation cannot name a group this staff member may not reach', function () {
|
|
$rep = scopedInviter();
|
|
$theirs = Group::query()->create(['name' => 'Somebody else\'s']);
|
|
|
|
$this->actingAs($rep)->post('/clients/invitations', [
|
|
'email' => 'invited@example.com',
|
|
'group_id' => $theirs->id,
|
|
])->assertSessionHasErrors('group_id');
|
|
|
|
expect(Invitation::query()->count())->toBe(0);
|
|
});
|
|
|
|
test('an invitation already carrying an out-of-scope group creates the account without it', function () {
|
|
// The invitations written before the door was guarded: the grant lands
|
|
// days later, when the sender is not there to be asked.
|
|
$rep = scopedInviter();
|
|
$theirs = Group::query()->create(['name' => 'Somebody else\'s']);
|
|
$stranger = User::factory()->client()->create();
|
|
$theirs->members()->sync([$stranger->id]);
|
|
|
|
$invitation = Invitation::issue('invited@example.com', null, $theirs, $rep, now()->addDay());
|
|
|
|
$this->post("/invite/{$invitation->token}", [
|
|
'token' => $invitation->token,
|
|
'name' => 'Invited Person',
|
|
'password' => 'Str0ng-Password!',
|
|
'password_confirmation' => 'Str0ng-Password!',
|
|
])->assertRedirect(route('login'));
|
|
|
|
$client = User::query()->where('email', 'invited@example.com')->sole();
|
|
|
|
expect($theirs->members()->where('users.id', $client->id)->exists())->toBeFalse()
|
|
// The account is what the person holding the link came for.
|
|
->and($client->isClient())->toBeTrue();
|
|
});
|
|
|
|
test('an invitation from an administrator still joins the group it named', function () {
|
|
// The guard is about reach, not about invitations: an unscoped sender
|
|
// has none to exceed.
|
|
$group = Group::query()->create(['name' => 'Anybody']);
|
|
$invitation = Invitation::issue('invited@example.com', null, $group, $this->admin, now()->addDay());
|
|
|
|
$this->post("/invite/{$invitation->token}", [
|
|
'token' => $invitation->token,
|
|
'name' => 'Invited Person',
|
|
'password' => 'Str0ng-Password!',
|
|
'password_confirmation' => 'Str0ng-Password!',
|
|
])->assertRedirect(route('login'));
|
|
|
|
$client = User::query()->where('email', 'invited@example.com')->sole();
|
|
|
|
expect($group->members()->where('users.id', $client->id)->exists())->toBeTrue();
|
|
});
|