Files
projectsend/tests/Feature/Clients/ClientInvitationTest.php
T
ignacionelson f06a3c7ab3 Put a new client account in front of the staff who administer clients
Invitations produced no in-app notification at all, and neither did
self-registration: the whole Clients module raised none. The only
admin-facing signal when an account appeared was an email to whatever raw
addresses an operator typed into a setting -- addresses that need not
correspond to any account in this installation, and that plenty of
installations never fill in. An invitation could be accepted and nobody
signed in would ever be told.

So: one new type, client_registered, reaching the bell and /notifications.
One type for both doors on purpose. A client arriving through the public
form and one arriving through an invitation are the same event to the
person being told -- an account now exists that did not -- and a second
type would buy nothing, because preferences here govern email only, so it
could not have been switched off separately anyway. Which door it came
through is one click away in the activity log and on the invitations
screen.

In-app only, the reasoning client_uploaded already states: email for this
event is sent separately to that address list, and routing it through
Notifier's mail dispatch too would risk double-emailing any staff member
who is also on it.

Two things worth stating about who gets it. Recipients are resolved at the
call site, because Notifier authorizes nothing by design -- its security
contract is explicit that a broad query must never be handed to it. And a
client-scoped staff member is deliberately not told: their whole view is
the clients assigned to them, and a brand-new account is assigned to
nobody, so it would link them to a screen they are refused.

Which is also why the notification links to the clients list filtered to
the address, and not to clients.edit: that route is gated by edit_clients
while these recipients are chosen by manage_clients. A notification that
refuses the person it was sent to is worse than one that lands a click
short.

Translated in all sixteen locales, and the redemption was driven through a
real browser to see the row arrive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
2026-09-12 17:34:47 -03:00

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