mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-24 12:22:01 +00:00
13b56186f4
On a managed installation with its staff seats full, /users/create opened as though there were room. You typed a name, an address and a password you had to invent, pressed Save, and the plan limit came back as a validation error under the email field -- which reads as a complaint about the address rather than a fact about the plan. A full installation is an ordinary state on a plan sold by the seat, so it is now stated up front. The list carries the seat position, the button goes dead once the last seat is taken and says why, and the create screen turns away anyone who reaches it by link or bookmark. The guard in store() is untouched: that is still the rule, this is only the door. The refusal is worded once, in SeatAllowance, and the screen is handed that sentence rather than writing its own -- two wordings of one limit is how somebody ends up believing there are two limits. `full` is derived there too, from the same comparison the guard refuses on, so a screen cannot disagree with it about the edge (used > limit, after an operator lowers a limit) and offer a button for a form that cannot be submitted. Clients get the same treatment: the cap exists there too, and reached it the same way. Self-hosted installations have no limit, so they are shown nothing about one.
339 lines
13 KiB
PHP
339 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Identity\Models\Role;
|
|
use App\Modules\Identity\Permissions\SystemRole;
|
|
use App\Modules\Platform\Seats\SeatAllowance;
|
|
use App\Modules\Platform\Settings\Setting;
|
|
use App\Modules\Platform\Settings\Settings;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Inertia\Testing\AssertableInertia;
|
|
|
|
/**
|
|
* A cap is only a cap if every door asks.
|
|
*
|
|
* There is no single User::create() these funnel through, so there is a
|
|
* test per door rather than a test of the service — the failure mode is
|
|
* one of them quietly not asking, and that is invisible from everywhere
|
|
* except the door that forgot. Same reason
|
|
* DownloadLimitEnforcementTest is written per route.
|
|
*/
|
|
beforeEach(function () {
|
|
$this->admin = User::factory()->create();
|
|
});
|
|
|
|
function seatLimits(?int $staff = null, ?int $clients = null): void
|
|
{
|
|
config([
|
|
'projectsend.platform.max_staff_users' => $staff,
|
|
'projectsend.platform.max_clients' => $clients,
|
|
]);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- the rule
|
|
|
|
test('no limit is the default, which is every self-hosted install', function () {
|
|
$seats = app(SeatAllowance::class);
|
|
|
|
expect($seats->staffLimit())->toBeNull()
|
|
->and($seats->clientLimit())->toBeNull();
|
|
});
|
|
|
|
test('a mistyped limit reads as unlimited rather than as zero', function () {
|
|
// An operator who fat-fingers the variable gets the self-hosted
|
|
// behaviour, not an installation that refuses every account.
|
|
seatLimits(staff: null);
|
|
config(['projectsend.platform.max_staff_users' => 'three']);
|
|
|
|
expect(app(SeatAllowance::class)->staffLimit())->toBeNull();
|
|
});
|
|
|
|
test('the count the guard reads is the count anyone else should display', function () {
|
|
// The portal shows "2 of 3 used" and the tenant refuses the fourth. If
|
|
// those are two counts they diverge, and it reads as a billing fault.
|
|
seatLimits(staff: 3, clients: 3);
|
|
|
|
$seats = app(SeatAllowance::class);
|
|
|
|
expect($seats->staffUsed())->toBe(1) // the admin from beforeEach
|
|
->and($seats->clientUsed())->toBe(0);
|
|
});
|
|
|
|
test('an inactive staff account still occupies its seat', function () {
|
|
// Otherwise deactivating is a way around the cap rather than a way to
|
|
// revoke access, since reactivating is one click.
|
|
User::factory()->create(['active' => false]);
|
|
|
|
expect(app(SeatAllowance::class)->staffUsed())->toBe(2);
|
|
});
|
|
|
|
test('a deleted account frees its seat', function () {
|
|
$extra = User::factory()->create();
|
|
|
|
expect(app(SeatAllowance::class)->staffUsed())->toBe(2);
|
|
|
|
$extra->delete();
|
|
|
|
expect(app(SeatAllowance::class)->staffUsed())->toBe(1);
|
|
});
|
|
|
|
test('a client awaiting approval does not occupy a seat', function () {
|
|
// Self-registration is open to strangers. Counting a pending request
|
|
// would let anyone exhaust a paid limit from outside, which turns a
|
|
// pricing tier into an availability control.
|
|
User::factory()->client()->create(['account_requested' => true, 'active' => false]);
|
|
|
|
expect(app(SeatAllowance::class)->clientUsed())->toBe(0);
|
|
});
|
|
|
|
// -------------------------------------------------------------- the doors
|
|
|
|
test('door: creating a staff account through the web screen', function () {
|
|
seatLimits(staff: 1); // the admin already fills it
|
|
|
|
$this->actingAs($this->admin)->post('/users', [
|
|
'name' => 'Second',
|
|
'email' => 'second@example.test',
|
|
'role_id' => Role::query()->where('name', SystemRole::AccountManager->value)->value('id'),
|
|
'password' => 'a-strong-password-1',
|
|
'password_confirmation' => 'a-strong-password-1',
|
|
])->assertSessionHasErrors('email');
|
|
|
|
expect(User::query()->where('email', 'second@example.test')->exists())->toBeFalse();
|
|
});
|
|
|
|
test('door: creating a staff account through the API', function () {
|
|
seatLimits(staff: 1);
|
|
|
|
Laravel\Sanctum\Sanctum::actingAs($this->admin, ['manage_users', 'create_users']);
|
|
|
|
$this->postJson('/api/v1/users', [
|
|
'name' => 'Second',
|
|
'email' => 'second@example.test',
|
|
'role_id' => Role::query()->where('name', SystemRole::AccountManager->value)->value('id'),
|
|
'password' => 'a-strong-password-1',
|
|
])->assertStatus(422);
|
|
|
|
expect(User::query()->where('email', 'second@example.test')->exists())->toBeFalse();
|
|
});
|
|
|
|
test('door: promoting a client to staff', function () {
|
|
// A promotion takes a staff seat, so it has to ask even though it
|
|
// creates no account.
|
|
seatLimits(staff: 1);
|
|
|
|
$client = User::factory()->client()->create();
|
|
|
|
$this->actingAs($this->admin)->post("/users/convert/{$client->id}", [
|
|
'direction' => 'to_staff',
|
|
'role_id' => Role::query()->where('name', SystemRole::AccountManager->value)->value('id'),
|
|
'password' => 'a-strong-password-1',
|
|
])->assertSessionHasErrors('email');
|
|
|
|
expect($client->refresh()->isClient())->toBeTrue();
|
|
});
|
|
|
|
test('door: creating a client through the web screen', function () {
|
|
seatLimits(clients: 0);
|
|
|
|
$this->actingAs($this->admin)->post('/clients', [
|
|
'name' => 'Nope',
|
|
'email' => 'nope@example.test',
|
|
'password' => 'a-strong-password-1',
|
|
'password_confirmation' => 'a-strong-password-1',
|
|
'active' => true,
|
|
])->assertSessionHasErrors('email');
|
|
|
|
expect(User::query()->where('email', 'nope@example.test')->exists())->toBeFalse();
|
|
});
|
|
|
|
test('door: creating a client through the API', function () {
|
|
seatLimits(clients: 0);
|
|
|
|
Laravel\Sanctum\Sanctum::actingAs($this->admin, ['create_clients']);
|
|
|
|
$this->postJson('/api/v1/clients', [
|
|
'name' => 'Nope',
|
|
'email' => 'nope@example.test',
|
|
'password' => 'a-strong-password-1',
|
|
])->assertStatus(422);
|
|
|
|
expect(User::query()->where('email', 'nope@example.test')->exists())->toBeFalse();
|
|
});
|
|
|
|
test('door: self-registration when the installation approves automatically', function () {
|
|
// Auto-approve means the account counts the moment it is made, so
|
|
// provisioning has to ask. Without auto-approve it does not — the next
|
|
// case covers that, and approval is where the seat is spent instead.
|
|
seatLimits(clients: 0);
|
|
app(Settings::class)->set(Setting::ClientsCanRegister, true);
|
|
app(Settings::class)->set(Setting::ClientsAutoApprove, true);
|
|
|
|
$this->post('/register', [
|
|
'name' => 'Stranger',
|
|
'email' => 'stranger@example.test',
|
|
'password' => 'a-strong-password-1',
|
|
'password_confirmation' => 'a-strong-password-1',
|
|
])->assertSessionHasErrors();
|
|
|
|
expect(User::query()->where('email', 'stranger@example.test')->exists())->toBeFalse();
|
|
});
|
|
|
|
test('door: approving an account request', function () {
|
|
seatLimits(clients: 0);
|
|
|
|
$pending = User::factory()->client()->create(['account_requested' => true, 'active' => false]);
|
|
|
|
$this->actingAs($this->admin)->post("/account-requests/{$pending->id}/approve")
|
|
->assertSessionHasErrors('email');
|
|
|
|
expect($pending->refresh()->account_requested)->toBeTrue();
|
|
});
|
|
|
|
test('door: demoting a staff account to client', function () {
|
|
seatLimits(clients: 0);
|
|
|
|
$staffer = User::factory()->create();
|
|
|
|
$this->actingAs($this->admin)->post("/users/convert/{$staffer->id}", [
|
|
'direction' => 'to_client',
|
|
])->assertSessionHasErrors('email');
|
|
|
|
expect($staffer->refresh()->isStaff())->toBeTrue();
|
|
});
|
|
|
|
// ------------------------------------------- saying so before anything is typed
|
|
|
|
/**
|
|
* A full installation is an ordinary state on a managed plan, not a fault.
|
|
*
|
|
* It used to read as one: the create screen opened, you invented a
|
|
* password, submitted, and the plan limit came back as a validation error
|
|
* under the email field — which looks like a complaint about the address.
|
|
* The guard stays where it is; these cases are about the screen in front
|
|
* of it.
|
|
*/
|
|
test('the create screen turns you away instead of taking a form it cannot accept', function () {
|
|
seatLimits(staff: 1); // the admin already fills it
|
|
|
|
$this->actingAs($this->admin)->get('/users/create')
|
|
->assertRedirect('/users')
|
|
->assertSessionHas('error', fn (string $message): bool => str_contains($message, 'limited to 1.'));
|
|
});
|
|
|
|
test('the client create screen does the same', function () {
|
|
seatLimits(clients: 0);
|
|
|
|
$this->actingAs($this->admin)->get('/clients/create')
|
|
->assertRedirect('/clients')
|
|
->assertSessionHas('error', fn (string $message): bool => str_contains($message, 'limited to 0.'));
|
|
});
|
|
|
|
test('room under the cap still opens the create screen', function () {
|
|
seatLimits(staff: 2, clients: 2);
|
|
|
|
$this->actingAs($this->admin)->get('/users/create')->assertOk();
|
|
$this->actingAs($this->admin)->get('/clients/create')->assertOk();
|
|
});
|
|
|
|
test('the list says where the installation stands, so the button can go dead with a reason', function () {
|
|
seatLimits(staff: 2);
|
|
|
|
$this->actingAs($this->admin)->get('/users')
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->where('seats.limit', 2)
|
|
->where('seats.used', 1)
|
|
->where('seats.full', false)
|
|
// Nothing to explain while there is room.
|
|
->where('seats.message', null));
|
|
});
|
|
|
|
test('the list carries the refusal in the guard\'s own words once it is full', function () {
|
|
// One wording for one limit: two is how somebody ends up believing
|
|
// there are two limits.
|
|
seatLimits(clients: 1);
|
|
|
|
User::factory()->client()->create();
|
|
|
|
$this->actingAs($this->admin)->get('/clients')
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->where('seats.full', true)
|
|
->where('seats.message', fn (string $message): bool => str_contains($message, 'limited to 1.')));
|
|
});
|
|
|
|
test('a self-hosted install is told nothing about seats at all', function () {
|
|
// No limit, so no counter, no dead button, and no invitation to
|
|
// wonder which plan it is on.
|
|
seatLimits();
|
|
|
|
$this->actingAs($this->admin)->get('/users')
|
|
->assertInertia(fn (AssertableInertia $page) => $page->where('seats', null));
|
|
|
|
$this->actingAs($this->admin)->get('/clients')
|
|
->assertInertia(fn (AssertableInertia $page) => $page->where('seats', null));
|
|
});
|
|
|
|
// --------------------------------------------------------- what must not change
|
|
|
|
test('the console command is deliberately not capped', function () {
|
|
// It is the recovery path, and anyone who can run it can also edit the
|
|
// environment the cap comes from. Capping it adds friction to getting
|
|
// back into a locked-out installation and closes nothing.
|
|
seatLimits(staff: 1);
|
|
|
|
$this->artisan('projectsend:admin', [
|
|
'--name' => 'Rescue',
|
|
'--email' => 'rescue@example.test',
|
|
'--password' => 'a-strong-password-1',
|
|
])->assertSuccessful();
|
|
|
|
expect(User::query()->where('email', 'rescue@example.test')->exists())->toBeTrue();
|
|
});
|
|
|
|
test('room under the cap still lets an account through', function () {
|
|
// Not deny-everything: the cap refuses the one past the limit, not the
|
|
// ones before it.
|
|
seatLimits(staff: 2);
|
|
|
|
$this->actingAs($this->admin)->post('/users', [
|
|
'name' => 'Second',
|
|
'email' => 'second@example.test',
|
|
'role_id' => Role::query()->where('name', SystemRole::AccountManager->value)->value('id'),
|
|
'password' => 'a-strong-password-1',
|
|
'password_confirmation' => 'a-strong-password-1',
|
|
])->assertSessionHasNoErrors();
|
|
|
|
expect(User::query()->where('email', 'second@example.test')->exists())->toBeTrue();
|
|
});
|
|
|
|
it('names the limit without putting a noun after the number', function () {
|
|
// The refusal used to read "limited to 1 staff accounts" — the number
|
|
// sat directly in front of a countable noun, so no single wording could
|
|
// be right for every value. English needs two forms; Polish, Czech and
|
|
// Russian need three, and inflect the noun by the number in front of
|
|
// it. Putting the number last means no language has to agree with it.
|
|
config()->set('projectsend.platform.max_staff_users', 1);
|
|
config()->set('projectsend.platform.max_clients', 1);
|
|
|
|
// Both seats have to be full for either guard to say anything at all.
|
|
User::factory()->client()->create();
|
|
|
|
$allowance = app(SeatAllowance::class);
|
|
|
|
foreach (['guardStaff', 'guardClient'] as $guard) {
|
|
try {
|
|
$allowance->{$guard}();
|
|
$this->fail($guard.'() should have refused at a limit of 1.');
|
|
} catch (ValidationException $e) {
|
|
$message = $e->validator->errors()->first();
|
|
|
|
expect($message)->toContain('limited to 1.')
|
|
// A trailing noun is exactly what this is here to stop.
|
|
->and($message)->not->toContain('1 staff accounts')
|
|
->and($message)->not->toContain('1 clients');
|
|
}
|
|
}
|
|
});
|