mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-16 16:45:07 +00:00
623ad686da
A managed installation's staff accounts were expected to arrive from outside it, so users.manage was Community-only and /users, /roles and their API twins answered 404 there. The platform side spent a long document designing its way around that gate; opening it is cheaper than routing around it, and more honest about where the knowledge sits. The division that settles it is the one managed storage already uses. We do not manage a tenant's files from outside — a bucket is provisioned, a scoped credential handed over, and what goes in it is the tenant's business. Seats are the same kind of thing. A platform knows how many staff accounts it sold; it does not know whether Alice should be an Account Manager, and it certainly does not know where her files go when she leaves. Capacity is the platform's, occupancy is the tenant's, and the cap belongs in an environment variable rather than in a closed screen. The capability stays in front of the routes rather than being deleted. It is currently true in both editions, but it is the seam an edition difference has to travel through, and removing it would mean inventing one again later. Seven test files asserted the old rule, which is the tests doing their job. Most flip. Two needed a different example instead: EnsureCapability and AbilityCapability were both using users.manage to stand for "Community-only", so they now use storage.configure and manage_updates — keys that still are. Two rationales half-expired and say so rather than being quietly rewritten. CommentAuthors gave two reasons for being a setting rather than a permission; the first was that roles are uneditable on cloud, which stopped being true here, and the second — that `Everyone` includes anonymous visitors, who have no role to hold a key — was always the stronger and is now the whole of it. The seat cap this makes necessary is the next commit, not this one. On its own this change lets a managed tenant create staff accounts without limit, which is why the two belong in the same release.
224 lines
8.7 KiB
PHP
224 lines
8.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Api\Auth\TokenAbilities;
|
|
use App\Modules\Identity\Permissions\Permission;
|
|
use App\Modules\Platform\Capabilities\Capability;
|
|
use App\Modules\Platform\Capabilities\Edition;
|
|
use Illuminate\Support\Facades\Route;
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Abilities are gated by capability as well as permission
|
|
|--------------------------------------------------------------------------
|
|
|
|
|
| Two independent gates: a permission says what a role may do, a capability
|
|
| says what the edition has at all. Every other surface applies both
|
|
| (routes/web.php pairs `capability:users.manage` with `can:manage_users`;
|
|
| the sidebar pairs them again). Token issuance must too, or a cloud install
|
|
| offers abilities for features it does not have.
|
|
|
|
|
| The suite runs as community by default (phpunit.xml), which is why the
|
|
| original permission-only implementation looked correct here while the
|
|
| cloud dev install visibly offered custom-asset and user-management
|
|
| abilities that could never work. These flip the edition explicitly.
|
|
|
|
|
*/
|
|
|
|
beforeEach(function () {
|
|
$this->admin = User::factory()->create();
|
|
confirmPassword($this->admin);
|
|
});
|
|
|
|
function asCloud(): void
|
|
{
|
|
// CapabilityRegistry is bound (not a singleton) precisely so this works.
|
|
config(['projectsend.edition' => Edition::Cloud]);
|
|
}
|
|
|
|
test('the capability pairing covers exactly the edition-gated permissions', function () {
|
|
$paired = collect(Permission::cases())
|
|
->filter(fn (Permission $p): bool => $p->capability() !== null)
|
|
->map(fn (Permission $p): string => $p->value)
|
|
->values()
|
|
->all();
|
|
|
|
expect($paired)->toBe([
|
|
'create_users',
|
|
'edit_users',
|
|
'delete_users',
|
|
'manage_users',
|
|
'manage_updates',
|
|
'create_assets',
|
|
'edit_assets',
|
|
'delete_assets',
|
|
]);
|
|
});
|
|
|
|
/*
|
|
* The capability half is asserted against isAvailable() rather than the
|
|
* rendered checkbox list: the "is it implemented" filter also applies to
|
|
* the list, and none of the capability-gated permissions has an endpoint
|
|
* yet, so the UI cannot currently distinguish "absent in this edition"
|
|
* from "not built yet". This targets the edition question on its own.
|
|
*/
|
|
test('capability-gated abilities are unavailable in an edition that lacks them', function () {
|
|
asCloud();
|
|
$abilities = app(TokenAbilities::class);
|
|
|
|
// manage_users and create_users used to be in this list. They stopped
|
|
// being edition-gated in 2.2.0 — a platform caps how many seats exist
|
|
// rather than closing the screen — so they would now assert the
|
|
// opposite of what this test is about.
|
|
foreach (['manage_updates', 'create_assets', 'delete_assets'] as $key) {
|
|
expect($abilities->isAvailable($key))->toBeFalse();
|
|
}
|
|
|
|
// And the pair that moved: available in both editions now, which is
|
|
// worth pinning here rather than only in CapabilityRegistryTest,
|
|
// because this is the surface that would silently stop offering them.
|
|
expect($abilities->isAvailable('manage_users'))->toBeTrue()
|
|
->and($abilities->isAvailable('create_users'))->toBeTrue();
|
|
|
|
// Ungated abilities are unaffected by the edition.
|
|
expect($abilities->isAvailable('upload'))->toBeTrue()
|
|
->and($abilities->isAvailable('edit_settings'))->toBeTrue();
|
|
});
|
|
|
|
test('the same abilities are available in the edition that has them', function () {
|
|
$abilities = app(TokenAbilities::class); // community, per phpunit.xml
|
|
|
|
foreach (['manage_users', 'create_users', 'manage_updates', 'create_assets', 'delete_assets'] as $key) {
|
|
expect($abilities->isAvailable($key))->toBeTrue();
|
|
}
|
|
});
|
|
|
|
test('the rendered checkbox list is never empty of implemented abilities', function () {
|
|
$offered = $this->actingAs($this->admin)->get('/settings/api-tokens/create')
|
|
->assertOk()
|
|
->viewData('page')['props']['available_abilities'];
|
|
|
|
$keys = collect($offered)->flatMap(fn (array $group) => array_column($group['abilities'], 'key'))->all();
|
|
|
|
// `upload` is read by the files endpoints and is edition-agnostic, so
|
|
// it is offered wherever this suite runs.
|
|
expect($keys)->toContain('upload');
|
|
});
|
|
|
|
test('issuance rejects a capability-unavailable ability even if the role grants it', function () {
|
|
asCloud();
|
|
|
|
// The role still grants manage_updates — an administrator holds every
|
|
// permission — so only the capability half can refuse this. Was
|
|
// manage_users until 2.2.0 opened that one on both editions.
|
|
$this->actingAs($this->admin)->post('/settings/api-tokens', [
|
|
'name' => 'Cloud update management',
|
|
'abilities' => [Permission::ManageUpdates->value],
|
|
'expires_in_days' => 30,
|
|
])->assertSessionHasErrors('abilities.0');
|
|
|
|
expect($this->admin->tokens()->count())->toBe(0);
|
|
});
|
|
|
|
test('a token minted under one edition stops working under another', function () {
|
|
Route::middleware(['auth:sanctum', 'api-active', 'staff-token', 'token-can:manage_updates'])
|
|
->get('api/v1/_test/updates', fn () => response()->json(['ok' => true]));
|
|
|
|
// Minted on community, where the capability exists.
|
|
$token = $this->admin->createToken('t', [Permission::ManageUpdates->value])->plainTextToken;
|
|
$this->withToken($token)->getJson('/api/v1/_test/updates')->assertOk();
|
|
|
|
asCloud();
|
|
forgetRequestState();
|
|
|
|
$this->withToken($token)->getJson('/api/v1/_test/updates')->assertForbidden();
|
|
});
|
|
|
|
test('me reports only abilities the edition can honour', function () {
|
|
asCloud();
|
|
|
|
$token = $this->admin->createToken('t', [
|
|
Permission::Upload->value,
|
|
Permission::ManageUpdates->value,
|
|
])->plainTextToken;
|
|
|
|
$this->withToken($token)->getJson('/api/v1/me')
|
|
->assertOk()
|
|
->assertJsonPath('data.abilities', [Permission::Upload->value]);
|
|
});
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Only abilities an endpoint actually consumes are offered
|
|
|--------------------------------------------------------------------------
|
|
|
|
|
| The API covers a fraction of the web UI. Offering a checkbox for an
|
|
| ability no endpoint reads invites granting something that silently does
|
|
| nothing, and quietly widens a credential for no benefit.
|
|
|
|
|
| These assert the mechanism rather than a snapshot of today's endpoints,
|
|
| so they keep their value as each phase lands instead of needing an edit
|
|
| every time the surface grows.
|
|
|
|
|
*/
|
|
|
|
test('every offered ability is required by at least one API route', function () {
|
|
$abilities = app(TokenAbilities::class);
|
|
|
|
$offered = collect($abilities->casesFor($this->admin))
|
|
->map(fn (Permission $p): string => $p->value)
|
|
->all();
|
|
|
|
expect($offered)->not->toBeEmpty();
|
|
|
|
foreach ($offered as $key) {
|
|
expect($abilities->inUse())->toContain($key);
|
|
}
|
|
});
|
|
|
|
test('a permission with no endpoint behind it is not offered', function () {
|
|
$abilities = app(TokenAbilities::class);
|
|
|
|
// Whatever the API implements, it does not implement every permission —
|
|
// pick one that no route consumes and assert it stays out.
|
|
$unimplemented = collect(Permission::cases())
|
|
->map(fn (Permission $p): string => $p->value)
|
|
->reject(fn (string $key): bool => in_array($key, $abilities->inUse(), true))
|
|
->first();
|
|
|
|
expect($unimplemented)->not->toBeNull()
|
|
->and($abilities->availableFor($this->admin))->not->toContain($unimplemented);
|
|
|
|
$this->actingAs($this->admin)->post('/settings/api-tokens', [
|
|
'name' => 'Reaching for an unbuilt feature',
|
|
'abilities' => [$unimplemented],
|
|
'expires_in_days' => 30,
|
|
])->assertSessionHasErrors('abilities.0');
|
|
});
|
|
|
|
test('registering an endpoint makes its abilities selectable', function () {
|
|
$abilities = app(TokenAbilities::class);
|
|
$unimplemented = collect(Permission::cases())
|
|
->map(fn (Permission $p): string => $p->value)
|
|
->reject(fn (string $key): bool => in_array($key, $abilities->inUse(), true))
|
|
->reject(fn (string $key): bool => ! $abilities->isAvailable($key))
|
|
->first();
|
|
|
|
Route::middleware(['auth:sanctum', 'api-active', 'staff-token', "token-can:{$unimplemented}"])
|
|
->get('api/v1/_test/new-feature', fn () => response()->json(['ok' => true]));
|
|
|
|
// A fresh instance, since the route scan is memoised per instance.
|
|
expect(app()->make(TokenAbilities::class)->availableFor($this->admin))->toContain($unimplemented);
|
|
});
|
|
|
|
test('an unknown ability string is never available', function () {
|
|
$abilities = app(TokenAbilities::class);
|
|
|
|
expect($abilities->isAvailable('not_a_permission'))->toBeFalse()
|
|
// A capability key is not an ability key — the two vocabularies
|
|
// must never be interchangeable.
|
|
->and($abilities->isAvailable(Capability::Branding->value))->toBeFalse();
|
|
});
|