mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-22 19:43:24 +00:00
ab6e9eecf3
SeatAllowance says a cap is only a cap if every door asks, and has a test
per door for that reason. Two doors do not ask.
The moment a seat is spent is the moment `account_requested` is cleared.
Five places do that. approve(), both store()s and ClientProvisioning ask
guardClient(); AccountConversion asks it through guardToClient(). The two
update()s -- web and API -- clear the flag with no guard at all, under a
comment that names exactly what they are doing:
// Activating a pending account through the edit screen counts as
// approval and clears the request flag.
Measured with clients: 0, one pending registration:
POST /account-requests/{id}/approve refused, flag still set
PATCH /clients/{id} active=true approved, clientUsed() 0 -> 1
PATCH /api/v1/clients/{id} active=true approved, clientUsed() 0 -> 1
A managed installation at its cap therefore keeps taking clients on, from
the edit screen or a PATCH, for as long as registrations keep arriving --
and self-registration is open to strangers, so the supply is not the
operator's to control.
Inside the branch, not above it. Above it, an installation sitting at its
cap could not rename a client it already holds, which would trade one
wrong refusal for another. There is a test pinning that.
The field is `active` rather than the default `email`: on this screen the
administrator is toggling `active`, and an error under the email field
would point at the wrong thing. approve() has no form of its own, so it
keeps the default.
Three tests, per door as the file's other eight are. The two door tests
were measured red against the unguarded controllers (2 failed / 18
passed). The third -- that editing an existing client still works at the
cap -- is green either way: it guards against the fix being written a
line too high, not against the bug.
Full suite passes (2051 passed / 2 skipped), PHPStan level 8 clean.
One thing worth knowing that this branch does not touch: on a parallel
run, `UpdateWelcomeTest > staff who may not read...` fails roughly one run
in six on untouched main, with `BindingResolutionException: Target
[Inertia\Ssr\Gateway] is not instantiable`. Measured over 24 baseline runs
before this change existed. It is not this fix, and it is not in scope
here, but it will start being visible as soon as the workflow parses
again.
315 lines
12 KiB
PHP
315 lines
12 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 Laravel\Sanctum\Sanctum;
|
|
|
|
/**
|
|
* 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: approving a pending client through the edit screen', function () {
|
|
seatLimits(clients: 0);
|
|
|
|
$pending = User::factory()->client()->create(['account_requested' => true, 'active' => false]);
|
|
|
|
$this->actingAs($this->admin)->patch("/clients/{$pending->id}", [
|
|
'name' => $pending->name,
|
|
'email' => $pending->email,
|
|
'active' => true,
|
|
])->assertSessionHasErrors('active');
|
|
|
|
// Nothing is written: the guard throws before save(), so a refused
|
|
// approval does not leave the name or the flag half-applied.
|
|
expect($pending->refresh()->account_requested)->toBeTrue()
|
|
->and($pending->active)->toBeFalse();
|
|
});
|
|
|
|
test('door: approving a pending client through the API', function () {
|
|
seatLimits(clients: 0);
|
|
|
|
$pending = User::factory()->client()->create(['account_requested' => true, 'active' => false]);
|
|
|
|
Sanctum::actingAs($this->admin, ['*']);
|
|
|
|
$this->patchJson("/api/v1/clients/{$pending->id}", ['active' => true])
|
|
->assertStatus(422)
|
|
->assertJsonValidationErrors('active');
|
|
|
|
expect($pending->refresh()->account_requested)->toBeTrue();
|
|
});
|
|
|
|
test('the cap does not block editing a client the installation already holds', function () {
|
|
// The guard sits inside the approval branch. Above it, an installation
|
|
// sitting at its cap could not rename anybody.
|
|
seatLimits(clients: 0);
|
|
|
|
$client = User::factory()->client()->create(['account_requested' => false, 'active' => true]);
|
|
|
|
$this->actingAs($this->admin)->patch("/clients/{$client->id}", [
|
|
'name' => 'Renamed Ltd',
|
|
'email' => $client->email,
|
|
'active' => true,
|
|
])->assertSessionHasNoErrors();
|
|
|
|
expect($client->refresh()->name)->toBe('Renamed Ltd');
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
// --------------------------------------------------------- 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');
|
|
}
|
|
}
|
|
});
|