mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-25 04:42:01 +00:00
463e86f82b
Opening user management on cloud (623ad68) left a managed tenant able to
create staff accounts without limit. This is the other half, and the two
belong in the same release.
max_clients and max_staff_users are numbers the platform sells and does
not enforce — grep finds them only being passed to screens. The
application is the only process that can count against them, so it
accepts the number from the environment and refuses to exceed it. That is
not the same as inventing a plan tier, which is what config/api.php
declines to do when it will not key a rate limit off billing: nothing
here knows what a plan is.
## One definition
staffUsed() and clientUsed() are public and are what the guards read. A
control plane showing "2 of 3 used" from its own query, beside an
application refusing the fourth from a different one, disagrees
eventually — over an inactive account, or a deleted one — and the
disagreement reads as a billing fault rather than a counting one.
## What counts, and the consequences somebody has to explain
An inactive staff account occupies its seat. Excluding it would make
deactivation a way around the cap rather than a way to revoke access,
since reactivating is one click. The cost is an awkward incentive —
deactivating is the safe removal and keeps paying, deleting frees the
seat and asks what happens to the files — and it is better explained than
hidden.
A client awaiting approval does not. Self-registration is open to
strangers, and counting a pending request would let anybody exhaust a
paid limit from the outside, turning a pricing tier into an availability
control. The seat is spent at approval, which is where the guard sits.
A soft-deleted account frees its seat, though not its address —
AvailableEmailRule holds that until erasure. So a seat can be free while
re-adding the same person is still refused, which is the address rule
rather than this one.
## Eight doors, eight tests
There is no single User::create() to guard. StaffAccounts::create()
covers both staff controllers, but a promotion takes a staff seat without
creating anything, a demotion takes a client seat, ClientProvisioning
serves registration and LDAP and social sign-in alike, and approval turns
an uncounted request into a counted client.
A cap is only a cap if every door asks, so there is a test per door and
each was verified to fail without its guard — eight red, with the two
"must not change" cases green either way. DownloadAllowance's shape for
DownloadAllowance's reason: the failure mode is one of them quietly not
asking, invisible from everywhere except the door that forgot.
projectsend:admin is deliberately uncapped and has a test saying so. It
is the recovery path, and anyone who can run it can also edit the
environment the cap comes from.
237 lines
8.5 KiB
PHP
237 lines
8.5 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;
|
|
|
|
/**
|
|
* 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();
|
|
});
|
|
|
|
// --------------------------------------------------------- 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();
|
|
});
|