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 $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. | */ /* * Staff endpoints used to be refused on cloud — users.manage was * Community-only, on the reasoning that a managed installation's accounts * arrived from outside. That reversed in 2.2.0: a platform provisions how * many seats exist, the tenant decides who fills them, and the cap is an * environment variable rather than a closed screen. * * The capability itself stays in front of these routes even though it is * currently true in both editions — it is the seam an edition difference * has to travel through, and deleting it would mean re-inventing one later. */ test('every staff endpoint answers on cloud, the same as on community', function (string $method, string $uri) { config(['projectsend.edition' => Edition::Cloud]); // Not asserting the status, which varies by route and payload — only // that none of them is the capability refusal any more. Read as raw // content rather than JSON: a successful delete answers 204 with an // empty body, which json() cannot parse. $response = $this->withToken($this->token)->json($method, $uri); expect((string) $response->getContent())->not->toContain('capability_unavailable'); })->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'], 'two-factor' => ['DELETE', '/api/v1/users/1/two-factor'], ]); /* |-------------------------------------------------------------------------- | 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.'); }); test('self-deactivation is refused however the boolean is written', function (mixed $active) { // A second administrator, so the last-administrator guard is not what // refuses this: with only one, that guard answers first and the refusal // under test here is never reached. User::factory()->create(); $this->withToken($this->token)->patchJson("/api/v1/users/{$this->admin->id}", ['active' => $active]) ->assertStatus(422) ->assertJsonPath('errors.active.0', 'You cannot deactivate your own account.'); expect($this->admin->refresh()->active)->toBeTrue(); })->with([ 'false' => [false], 'zero' => [0], 'the string zero' => ['0'], ]); test('deactivating somebody else still works in every one of those forms', function (mixed $active) { $user = User::factory()->role(SystemRole::Uploader)->create(); $this->withToken($this->token)->patchJson("/api/v1/users/{$user->id}", ['active' => $active])->assertOk(); expect($user->refresh()->active)->toBeFalse(); })->with([ 'false' => [false], 'zero' => [0], 'the string zero' => ['0'], ]); /* |-------------------------------------------------------------------------- | 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'); }); test('a failure while disposing of a deleted account\'s content rolls the deletion back', function () { $user = User::factory()->role(SystemRole::Uploader)->create(); failAccountContentDisposal(); $this->withToken($this->token)->deleteJson("/api/v1/users/{$user->id}", [ 'content_action' => 'reassign', 'reassign_to_id' => $this->admin->id, ])->assertStatus(500); // The soft-delete shares a transaction with the content step, so its // failure leaves the account intact rather than deleted-but-orphaning. expect(User::query()->whereKey($user->id)->exists())->toBeTrue() ->and(ActivityLog::query()->where('action', Action::UserDeleted)->exists())->toBeFalse(); }); /* |-------------------------------------------------------------------------- | 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(); });