Files
projectsend/tests/Feature/Api/ClientsTest.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

289 lines
11 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Clients\ClientCustomFieldType;
use App\Modules\Clients\Models\ClientCustomField;
use App\Modules\Clients\Models\ClientCustomFieldValue;
use App\Modules\Files\Models\File;
use App\Modules\Identity\Permissions\Permission;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake('files');
$this->admin = User::factory()->create();
$this->token = $this->admin->createToken('t', [
Permission::ManageClients->value,
Permission::CreateClients->value,
Permission::EditClients->value,
Permission::DeleteClients->value,
])->plainTextToken;
});
/*
|--------------------------------------------------------------------------
| Privacy
|--------------------------------------------------------------------------
|
| `users` is the most sensitive table here. These assert on the raw
| response body rather than on parsed fields, so a leak through a nested
| relation or a future column is caught too.
|
*/
test('no client response carries credentials', function () {
$client = User::factory()->client()->create();
$bodies = [
$this->withToken($this->token)->getJson('/api/v1/clients')->getContent(),
$this->withToken($this->token)->getJson("/api/v1/clients/{$client->id}")->getContent(),
];
foreach ($bodies as $body) {
foreach (['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'] as $forbidden) {
expect($body)->not->toContain($forbidden);
}
}
});
test('a staff account is not reachable through the clients surface', function () {
$staff = User::factory()->create();
$this->withToken($this->token)->getJson("/api/v1/clients/{$staff->id}")->assertNotFound();
$this->withToken($this->token)->patchJson("/api/v1/clients/{$staff->id}", ['name' => 'x'])->assertNotFound();
$this->withToken($this->token)->deleteJson("/api/v1/clients/{$staff->id}")->assertNotFound();
expect(User::query()->find($staff->id))->not->toBeNull();
});
test('the listing does not hand out every clients custom field data', function () {
$field = ClientCustomField::query()->create([
'name' => 'vat', 'label' => 'VAT number', 'type' => ClientCustomFieldType::Text,
'required' => false, 'sort_order' => 1,
]);
$client = User::factory()->client()->create();
ClientCustomFieldValue::query()->create([
'client_custom_field_id' => $field->id, 'user_id' => $client->id, 'value' => 'SECRET-VAT',
]);
expect($this->withToken($this->token)->getJson('/api/v1/clients')->getContent())
->not->toContain('SECRET-VAT');
// But reading one client does include them — the same data the edit
// screen shows to anyone with edit_clients.
$this->withToken($this->token)->getJson("/api/v1/clients/{$client->id}")
->assertOk()
->assertJsonPath('data.custom_fields.0.value', 'SECRET-VAT');
});
/*
|--------------------------------------------------------------------------
| CRUD
|--------------------------------------------------------------------------
*/
test('a client can be created', function () {
$this->withToken($this->token)->postJson('/api/v1/clients', [
'name' => 'Acme Ltd',
'email' => 'billing@acme.test',
'password' => 'a-sufficiently-long-password',
])->assertStatus(201)->assertJsonPath('data.email', 'billing@acme.test');
$client = User::query()->where('email', 'billing@acme.test')->firstOrFail();
expect($client->isClient())->toBeTrue()
->and($client->active)->toBeTrue()
->and(ActivityLog::query()->where('action', Action::UserCreated)->exists())->toBeTrue();
});
test('a required custom field is enforced on create', function () {
ClientCustomField::query()->create([
'name' => 'vat', 'label' => 'VAT number', 'type' => ClientCustomFieldType::Text,
'required' => true, 'sort_order' => 1,
]);
$this->withToken($this->token)->postJson('/api/v1/clients', [
'name' => 'No VAT',
'email' => 'novat@acme.test',
'password' => 'a-sufficiently-long-password',
])->assertStatus(422);
expect(User::query()->where('email', 'novat@acme.test')->exists())->toBeFalse();
});
test('a weak password is refused', function () {
$this->withToken($this->token)->postJson('/api/v1/clients', [
'name' => 'Weak',
'email' => 'weak@acme.test',
'password' => 'short',
])->assertStatus(422);
});
test('update changes only the fields sent', function () {
$client = User::factory()->client()->create(['name' => 'Before', 'storage_quota_mb' => 500]);
$this->withToken($this->token)->patchJson("/api/v1/clients/{$client->id}", ['name' => 'After'])
->assertOk()
->assertJsonPath('data.name', 'After');
$client->refresh();
expect($client->name)->toBe('After')
// Untouched by a PATCH that did not mention it — unlike the web
// form, which always submits every field.
->and($client->storage_quota_mb)->toBe(500);
});
test('deactivating a client is recorded', function () {
$client = User::factory()->client()->create(['active' => true]);
$this->withToken($this->token)->patchJson("/api/v1/clients/{$client->id}", ['active' => false])->assertOk();
expect($client->refresh()->active)->toBeFalse()
->and(ActivityLog::query()->where('action', Action::UserDeactivated)->exists())->toBeTrue();
});
/*
|--------------------------------------------------------------------------
| Deletion
|--------------------------------------------------------------------------
*/
test('a client with no content deletes without ceremony', function () {
$client = User::factory()->client()->create();
$this->withToken($this->token)->deleteJson("/api/v1/clients/{$client->id}")->assertNoContent();
expect(User::query()->find($client->id))->toBeNull();
});
test('deleting a client that owns files demands an explicit decision', function () {
$client = User::factory()->client()->create();
File::factory()->create(['uploaded_by' => $client->id]);
// No default is possible: one would silently destroy the files, the
// other would silently transfer them to somebody else.
$this->withToken($this->token)->deleteJson("/api/v1/clients/{$client->id}")
->assertStatus(422)
->assertJsonPath('type', 'validation_failed');
expect(User::query()->find($client->id))->not->toBeNull();
});
test('cascade_delete removes the clients content', function () {
$client = User::factory()->client()->create();
$file = File::factory()->create(['uploaded_by' => $client->id]);
$this->withToken($this->token)
->deleteJson("/api/v1/clients/{$client->id}", ['content_action' => 'cascade_delete'])
->assertNoContent();
expect(User::query()->find($client->id))->toBeNull()
->and(File::query()->find($file->id))->toBeNull();
});
test('reassign moves the content to the named account', function () {
$client = User::factory()->client()->create();
$file = File::factory()->create(['uploaded_by' => $client->id]);
$this->withToken($this->token)->deleteJson("/api/v1/clients/{$client->id}", [
'content_action' => 'reassign',
'reassign_to_id' => $this->admin->id,
])->assertNoContent();
expect(File::query()->find($file->id)?->uploaded_by)->toBe($this->admin->id);
});
test('show reports what a delete would have to decide about', function () {
$client = User::factory()->client()->create();
File::factory()->count(2)->create(['uploaded_by' => $client->id]);
$this->withToken($this->token)->getJson("/api/v1/clients/{$client->id}")
->assertOk()
->assertJsonPath('data.content.files', 2);
});
/*
|--------------------------------------------------------------------------
| Permissions
|--------------------------------------------------------------------------
*/
test('each route needs its own permission', function () {
$client = User::factory()->client()->create();
$readOnly = staffWithPermissions([Permission::ManageClients->value]);
$token = $readOnly->createToken('t', [Permission::ManageClients->value])->plainTextToken;
$this->withToken($token)->getJson('/api/v1/clients')->assertOk();
$this->withToken($token)->postJson('/api/v1/clients', [])->assertForbidden();
$this->withToken($token)->patchJson("/api/v1/clients/{$client->id}", [])->assertForbidden();
$this->withToken($token)->deleteJson("/api/v1/clients/{$client->id}")->assertForbidden();
});
test('storage quota reporting distinguishes inherited from unlimited', function () {
$client = User::factory()->client()->create(['storage_quota_mb' => 0]);
$this->withToken($this->token)->getJson("/api/v1/clients/{$client->id}")
->assertOk()
// 0 on the client means "inherit the site default", which is what
// effective_quota_mb resolves — a caller should not have to know
// that rule to display the number correctly.
->assertJsonPath('data.storage.quota_mb', 0)
->assertJsonStructure(['data' => ['storage' => ['effective_quota_mb', 'unlimited', 'used_mb']]]);
});
/*
|--------------------------------------------------------------------------
| Two-factor reset
|--------------------------------------------------------------------------
*/
test('a client\'s second factor can be removed', function () {
$client = User::factory()->client()->create();
enableTwoFactor($client);
forgetRequestState();
$this->withToken($this->token)
->deleteJson("/api/v1/clients/{$client->id}/two-factor")
->assertNoContent();
expect($client->refresh()->hasTwoFactorEnabled())->toBeFalse();
expect(ActivityLog::query()->where('action', Action::TwoFactorReset)->sole()->subject_id)
->toBe($client->id);
});
test('removing a second factor needs edit_clients, not just manage_clients', function () {
$token = $this->admin->createToken('narrow', [Permission::ManageClients->value])->plainTextToken;
$client = User::factory()->client()->create();
enableTwoFactor($client);
forgetRequestState();
$this->withToken($token)
->deleteJson("/api/v1/clients/{$client->id}/two-factor")
->assertForbidden();
expect($client->refresh()->hasTwoFactorEnabled())->toBeTrue();
});
test('a staff account is not addressable through the client two-factor route', function () {
$staff = User::factory()->create();
$this->withToken($this->token)
->deleteJson("/api/v1/clients/{$staff->id}/two-factor")
->assertNotFound();
});
test('the listing reports whether a client has a second factor', function () {
$client = User::factory()->client()->create();
enableTwoFactor($client);
forgetRequestState();
$this->withToken($this->token)
->getJson('/api/v1/clients')
->assertOk()
->assertJsonPath('data.0.two_factor_enabled', true);
});