Files
projectsend/tests/Feature/Clients/ClientInvitationTest.php
T
ignacionelson a502a26075 Let staff cancel an invitation nobody has used
An invitation could be sent and never taken back. There was no list of
outstanding ones and no revoke, so the only way to withdraw a link sent to
the wrong address was to let it expire -- and the expired page's own "send
me a new one" button undoes exactly that, silently, for anybody still
holding the link. The one cancel the feature had could be reversed by the
person it was aimed at.

So: a new STATUS_REVOKED, outside the pending() scope that both the
redemption and the resend doors look through. A revoked link is dead to all
three things a live one can do -- opening the form, redeeming it, and
asking for a replacement -- and nothing but sending a fresh invitation
brings it back.

The list sits under the invite form rather than on a screen of its own,
because the person who wants to cancel an invitation is the person who just
sent one. It shows outstanding invitations only: pending, expired ones
included. An expired invitation is not inert until it is revoked, so hiding
it would hide the rows most worth a decision -- which is why they sort to
the top, soonest expiry first.

Revoking is gated by create_clients, the same authority as sending:
whoever may invite somebody may take it back. It is logged, like sending
and redeeming already were. The row is kept rather than deleted, for the
reason a superseded one is kept -- the activity log names who invited this
address and when, and that trail should still lead somewhere.

Verified in a browser, not only in tests: the screen mounts, both rows
render, and the expired one carries its badge.

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

382 lines
16 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\UserType;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
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/invite', [
'email' => 'invited@example.com',
'name' => 'Invited Person',
'group_id' => 0,
])->assertRedirect(route('clients.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/invite', [
'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/invite', [
'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/invite', [
'email' => 'invited@example.com',
'group_id' => 0,
])->assertRedirect(route('clients.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/invite', [
'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/invite')->assertRedirect(route('dashboard'));
$this->post('/clients/invite', ['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/invite', [
'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 invite screen lists outstanding invitations, expired ones included', 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());
// Neither of these is outstanding any more, and neither should be listed.
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();
$this->actingAs($this->admin)->get('/clients/invite')->assertInertia(
fn (AssertableInertia $page) => $page
->component('clients/invite')
->has('invitations', 2)
->where('invitations.0.email', 'stale@example.com')
->where('invitations.0.expired', true)
->where('invitations.1.email', 'live@example.com')
->where('invitations.1.expired', false)
->where('invitations.1.group', 'Invited Clients')
->where('invitations.1.invited_by', $this->admin->name),
);
});
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);
});