Files
projectsend/tests/Feature/Clients/ClientInvitationTest.php
T
ignacionelson 305c79bc96 Keep an invitation inside the sender's own client scope
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.
2026-09-18 03:35:14 -03:00

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