Files
projectsend/tests/Feature/Api/UsersTest.php
ignacionelson 6e47d76ba6 ProjectSend 2.0.0
Client file sharing, rebuilt from the ground up: a private area per
client, resumable uploads, folders, groups and categories, sharing with
expiry dates and download limits, comments, file versions, an activity
log, a REST API, and sixteen languages.

This repository begins here. ProjectSend 2 was developed privately, and
that development history is not published — the previous generation
remains available, with its own history, at projectsend/legacy.

Free software under the GNU General Public License v2, or (at your
option) any later version.
2026-08-14 01:38:12 -03:00

488 lines
20 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Files\Models\File;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Models\RolePermission;
use App\Modules\Identity\Permissions\EnsureSystemRoles;
use App\Modules\Identity\Permissions\Permission;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Platform\Capabilities\Edition;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake('files');
$this->admin = User::factory()->create();
$this->token = $this->admin->createToken('t', [
Permission::ManageUsers->value,
Permission::CreateUsers->value,
Permission::EditUsers->value,
Permission::DeleteUsers->value,
])->plainTextToken;
});
/**
* Materialised on demand, the same way UserFactory does it: fresh installs
* no longer seed the legacy Uploader role, so looking it up without
* creating it returns null on a database where no test has used it yet.
*/
function roleId(SystemRole $role): int
{
return app(EnsureSystemRoles::class)->materialize($role)->id;
}
/**
* A staff account that genuinely holds the user-management permissions but
* is not an administrator.
*
* Not a shortcut: EnsureTokenCan re-checks the owner's live permissions on
* every request, so handing these abilities to a token owned by, say, a
* Client Manager would be refused by the ability gate long before any
* authority rule was reached — and the test would pass while asserting
* nothing about the rule it names.
*
* @param list<string> $permissions
* @return array{User, string}
*/
function userManager(array $permissions = ['manage_users', 'create_users', 'edit_users', 'delete_users']): array
{
$role = Role::query()->create(['name' => 'User Manager '.uniqid()]);
foreach ($permissions as $permission) {
RolePermission::query()->create(['role_id' => $role->id, 'permission' => $permission]);
}
$manager = User::factory()->create(['role_id' => $role->id]);
return [$manager, $manager->createToken('t', $permissions)->plainTextToken];
}
/*
|--------------------------------------------------------------------------
| Edition
|--------------------------------------------------------------------------
|
| The whole point of the feature: managed installations create staff
| accounts outside the application, so an API able to mint them there
| would be a second, unmanaged door into the same thing.
|
*/
test('every staff endpoint is refused on cloud, with a reason a caller can branch on', function (string $method, string $uri) {
config(['projectsend.edition' => Edition::Cloud]);
$this->withToken($this->token)->json($method, $uri)
->assertForbidden()
->assertHeader('Content-Type', 'application/problem+json')
->assertJsonPath('type', 'capability_unavailable')
->assertJsonPath('capability', 'users.manage')
->assertJsonPath('edition', 'cloud');
})->with([
'list' => ['GET', '/api/v1/users'],
'roles' => ['GET', '/api/v1/roles'],
'read' => ['GET', '/api/v1/users/1'],
'create' => ['POST', '/api/v1/users'],
'update' => ['PATCH', '/api/v1/users/1'],
'delete' => ['DELETE', '/api/v1/users/1'],
'reset two-factor' => ['DELETE', '/api/v1/users/1/two-factor'],
]);
// The routes exist in every edition so the committed OpenAPI document is
// identical everywhere — the middleware refuses, the route table does not
// lie. A 404 here would mean the document described a path that was not
// registered.
test('the routes are registered on cloud even though they refuse', function () {
config(['projectsend.edition' => Edition::Cloud]);
$this->withToken($this->token)->getJson('/api/v1/users')->assertStatus(403);
});
/*
|--------------------------------------------------------------------------
| Privacy
|--------------------------------------------------------------------------
|
| `users` is the most sensitive table here, and these rows carry the
| permissions that administer the installation. Asserted against the raw
| body so a leak through a nested relation or a future column is caught.
|
*/
test('no staff response carries credentials', function () {
$other = User::factory()->role(SystemRole::Uploader)->create();
$bodies = [
$this->withToken($this->token)->getJson('/api/v1/users')->getContent(),
$this->withToken($this->token)->getJson("/api/v1/users/{$other->id}")->getContent(),
$this->withToken($this->token)->postJson('/api/v1/users', [
'name' => 'New Person', 'email' => 'new@example.test',
'role_id' => roleId(SystemRole::Uploader), 'password' => 'correct-horse-battery-staple',
])->getContent(),
];
foreach ($bodies as $body) {
expect($body)->not->toContain('password')
->and($body)->not->toContain('two_factor_secret')
->and($body)->not->toContain('two_factor_recovery_codes')
->and($body)->not->toContain('remember_token');
}
});
/*
|--------------------------------------------------------------------------
| Reading
|--------------------------------------------------------------------------
*/
test('the list carries staff only, never clients', function () {
User::factory()->role(SystemRole::Uploader)->create(['name' => 'A Staffer']);
$client = User::factory()->client()->create(['name' => 'A Client']);
$ids = collect($this->withToken($this->token)->getJson('/api/v1/users')
->assertOk()->json('data'))->pluck('id');
expect($ids)->not->toContain($client->id)
->and($ids)->toContain($this->admin->id);
});
test('a client is not addressable as a staff account even by id', function () {
$client = User::factory()->client()->create();
$this->withToken($this->token)->getJson("/api/v1/users/{$client->id}")->assertNotFound();
$this->withToken($this->token)->patchJson("/api/v1/users/{$client->id}", ['name' => 'x'])->assertNotFound();
$this->withToken($this->token)->deleteJson("/api/v1/users/{$client->id}")->assertNotFound();
});
test('reading one account carries its role, its clients and its content counts', function () {
$user = User::factory()->role(SystemRole::Uploader)->create();
$this->withToken($this->token)->getJson("/api/v1/users/{$user->id}")
->assertOk()
->assertJsonPath('data.role.name', SystemRole::Uploader->value)
->assertJsonPath('data.role.is_administrator', false)
->assertJsonPath('data.content', ['files' => 0, 'folders' => 0])
->assertJsonPath('data.assigned_client_ids', [])
->assertJsonPath('data.two_factor_enabled', false);
});
test('the list filters by search, role and status', function () {
$uploader = User::factory()->role(SystemRole::Uploader)->create(['name' => 'Zoe Zebra', 'active' => false]);
$byName = $this->withToken($this->token)->getJson('/api/v1/users?search=Zebra')->assertOk()->json('data');
expect($byName)->toHaveCount(1)->and($byName[0]['id'])->toBe($uploader->id);
$byRole = $this->withToken($this->token)->getJson('/api/v1/users?role_id='.roleId(SystemRole::Uploader))->assertOk()->json('data');
expect(collect($byRole)->pluck('id')->all())->toBe([$uploader->id]);
$inactive = $this->withToken($this->token)->getJson('/api/v1/users?status=inactive')->assertOk()->json('data');
expect(collect($inactive)->pluck('id')->all())->toBe([$uploader->id]);
});
test('roles lists what may be assigned, and never the client role', function () {
roleId(SystemRole::Uploader);
$names = collect($this->withToken($this->token)->getJson('/api/v1/roles')->assertOk()->json('data'))
->pluck('name');
expect($names)->toContain(SystemRole::Uploader->value)
->and($names)->not->toContain(SystemRole::Client->value);
});
/*
|--------------------------------------------------------------------------
| Writing
|--------------------------------------------------------------------------
*/
test('creating an account makes it active, verified and audited', function () {
$response = $this->withToken($this->token)->postJson('/api/v1/users', [
'name' => 'New Person',
'email' => 'new@example.test',
'role_id' => roleId(SystemRole::Uploader),
'password' => 'correct-horse-battery-staple',
])->assertCreated();
$user = User::query()->where('email', 'new@example.test')->sole();
expect($user->active)->toBeTrue()
->and($user->isStaff())->toBeTrue()
->and($user->email_verified_at)->not->toBeNull()
->and($response->json('data.id'))->toBe($user->id);
expect(ActivityLog::query()->where('action', Action::UserCreated)->where('subject_id', $user->id)->exists())->toBeTrue();
});
test('updating is PATCH — an absent field is left alone', function () {
$user = User::factory()->role(SystemRole::Uploader)->create(['name' => 'Before', 'email' => 'before@example.test']);
$this->withToken($this->token)->patchJson("/api/v1/users/{$user->id}", ['name' => 'After'])
->assertOk()
->assertJsonPath('data.name', 'After');
expect($user->refresh()->email)->toBe('before@example.test');
});
test('the assigned role can be changed, and the change is audited', function () {
$user = User::factory()->role(SystemRole::Uploader)->create();
$managerId = roleId(SystemRole::AccountManager);
$this->withToken($this->token)->patchJson("/api/v1/users/{$user->id}", ['role_id' => $managerId])
->assertOk()
->assertJsonPath('data.role.id', $managerId);
expect($user->refresh()->role_id)->toBe($managerId);
$entry = ActivityLog::query()->where('action', Action::UserUpdated)->where('subject_id', $user->id)->sole();
expect($entry->context['role']['to'])->toBe(SystemRole::AccountManager->value);
});
test('deactivating and reactivating each log their own entry', function () {
$user = User::factory()->role(SystemRole::Uploader)->create();
$this->withToken($this->token)->patchJson("/api/v1/users/{$user->id}", ['active' => false])->assertOk();
$this->withToken($this->token)->patchJson("/api/v1/users/{$user->id}", ['active' => true])->assertOk();
expect(ActivityLog::query()->where('action', Action::UserDeactivated)->where('subject_id', $user->id)->exists())->toBeTrue()
->and(ActivityLog::query()->where('action', Action::UserActivated)->where('subject_id', $user->id)->exists())->toBeTrue();
});
// Only a client-scoped role has a roster; any other role clears it, which
// is the sync's rule rather than something a caller has to remember.
test('assigned clients stick to a client-scoped role and are cleared by any other', function () {
$client = User::factory()->client()->create();
$user = User::factory()->role(SystemRole::ClientManager)->create();
$this->withToken($this->token)->patchJson("/api/v1/users/{$user->id}", [
'assigned_clients' => [$client->id],
])->assertOk()->assertJsonPath('data.assigned_client_ids', [$client->id]);
$this->withToken($this->token)->patchJson("/api/v1/users/{$user->id}", [
'role_id' => roleId(SystemRole::Uploader),
])->assertOk()->assertJsonPath('data.assigned_client_ids', []);
});
/*
|--------------------------------------------------------------------------
| Authority
|--------------------------------------------------------------------------
|
| The invariants StaffAccounts exists to hold in one place. If the API
| ever stops calling it, these are what notice.
|
*/
test('a caller cannot grant a role carrying permissions they do not hold', function () {
[, $token] = userManager();
$administratorId = Role::query()->where('is_administrator', true)->value('id');
$this->withToken($token)->postJson('/api/v1/users', [
'name' => 'Escalation',
'email' => 'escalation@example.test',
'role_id' => $administratorId,
'password' => 'correct-horse-battery-staple',
])->assertStatus(422)->assertJsonPath('type', 'validation_failed');
expect(User::query()->where('email', 'escalation@example.test')->exists())->toBeFalse();
});
test('a caller cannot touch an account whose role outranks them', function () {
[, $token] = userManager();
$this->withToken($token)->getJson("/api/v1/users/{$this->admin->id}")->assertForbidden();
$this->withToken($token)->patchJson("/api/v1/users/{$this->admin->id}", ['name' => 'x'])->assertForbidden();
$this->withToken($token)->deleteJson("/api/v1/users/{$this->admin->id}")->assertForbidden();
expect($this->admin->refresh()->name)->not->toBe('x');
});
// Only the administrator themselves can attempt this: anybody else is
// stopped by guardTarget first, since they could not grant that role.
test('the last active administrator cannot demote themselves', function () {
$this->withToken($this->token)->patchJson("/api/v1/users/{$this->admin->id}", [
'role_id' => roleId(SystemRole::Uploader),
])
->assertStatus(422)
->assertJsonPath('errors.role_id.0', 'This is the last active administrator account.');
expect($this->admin->refresh()->role?->is_administrator)->toBeTrue();
});
test('deleting an administrator is refused while they are the last active one', function () {
$second = User::factory()->create();
[, $token] = userManager(['manage_users', 'delete_users']);
// A User Manager cannot delete an administrator at all, so the actor
// here has to be the other administrator.
$this->withToken($token)->deleteJson("/api/v1/users/{$second->id}")->assertForbidden();
// The auth guard caches whoever it resolved first, so a second actor in
// the same test would otherwise be ignored — see forgetRequestState().
forgetRequestState();
// With two administrators, one may go.
$this->withToken($this->token)->deleteJson("/api/v1/users/{$second->id}")->assertNoContent();
// Now the survivor is the last one, and nothing can remove them: their
// own request is refused as a self-delete, and no lesser account may
// reach them.
$this->withToken($this->token)->deleteJson("/api/v1/users/{$this->admin->id}")->assertStatus(422);
expect(User::query()->whereKey($this->admin->id)->exists())->toBeTrue();
});
test('a caller cannot deactivate or delete their own account', function () {
// A second administrator, so the last-administrator guard is not what
// refuses these.
User::factory()->create();
$this->withToken($this->token)->patchJson("/api/v1/users/{$this->admin->id}", ['active' => false])
->assertStatus(422)
->assertJsonPath('errors.active.0', 'You cannot deactivate your own account.');
$this->withToken($this->token)->deleteJson("/api/v1/users/{$this->admin->id}")
->assertStatus(422)
->assertJsonPath('errors.user.0', 'You cannot delete your own account.');
});
/*
|--------------------------------------------------------------------------
| Deletion and its content
|--------------------------------------------------------------------------
*/
test('deleting an account that owns files demands a decision about them', function () {
$user = User::factory()->role(SystemRole::Uploader)->create();
File::factory()->create([
'uploaded_by' => $user->id, 'name' => 'doc', 'original_name' => 'doc.pdf',
'mime_type' => 'application/pdf', 'size' => 10,
]);
$this->withToken($this->token)->deleteJson("/api/v1/users/{$user->id}")
->assertStatus(422)
->assertJsonPath('type', 'validation_failed');
expect(User::query()->whereKey($user->id)->exists())->toBeTrue();
$this->withToken($this->token)->deleteJson("/api/v1/users/{$user->id}", [
'content_action' => 'reassign',
'reassign_to_id' => $this->admin->id,
])->assertNoContent();
expect(User::query()->whereKey($user->id)->exists())->toBeFalse()
->and(File::query()->where('uploaded_by', $this->admin->id)->exists())->toBeTrue();
});
test('deleting an account with no content needs no body, and is audited', function () {
$user = User::factory()->role(SystemRole::Uploader)->create(['name' => 'Departing']);
$this->withToken($this->token)->deleteJson("/api/v1/users/{$user->id}")->assertNoContent();
expect(User::query()->whereKey($user->id)->exists())->toBeFalse();
$entry = ActivityLog::query()->where('action', Action::UserDeleted)->latest('id')->sole();
expect($entry->context['name'])->toBe('Departing');
});
/*
|--------------------------------------------------------------------------
| Token abilities
|--------------------------------------------------------------------------
*/
test('each endpoint needs its own ability on top of manage_users', function () {
$readOnly = $this->admin->createToken('read', [
Permission::ManageUsers->value,
])->plainTextToken;
$user = User::factory()->role(SystemRole::Uploader)->create();
// manage_users alone reaches the listing and the roles list...
$this->withToken($readOnly)->getJson('/api/v1/users')->assertOk();
$this->withToken($readOnly)->getJson('/api/v1/roles')->assertOk();
// ...and nothing else.
$this->withToken($readOnly)->getJson("/api/v1/users/{$user->id}")->assertForbidden();
$this->withToken($readOnly)->postJson('/api/v1/users', [])->assertForbidden();
$this->withToken($readOnly)->patchJson("/api/v1/users/{$user->id}", [])->assertForbidden();
$this->withToken($readOnly)->deleteJson("/api/v1/users/{$user->id}")->assertForbidden();
});
test('a token without manage_users reaches none of it, whatever else it holds', function () {
$token = $this->admin->createToken('t', [
Permission::CreateUsers->value,
Permission::EditUsers->value,
Permission::DeleteUsers->value,
])->plainTextToken;
$this->withToken($token)->getJson('/api/v1/users')->assertForbidden();
$this->withToken($token)->getJson('/api/v1/roles')->assertForbidden();
$this->withToken($token)->postJson('/api/v1/users', [])->assertForbidden();
});
/*
|--------------------------------------------------------------------------
| Two-factor reset
|--------------------------------------------------------------------------
|
| The API twin of the button on /users/{user}: an account whose
| authenticator app and recovery codes are both gone cannot be opened by
| anybody, including the caller.
|
*/
test('a staff account\'s second factor can be removed', function () {
$locked = User::factory()->role(SystemRole::Uploader)->create();
enableTwoFactor($locked);
forgetRequestState();
$this->withToken($this->token)
->deleteJson("/api/v1/users/{$locked->id}/two-factor")
->assertNoContent();
expect($locked->refresh()->hasTwoFactorEnabled())->toBeFalse()
->and($locked->two_factor_secret)->toBeNull();
$entry = ActivityLog::query()->where('action', Action::TwoFactorReset)->sole();
expect($entry->actor_id)->toBe($this->admin->id)
->and($entry->subject_id)->toBe($locked->id);
});
test('removing a second factor needs edit_users, not just manage_users', function () {
[, $token] = userManager(['manage_users']);
$locked = User::factory()->role(SystemRole::Uploader)->create();
enableTwoFactor($locked);
forgetRequestState();
$this->withToken($token)
->deleteJson("/api/v1/users/{$locked->id}/two-factor")
->assertForbidden();
expect($locked->refresh()->hasTwoFactorEnabled())->toBeTrue();
});
test('a non-administrator cannot strip an administrator\'s second factor', function () {
[, $token] = userManager(['manage_users', 'edit_users']);
$admin = User::factory()->create();
enableTwoFactor($admin);
forgetRequestState();
$this->withToken($token)
->deleteJson("/api/v1/users/{$admin->id}/two-factor")
->assertForbidden();
expect($admin->refresh()->hasTwoFactorEnabled())->toBeTrue();
});
test('a client is not addressable through the staff two-factor route', function () {
$client = User::factory()->client()->create();
$this->withToken($this->token)
->deleteJson("/api/v1/users/{$client->id}/two-factor")
->assertNotFound();
});