mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 09:05:08 +00:00
cd2ce960d3
An invitation stays live for days -- 72 hours by default -- and the address it names can be claimed in that window: staff got impatient and created the account by hand, or the person used the public registration form instead. Redemption never asked, so User::create() met the unique index on users.email and raised a QueryException. A 500, on the screen of somebody who had just chosen a password, having done nothing wrong. ClientProvisioning::addressIsFree() exists for exactly this, and its docblock says who must call it: the paths with no form to validate. LDAP asks. Redemption is the third such path and did not. It now refuses with a message that says what happened, rather than the generic "this invitation is no longer valid" the expired case uses. There is nothing to withhold here -- whoever holds the link already knows the address, because it is the one the invitation was sent to -- and being told to sign in instead is the only useful thing to say. The invitation stays pending rather than being retired. It is the account that resolved the situation, not the link, and a retired row would only make the second attempt read as expired. The address rule spans soft-deleted accounts, the same as it does everywhere else, so a deleted account still holds its address until erasure takes the row away. Both cases are tested. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
273 lines
11 KiB
PHP
273 lines
11 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();
|
|
});
|