Files
projectsend/tests/Feature/Clients/ClientInvitationTest.php
T
ignacionelson fb3fd1766c Turn the second tab into a history of every invitation, and open on it
The tab listed only what was still live, which cannot answer the question
somebody actually arrives with: did we ever invite this person, and what
happened? An invitation that was accepted, revoked or replaced by a newer
one simply vanished from the screen, and the activity log was the only
place left to look.

So the tab is a history now -- every invitation ever sent, newest first,
each carrying the state it ended in -- with a status filter for reading one
slice of it. And it opens first, because arriving here the question is
usually about what has already been sent, including to the person you were
about to invite again. ?tab=send still goes straight to the form.

Five states, and two of them needed deciding:

- "Expired" is not a stored status and deliberately is not one: nothing
  writes it, a row becomes expired by the clock passing rather than by
  anybody acting, and storing it would need a scheduled task to stay true.
  Invitation::state() derives it, once, and both the badge and the filter
  read that -- two copies of the rule is how they start disagreeing about a
  row whose expiry passed a second ago.
- "Replaced" is what superseded says to somebody who is not reading the
  source. It is a different fact from expired, and worth telling apart: one
  ran out, the other was retired by a newer invitation to the same address.

The count in the tab label stays a count of live invitations rather than of
the rows below. The history is mostly settled, and the number worth
carrying in a label is the one that says whether anybody is still waiting --
which is also why it is counted over the table rather than the filtered
page, so narrowing the list cannot change it.

Revoke appears only on a row that still has something to revoke, and the
expiry column is blank on a settled one: the date is still stored and still
true, and printing it invites somebody to wonder what expires about an
invitation that was accepted.

The screen is called Invitations now, in the heading and the breadcrumb.
With the history first it is no longer a form with a list under it.

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

476 lines
20 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\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/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 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/invite')->assertInertia(
fn (AssertableInertia $page) => $page
->component('clients/invite')
->has('invitations', 5)
// Only the one that is live and unexpired counts as waiting.
->where('pending_count', 1)
->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/invite')->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/invite?status=pending')->assertInertia(
fn (AssertableInertia $page) => $page
->has('invitations', 1)
->where('invitations.0.email', 'live@example.com')
->where('filters.status', 'pending')
// Unchanged by the filter: it answers "is anybody waiting",
// not "how many rows am I looking at".
->where('pending_count', 1),
);
$this->actingAs($this->admin)->get('/clients/invite?status=expired')->assertInertia(
fn (AssertableInertia $page) => $page->has('invitations', 1)->where('invitations.0.email', 'stale@example.com'),
);
$this->actingAs($this->admin)->get('/clients/invite?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/invite?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/invite', [
'email' => 'invited@example.com',
'group_id' => 0,
])->assertRedirect(route('clients.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);
});