Files
projectsend/tests/Feature/Clients/ClientsManagementTest.php
T
ignacionelson 616a355d54 Give each client a folder of their own, standing in for the root
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."
2026-09-17 00:22:29 -03:00

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();
});