mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-20 18:43:20 +00:00
616a355d54
A client who may create folders creates them at the top of the library, beside the ones staff made, and their uploads land at the root too. An administrator opening /files gets one flat pile with nothing saying which parts belong to whom. With the new "Give each client a folder of their own" setting, every new client gets a folder named after them and it acts as their root: what they upload and any folder they create goes inside it. /files becomes a list of clients rather than a pile. The sentence this feature has to keep true: **the home is a default location, not a boundary.** Folder::scopeVisibleToClient is untouched, so a folder staff shared with a client still reaches them and sits beside their own. Making the home a jail would have silently revoked every share that already exists -- a data-access change wearing the clothes of a tidying-up feature. There is a test named after that rule. What the client sees is the *inside* of their folder, not a folder wearing their own name, which is not information to them. The breadcrumb is trimmed of it for the same reason: "Invoices", not "Acme Ltd / Invoices". Some decisions worth naming: - **A column, not a convention.** `folders.home_for_user_id`, unique. Matching on the name breaks the moment two clients share one, and `created_by` plus a null parent catches every root folder a client ever made themselves. The question is asked on each upload and each portal listing and the answer has to be exact. - **created_by is the client**, because that is how scopeVisibleToClient already grants somebody their own folder -- no assignment row to keep in step with it. That is also why this writes the row rather than calling FolderService::create(), which takes created_by from auth()->id(). - **On model events**, not in the services that make and rename clients. There are nine of those (ClientAccounts, ClientProvisioning, the profile screen, two update endpoints, AccountConversion, invitations, LDAP, social) and a rule repeated in nine places is missing from the tenth. - **Turning the setting on creates nothing.** Existing clients get a folder when an administrator presses a button that says how many are waiting, and it reports created/total/already-had afterwards. Somebody should be able to switch this on, look, and switch it off without having reorganised a library. It moves no files either. - **Nobody deletes a home from a folder screen**, staff included, and the client cannot rename theirs -- they own it, so ownership alone would have let them, and its name follows the account anyway. - **The name always follows the client**, over a hand-typed one. A folder still called "Acme Ltd" under an account now called something else misleads the administrator the feature exists for. Verified in a real browser as well as in tests: the screen mounts, the panel reads "24 of your existing clients have no folder yet", and pressing the button answers "24 of 24 clients got a folder. 0 already had one."
184 lines
7.2 KiB
PHP
184 lines
7.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Audit\Action;
|
|
use App\Modules\Audit\ActivityLog;
|
|
use App\Modules\Identity\Models\Role;
|
|
use App\Modules\Identity\Models\RolePermission;
|
|
use App\Modules\Identity\Permissions\Permission;
|
|
use App\Modules\Identity\Permissions\SystemRole;
|
|
use App\Modules\Identity\UserType;
|
|
use App\Modules\Platform\Capabilities\Edition;
|
|
use Inertia\Testing\AssertableInertia;
|
|
|
|
beforeEach(function () {
|
|
$this->admin = User::factory()->create();
|
|
});
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| A scoped creator keeps what they create
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
test('a client-scoped creator can open the client they just made', function () {
|
|
// guardTarget() answers 404 for anything off the roster, and
|
|
// StaffLibraryScope::clients() leaves it out of the list -- so without
|
|
// the roster entry the record exists, is welcomed by email, and is
|
|
// invisible to the person who created it.
|
|
$role = Role::query()->create(['name' => 'Scoped creator', 'client_scoped' => true]);
|
|
RolePermission::query()->insert([
|
|
['role_id' => $role->id, 'permission' => Permission::ManageClients->value],
|
|
['role_id' => $role->id, 'permission' => Permission::CreateClients->value],
|
|
['role_id' => $role->id, 'permission' => Permission::EditClients->value],
|
|
]);
|
|
$creator = User::factory()->create(['role_id' => $role->id]);
|
|
|
|
$this->actingAs($creator)->post('/clients', [
|
|
'name' => 'Brand New',
|
|
'email' => 'brand-new@example.com',
|
|
'password' => 'super-secret-password',
|
|
'password_confirmation' => 'super-secret-password',
|
|
])->assertRedirect();
|
|
|
|
$client = User::query()->where('email', 'brand-new@example.com')->sole();
|
|
|
|
$this->actingAs($creator)->get("/clients/{$client->id}")->assertOk();
|
|
|
|
$props = $this->actingAs($creator)->get('/clients')->assertOk()->viewData('page')['props'];
|
|
expect(collect($props['clients'])->pluck('name')->all())->toContain('Brand New');
|
|
});
|
|
|
|
test('an unscoped creator gains no roster entry from creating a client', function () {
|
|
// Nothing to add to: an unscoped staff member sees every client
|
|
// already, and a roster entry would change what assignedClients means
|
|
// for them.
|
|
$this->actingAs($this->admin)->post('/clients', [
|
|
'name' => 'Also New',
|
|
'email' => 'also-new@example.com',
|
|
'password' => 'super-secret-password',
|
|
'password_confirmation' => 'super-secret-password',
|
|
])->assertRedirect();
|
|
|
|
expect($this->admin->refresh()->assignedClients()->count())->toBe(0);
|
|
});
|
|
|
|
test('the index lists clients only — staff never appear', function () {
|
|
User::factory()->client()->create(['name' => 'A Client']);
|
|
User::factory()->role(SystemRole::Uploader)->create(['name' => 'A Staffer']);
|
|
|
|
$this->actingAs($this->admin)->get('/clients')->assertInertia(
|
|
fn (AssertableInertia $page) => $page
|
|
->component('clients/index')
|
|
->has('clients', 1)
|
|
->where('clients.0.name', 'A Client'),
|
|
);
|
|
});
|
|
|
|
test('a staff-created client is active with the Client role', function () {
|
|
$response = $this->actingAs($this->admin)->post('/clients', [
|
|
'name' => 'Handmade Client',
|
|
'email' => 'made@example.com',
|
|
'password' => 'super-secret-password',
|
|
'password_confirmation' => 'super-secret-password',
|
|
]);
|
|
|
|
$client = User::query()->where('email', 'made@example.com')->sole();
|
|
$response->assertRedirect(route('clients.edit', $client));
|
|
expect($client->type)->toBe(UserType::Client)
|
|
->and($client->active)->toBeTrue()
|
|
->and($client->role?->name)->toBe('Client');
|
|
});
|
|
|
|
test('staff accounts are unreachable through client screens', function () {
|
|
$staffer = User::factory()->role(SystemRole::Uploader)->create();
|
|
|
|
$this->actingAs($this->admin);
|
|
$this->get("/clients/{$staffer->id}")->assertNotFound();
|
|
$this->patch("/clients/{$staffer->id}", [])->assertNotFound();
|
|
$this->delete("/clients/{$staffer->id}")->assertNotFound();
|
|
});
|
|
|
|
test('activating a pending client through the edit screen clears the request flag', function () {
|
|
$pending = User::factory()->pendingClient()->create();
|
|
|
|
$this->actingAs($this->admin)->patch("/clients/{$pending->id}", [
|
|
'name' => $pending->name,
|
|
'email' => $pending->email,
|
|
'active' => true,
|
|
])->assertRedirect();
|
|
|
|
$pending->refresh();
|
|
expect($pending->active)->toBeTrue()
|
|
->and($pending->account_requested)->toBeFalse();
|
|
});
|
|
|
|
test('an account manager can manage clients but an uploader cannot', function () {
|
|
// Account Manager holds create/edit/delete_clients + approve_account_requests.
|
|
$manager = User::factory()->role(SystemRole::AccountManager)->create();
|
|
$this->actingAs($manager);
|
|
$this->get('/account-requests')->assertOk();
|
|
|
|
// manage_clients is NOT in the Account Manager default set (v1 parity):
|
|
// the list needs it, so the index is refused while the queue works.
|
|
$this->get('/clients')->assertForbidden();
|
|
|
|
$uploader = User::factory()->role(SystemRole::Uploader)->create();
|
|
$this->actingAs($uploader);
|
|
$this->get('/clients')->assertForbidden();
|
|
$this->get('/account-requests')->assertForbidden();
|
|
});
|
|
|
|
test('client management exists in the cloud edition too', function () {
|
|
config()->set('projectsend.edition', Edition::Cloud);
|
|
|
|
$this->actingAs($this->admin)->get('/clients')->assertOk();
|
|
$this->actingAs($this->admin)->get('/account-requests')->assertOk();
|
|
});
|
|
|
|
test('staff can update client settings and they take effect', function () {
|
|
$this->actingAs($this->admin);
|
|
|
|
$this->get('/system/settings/clients')->assertInertia(
|
|
fn (AssertableInertia $page) => $page
|
|
->component('system/settings/clients')
|
|
->where('clients_can_register', false),
|
|
);
|
|
|
|
$this->patch('/system/settings/clients', [
|
|
'clients_can_register' => true,
|
|
'clients_auto_approve' => false,
|
|
'clients_auto_group' => 0,
|
|
'clients_can_select_group' => 'none',
|
|
'clients_membership_deny_cooldown_days' => 30,
|
|
'client_invitation_expiry_hours' => 72,
|
|
'default_client_storage_quota_mb' => 0,
|
|
'clients_can_preview_files' => true,
|
|
'clients_home_folders' => false,
|
|
])->assertRedirect()->assertSessionDoesntHaveErrors();
|
|
|
|
Auth::logout();
|
|
$this->flushSession();
|
|
$this->get('/register')->assertOk();
|
|
});
|
|
|
|
test('a failure while disposing of a deleted client\'s content rolls the deletion back', function () {
|
|
$client = User::factory()->client()->create();
|
|
$reassignTarget = User::factory()->create();
|
|
|
|
failAccountContentDisposal();
|
|
|
|
$this->actingAs($this->admin)->delete("/clients/{$client->id}", [
|
|
'content_action' => 'reassign',
|
|
'reassign_to_id' => $reassignTarget->id,
|
|
])->assertStatus(500);
|
|
|
|
// The soft-delete and its log share a transaction with the content step,
|
|
// so a failure there leaves the client intact rather than
|
|
// deleted-but-still-owning-files.
|
|
expect(User::query()->find($client->id))->not->toBeNull()
|
|
->and(ActivityLog::query()->where('action', Action::UserDeleted)->exists())->toBeFalse();
|
|
});
|