mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 17:15:08 +00:00
61c385e423
Deleting a staff or client account is two writes: soft-delete the account, then cascade or reassign the files and folders it owns. All four destroy() paths (Users + Clients, web + API) ran them one after the other with nothing tying them together. If the second write throws, the account is already gone but its content is not handled. The concrete way in is the reassign branch: validate() checks reassign_to_id with exists(active), but apply() re-resolves it with findOrFail() a moment later (AccountContentDeletion:108), so a target deactivated or deleted in between throws — leaving a soft-deleted account whose files still point at it, and a UserDeleted log for a deletion that did not finish. Wrap the delete()+apply() pair in a single DB::transaction() in each of the four destroy() methods. validate() and the authorization guards stay outside it: they are read-only and must be able to reject before anything is written. cascadeDelete()/reassignTo() already open their own transaction, which nests as a savepoint under this one, so the account soft-delete, its activity log, and the content work now commit or roll back together. Tests: a DeletedAccountContent double that reports content to handle and then throws while handling it (tests/Helpers.php) drives one test per destroy() endpoint asserting the account survives the failure and no UserDeleted entry is written; each goes red against the un-wrapped controller.
371 lines
15 KiB
PHP
371 lines
15 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\Files\Models\Folder;
|
|
use App\Modules\Identity\Models\Role;
|
|
use App\Modules\Identity\Models\RolePermission;
|
|
use App\Modules\Identity\Permissions\SystemRole;
|
|
use App\Modules\Platform\Capabilities\Edition;
|
|
use Inertia\Testing\AssertableInertia;
|
|
|
|
function admin(): User
|
|
{
|
|
return User::factory()->create();
|
|
}
|
|
|
|
function staffFile(User $uploader): File
|
|
{
|
|
return File::factory()->create([
|
|
'uploaded_by' => $uploader->id,
|
|
'name' => 'doc',
|
|
'original_name' => 'doc.pdf',
|
|
'mime_type' => 'application/pdf',
|
|
'size' => 123,
|
|
]);
|
|
}
|
|
|
|
test('the index lists staff only — clients never appear', function () {
|
|
$adminUser = admin();
|
|
User::factory()->role(SystemRole::Uploader)->create(['name' => 'Staff Member']);
|
|
User::factory()->client()->create(['name' => 'A Client']);
|
|
|
|
$this->actingAs($adminUser)->get('/users')->assertInertia(
|
|
fn (AssertableInertia $page) => $page
|
|
->component('users/index')
|
|
->has('users', 2)
|
|
->where('users.0.role', fn ($role) => $role !== 'Client'),
|
|
);
|
|
});
|
|
|
|
test('the index flags the acting user\'s own row so the UI can hide self-delete', function () {
|
|
$adminUser = admin();
|
|
$other = User::factory()->role(SystemRole::Uploader)->create(['name' => 'Other Staffer']);
|
|
|
|
$this->actingAs($adminUser)->get('/users')->assertInertia(fn (AssertableInertia $page) => $page
|
|
->where('users', fn ($users) => collect($users)->firstWhere('id', $adminUser->id)['is_self'] === true
|
|
&& collect($users)->firstWhere('id', $other->id)['is_self'] === false));
|
|
});
|
|
|
|
test('an administrator can create a staff user with a role', function () {
|
|
$this->actingAs(admin());
|
|
|
|
$staffRole = Role::query()->where('name', 'Account Manager')->sole();
|
|
|
|
$response = $this->post('/users', [
|
|
'name' => 'New Staffer',
|
|
'email' => 'staffer@example.com',
|
|
'role_id' => $staffRole->id,
|
|
'password' => 'super-secret-password',
|
|
'password_confirmation' => 'super-secret-password',
|
|
]);
|
|
|
|
$created = User::query()->where('email', 'staffer@example.com')->sole();
|
|
$response->assertRedirect(route('users.edit', $created));
|
|
expect($created->isStaff())->toBeTrue()
|
|
->and($created->role?->name)->toBe('Account Manager')
|
|
->and(ActivityLog::query()->where('action', Action::UserCreated)->where('subject_name', 'New Staffer')->exists())->toBeTrue();
|
|
});
|
|
|
|
test('the client role cannot be assigned to a staff user', function () {
|
|
$this->actingAs(admin());
|
|
|
|
$clientRole = Role::query()->where('name', 'Client')->sole();
|
|
|
|
$this->post('/users', [
|
|
'name' => 'Sneaky',
|
|
'email' => 'sneaky@example.com',
|
|
'role_id' => $clientRole->id,
|
|
'password' => 'super-secret-password',
|
|
'password_confirmation' => 'super-secret-password',
|
|
])->assertSessionHasErrors('role_id');
|
|
});
|
|
|
|
test('editing updates fields and password only when provided', function () {
|
|
$this->actingAs(admin());
|
|
$user = User::factory()->role(SystemRole::Uploader)->create();
|
|
$originalPassword = $user->password;
|
|
|
|
$managerRole = Role::query()->where('name', 'Account Manager')->sole();
|
|
|
|
$this->patch("/users/{$user->id}", [
|
|
'name' => 'Renamed',
|
|
'email' => $user->email,
|
|
'role_id' => $managerRole->id,
|
|
'active' => true,
|
|
'password' => null,
|
|
])->assertRedirect();
|
|
|
|
$user->refresh();
|
|
expect($user->name)->toBe('Renamed')
|
|
->and($user->role?->name)->toBe('Account Manager')
|
|
->and($user->password)->toBe($originalPassword);
|
|
|
|
$this->patch("/users/{$user->id}", [
|
|
'name' => 'Renamed',
|
|
'email' => $user->email,
|
|
'role_id' => $managerRole->id,
|
|
'active' => true,
|
|
'password' => 'a-changed-password-1',
|
|
'password_confirmation' => 'a-changed-password-1',
|
|
]);
|
|
|
|
expect($user->refresh()->password)->not->toBe($originalPassword);
|
|
});
|
|
|
|
test('client accounts are not reachable through the users screens', function () {
|
|
$this->actingAs(admin());
|
|
$client = User::factory()->client()->create();
|
|
|
|
$this->get("/users/{$client->id}")->assertNotFound();
|
|
$this->delete("/users/{$client->id}")->assertNotFound();
|
|
});
|
|
|
|
test('deactivating a user terminates their session and blocks login', function () {
|
|
$adminUser = admin();
|
|
$user = User::factory()->role(SystemRole::Uploader)->create();
|
|
|
|
// The user has an active session.
|
|
$this->actingAs($user)->get('/dashboard')->assertOk();
|
|
|
|
$this->actingAs($adminUser)->patch("/users/{$user->id}", [
|
|
'name' => $user->name,
|
|
'email' => $user->email,
|
|
'role_id' => $user->role_id,
|
|
'active' => false,
|
|
])->assertRedirect();
|
|
|
|
expect(ActivityLog::query()->where('action', Action::UserDeactivated)->exists())->toBeTrue();
|
|
|
|
// Their next request logs them out.
|
|
$this->actingAs($user->refresh())->get('/dashboard')->assertRedirect(route('login'));
|
|
|
|
// And the login form refuses them with the deactivation notice.
|
|
$this->post('/login', ['email' => $user->email, 'password' => 'password'])
|
|
->assertSessionHasErrors('email');
|
|
$this->assertGuest();
|
|
});
|
|
|
|
test('a wrong password on a deactivated account does not reveal its state', function () {
|
|
admin();
|
|
$user = User::factory()->create(['active' => false]);
|
|
|
|
$response = $this->from('/login')->post('/login', ['email' => $user->email, 'password' => 'wrong']);
|
|
|
|
$response->assertSessionHasErrors('email');
|
|
expect(session('errors')->first('email'))->toBe(__('auth.failed'));
|
|
});
|
|
|
|
test('you cannot deactivate or delete yourself', function () {
|
|
$adminUser = admin();
|
|
// A second admin so the last-administrator guard is not what trips.
|
|
User::factory()->create();
|
|
|
|
$this->actingAs($adminUser);
|
|
|
|
$this->patch("/users/{$adminUser->id}", [
|
|
'name' => $adminUser->name,
|
|
'email' => $adminUser->email,
|
|
'role_id' => $adminUser->role_id,
|
|
'active' => false,
|
|
])->assertSessionHasErrors('active');
|
|
|
|
$this->delete("/users/{$adminUser->id}")->assertSessionHasErrors('user');
|
|
});
|
|
|
|
test('the last active administrator cannot be deleted, demoted, or deactivated by another admin path', function () {
|
|
$adminUser = admin();
|
|
$secondAdmin = User::factory()->create();
|
|
|
|
$this->actingAs($adminUser);
|
|
|
|
// Demoting the other admin is fine while this one remains...
|
|
$staffRole = Role::query()->where('name', 'Account Manager')->sole();
|
|
$this->patch("/users/{$secondAdmin->id}", [
|
|
'name' => $secondAdmin->name,
|
|
'email' => $secondAdmin->email,
|
|
'role_id' => $staffRole->id,
|
|
'active' => true,
|
|
])->assertSessionDoesntHaveErrors();
|
|
|
|
// ...but now this account is the last admin: demoting self is refused.
|
|
$this->patch("/users/{$adminUser->id}", [
|
|
'name' => $adminUser->name,
|
|
'email' => $adminUser->email,
|
|
'role_id' => $staffRole->id,
|
|
'active' => true,
|
|
])->assertSessionHasErrors('role_id');
|
|
});
|
|
|
|
test('the last active administrator cannot demote themselves, and the index/edit pages flag them for the UI', function () {
|
|
$adminUser = admin();
|
|
$staffRole = Role::query()->create(['name' => 'Plain Staff']);
|
|
|
|
$this->actingAs($adminUser);
|
|
|
|
// Sole active admin: flagged everywhere, and the server refuses the
|
|
// demotion. Self-demotion is the live path to this guard — only an
|
|
// administrator may touch an administrator now (see the 403 test
|
|
// below), and any *other* admin actor would themselves be a second
|
|
// active admin, which is exactly the condition that clears it.
|
|
$this->get('/users')->assertInertia(fn (AssertableInertia $page) => $page
|
|
->where('users', fn ($users) => collect($users)->firstWhere('id', $adminUser->id)['is_last_administrator'] === true));
|
|
$this->get("/users/{$adminUser->id}")->assertInertia(fn (AssertableInertia $page) => $page->where('is_last_administrator', true));
|
|
|
|
$this->patch("/users/{$adminUser->id}", [
|
|
'name' => $adminUser->name,
|
|
'email' => $adminUser->email,
|
|
'role_id' => $staffRole->id,
|
|
'active' => true,
|
|
])->assertSessionHasErrors('role_id');
|
|
expect($adminUser->refresh()->role?->is_administrator)->toBeTrue();
|
|
|
|
// A second active admin joins: the flag drops and the demotion lands.
|
|
User::factory()->create();
|
|
$this->get('/users')->assertInertia(fn (AssertableInertia $page) => $page
|
|
->where('users', fn ($users) => collect($users)->firstWhere('id', $adminUser->id)['is_last_administrator'] === false));
|
|
$this->get("/users/{$adminUser->id}")->assertInertia(fn (AssertableInertia $page) => $page->where('is_last_administrator', false));
|
|
|
|
$this->patch("/users/{$adminUser->id}", [
|
|
'name' => $adminUser->name,
|
|
'email' => $adminUser->email,
|
|
'role_id' => $staffRole->id,
|
|
'active' => true,
|
|
])->assertSessionHasNoErrors();
|
|
expect($adminUser->refresh()->role_id)->toBe($staffRole->id);
|
|
});
|
|
|
|
// manage_users is a permission like any other; without this it would be a
|
|
// back door to every other permission, since a role is just a bundle of
|
|
// them and this is the screen that hands roles out.
|
|
test('a non-administrator with manage_users cannot reach, alter, or mint an administrator', function () {
|
|
$adminUser = admin();
|
|
|
|
$role = Role::query()->create(['name' => 'User Manager']);
|
|
foreach (['manage_users', 'create_users', 'edit_users', 'delete_users'] as $permission) {
|
|
RolePermission::query()->create(['role_id' => $role->id, 'permission' => $permission]);
|
|
}
|
|
$manager = User::factory()->create(['role_id' => $role->id]);
|
|
|
|
$this->actingAs($manager);
|
|
|
|
// The administrator's account is out of reach entirely.
|
|
$this->get("/users/{$adminUser->id}")->assertForbidden();
|
|
$this->patch("/users/{$adminUser->id}", [
|
|
'name' => 'Renamed', 'email' => $adminUser->email, 'role_id' => $role->id, 'active' => true,
|
|
])->assertForbidden();
|
|
$this->delete("/users/{$adminUser->id}")->assertForbidden();
|
|
expect($adminUser->refresh()->name)->not->toBe('Renamed');
|
|
|
|
// The administrator role is not offered, and is refused if posted anyway.
|
|
$adminRoleId = Role::query()->where('is_administrator', true)->value('id');
|
|
$this->get('/users/create')->assertInertia(fn (AssertableInertia $page) => $page
|
|
->where('roles', fn ($roles) => ! collect($roles)->pluck('id')->contains($adminRoleId)));
|
|
|
|
$this->post('/users', [
|
|
'name' => 'Backdoor', 'email' => 'backdoor@example.com', 'role_id' => $adminRoleId,
|
|
'password' => 'a-long-enough-password', 'password_confirmation' => 'a-long-enough-password',
|
|
])->assertSessionHasErrors('role_id');
|
|
expect(User::query()->where('email', 'backdoor@example.com')->exists())->toBeFalse();
|
|
|
|
// Nor can they mint a role carrying permissions they do not hold and
|
|
// route around the check that way.
|
|
$this->post('/roles', ['name' => 'Sneaky', 'permissions' => ['edit_settings']])
|
|
->assertSessionHasErrors('permissions');
|
|
expect(Role::query()->where('name', 'Sneaky')->exists())->toBeFalse();
|
|
|
|
// A role within their own authority is still fine.
|
|
$this->post('/roles', ['name' => 'Fine', 'permissions' => ['edit_users']])
|
|
->assertSessionHasNoErrors();
|
|
expect(Role::query()->where('name', 'Fine')->exists())->toBeTrue();
|
|
});
|
|
|
|
test('the index exposes each user\'s content counts and a shared reassignment candidate list', function () {
|
|
$adminUser = admin();
|
|
$user = User::factory()->role(SystemRole::Uploader)->create();
|
|
staffFile($user);
|
|
|
|
$this->actingAs($adminUser)->get('/users')->assertInertia(
|
|
fn (AssertableInertia $page) => $page
|
|
->where('users', fn ($users) => collect($users)->firstWhere('id', $user->id)['content'] === ['files' => 1, 'folders' => 0])
|
|
->has('reassign_candidates')
|
|
->where('reassign_candidates', fn ($candidates) => collect($candidates)->pluck('id')->contains($adminUser->id)),
|
|
);
|
|
});
|
|
|
|
test('deleting a staff user that owns content is refused without a choice', function () {
|
|
$adminUser = admin();
|
|
$user = User::factory()->role(SystemRole::Uploader)->create();
|
|
staffFile($user);
|
|
|
|
$this->actingAs($adminUser)->delete("/users/{$user->id}")->assertSessionHasErrors('content_action');
|
|
|
|
expect(User::query()->find($user->id))->not->toBeNull();
|
|
});
|
|
|
|
test('cascade-deleting a staff user removes their own files and logs a summary', function () {
|
|
$adminUser = admin();
|
|
$user = User::factory()->role(SystemRole::Uploader)->create();
|
|
$file = staffFile($user);
|
|
$folder = Folder::query()->create(['name' => 'Mine', 'created_by' => $user->id]);
|
|
|
|
$this->actingAs($adminUser)->delete("/users/{$user->id}", ['content_action' => 'cascade_delete'])
|
|
->assertRedirect('/users');
|
|
|
|
expect(File::withTrashed()->findOrFail($file->id)->trashed())->toBeTrue()
|
|
->and(Folder::withTrashed()->findOrFail($folder->id)->trashed())->toBeTrue()
|
|
->and(ActivityLog::query()->where('action', Action::AccountContentCascadeDeleted)->exists())->toBeTrue();
|
|
});
|
|
|
|
test('reassigning a deleted staff user\'s content transfers ownership and logs a summary', function () {
|
|
$adminUser = admin();
|
|
$user = User::factory()->role(SystemRole::Uploader)->create();
|
|
$target = User::factory()->role(SystemRole::Uploader)->create();
|
|
$file = staffFile($user);
|
|
|
|
$this->actingAs($adminUser)->delete("/users/{$user->id}", [
|
|
'content_action' => 'reassign',
|
|
'reassign_to_id' => $target->id,
|
|
])->assertRedirect('/users');
|
|
|
|
expect($file->refresh()->uploaded_by)->toBe($target->id)
|
|
->and(ActivityLog::query()->where('action', Action::AccountContentReassigned)->exists())->toBeTrue();
|
|
});
|
|
|
|
test('a failure while disposing of a deleted staff account\'s content rolls the deletion back', function () {
|
|
$adminUser = admin();
|
|
$user = User::factory()->role(SystemRole::Uploader)->create();
|
|
$reassignTarget = User::factory()->role(SystemRole::Uploader)->create();
|
|
|
|
failAccountContentDisposal();
|
|
|
|
$this->actingAs($adminUser)->delete("/users/{$user->id}", [
|
|
'content_action' => 'reassign',
|
|
'reassign_to_id' => $reassignTarget->id,
|
|
])->assertStatus(500);
|
|
|
|
// The soft-delete and its log belong to the same transaction as the
|
|
// content step, so a failure there leaves the account intact rather than
|
|
// deleted-but-still-owning-files.
|
|
expect(User::query()->find($user->id))->not->toBeNull()
|
|
->and(ActivityLog::query()->where('action', Action::UserDeleted)->exists())->toBeFalse();
|
|
});
|
|
|
|
test('users management requires granular permissions', function () {
|
|
// Account Manager lacks manage_users entirely.
|
|
$this->actingAs(User::factory()->role(SystemRole::AccountManager)->create());
|
|
$this->get('/users')->assertForbidden();
|
|
});
|
|
|
|
test('users management is absent in the cloud edition', function () {
|
|
config()->set('projectsend.edition', Edition::Cloud);
|
|
|
|
$this->actingAs(admin());
|
|
|
|
$this->get('/users')->assertNotFound();
|
|
});
|