mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 00:55:07 +00:00
6e47d76ba6
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.
289 lines
11 KiB
PHP
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);
|
|
});
|