mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-16 16:45: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.
281 lines
12 KiB
PHP
281 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Comments\Models\FileComment;
|
|
use App\Modules\Files\Models\File;
|
|
use App\Modules\Identity\Models\RolePermission;
|
|
use App\Modules\Identity\Permissions\SystemRole;
|
|
use App\Modules\Platform\Captcha\Captcha;
|
|
use App\Modules\Platform\Captcha\CaptchaForm;
|
|
use App\Modules\Platform\Captcha\CaptchaProvider;
|
|
use App\Modules\Platform\Captcha\CaptchaSettings;
|
|
use App\Modules\Platform\Captcha\CaptchaVerifier;
|
|
use App\Modules\Platform\Settings\Setting;
|
|
use App\Modules\Platform\Settings\Settings;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Laravel\Sanctum\Sanctum;
|
|
|
|
beforeEach(function () {
|
|
$this->admin = User::factory()->create();
|
|
$this->settings = app(Settings::class);
|
|
|
|
$this->settings->set(Setting::CommentsScope, 'all');
|
|
$this->settings->set(Setting::CommentsAuthors, 'staff_and_clients');
|
|
$this->settings->set(Setting::PublicCommentsEnabled, false);
|
|
$this->settings->set(Setting::CommentsEditWindowMinutes, 15);
|
|
});
|
|
|
|
test('a token can read a file\'s comments', function () {
|
|
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
|
|
$client = User::factory()->client()->create();
|
|
shareFileWith($file, $client);
|
|
|
|
FileComment::factory()->for($file)->inThreadOf($client)->create(['author_id' => $client->id, 'body' => 'A question']);
|
|
|
|
Sanctum::actingAs($this->admin, ['edit_others_files']);
|
|
|
|
$this->getJson("/api/v1/files/{$file->id}/comments")
|
|
->assertOk()
|
|
->assertJsonPath('data.0.body', 'A question')
|
|
->assertJsonPath('data.0.conversation.client_id', $client->id)
|
|
->assertJsonPath('data.0.author.type', 'client')
|
|
// Personal data collected for moderation, not for integrations.
|
|
->assertJsonMissingPath('data.0.ip_address');
|
|
});
|
|
|
|
test('a token replies into a client\'s conversation by answering their comment', function () {
|
|
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
|
|
$client = User::factory()->client()->create();
|
|
shareFileWith($file, $client);
|
|
|
|
$question = FileComment::factory()->for($file)->inThreadOf($client)->create(['author_id' => $client->id]);
|
|
|
|
Sanctum::actingAs($this->admin, ['edit_others_files']);
|
|
|
|
$this->postJson("/api/v1/files/{$file->id}/comments", [
|
|
'body' => 'Here you go',
|
|
'visibility' => 'clients',
|
|
'reply_to' => $question->id,
|
|
])->assertCreated()->assertJsonPath('data.conversation.client_id', $client->id);
|
|
|
|
expect(FileComment::query()->latest('id')->first()->client_context_id)->toBe($client->id);
|
|
});
|
|
|
|
test('a token has no way to address a client it was not shown', function () {
|
|
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
|
|
$client = User::factory()->client()->create();
|
|
shareFileWith($file, $client);
|
|
|
|
Sanctum::actingAs($this->admin, ['edit_others_files']);
|
|
|
|
// No client id is accepted anywhere; a fresh comment reaches every
|
|
// client on the file, which is the only audience on offer.
|
|
$this->postJson("/api/v1/files/{$file->id}/comments", [
|
|
'body' => 'Here you go',
|
|
'visibility' => 'clients',
|
|
'client_context_id' => $client->id,
|
|
])->assertCreated()->assertJsonPath('data.conversation', null);
|
|
|
|
expect(FileComment::query()->sole()->client_context_id)->toBeNull();
|
|
});
|
|
|
|
test('a token cannot ask for a visibility the settings do not allow', function () {
|
|
$file = File::factory()->public()->create(['uploaded_by' => $this->admin->id]);
|
|
|
|
Sanctum::actingAs($this->admin, ['edit_others_files']);
|
|
|
|
$this->postJson("/api/v1/files/{$file->id}/comments", [
|
|
'body' => 'Hello world',
|
|
'visibility' => 'everyone',
|
|
])->assertForbidden();
|
|
});
|
|
|
|
test('a token without a file ability cannot reach comments at all', function () {
|
|
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
|
|
|
|
Sanctum::actingAs($this->admin, ['manage_clients']);
|
|
|
|
$this->getJson("/api/v1/files/{$file->id}/comments")->assertForbidden();
|
|
});
|
|
|
|
test('a client-scoped token sees its own clients\' threads and not the others', function () {
|
|
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
|
|
$mine = User::factory()->client()->create();
|
|
$theirs = User::factory()->client()->create();
|
|
shareFileWith($file, $mine);
|
|
shareFileWith($file, $theirs);
|
|
|
|
$manager = User::factory()->role(SystemRole::ClientManager)->create();
|
|
$manager->assignedClients()->attach($mine->id);
|
|
|
|
$visible = FileComment::factory()->for($file)->inThreadOf($mine)->create(['author_id' => $mine->id]);
|
|
FileComment::factory()->for($file)->inThreadOf($theirs)->create(['author_id' => $theirs->id, 'body' => 'Not yours']);
|
|
|
|
Sanctum::actingAs($manager, ['edit_files']);
|
|
|
|
$response = $this->getJson("/api/v1/files/{$file->id}/comments")->assertOk();
|
|
|
|
expect($response->json('data'))->toHaveCount(1)
|
|
->and($response->json('data.0.id'))->toBe($visible->id)
|
|
->and(json_encode($response->json()))->not->toContain('Not yours')
|
|
->and(json_encode($response->json()))->not->toContain($theirs->name);
|
|
});
|
|
|
|
test('a client-scoped token cannot reply into a conversation outside its scope', function () {
|
|
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
|
|
$mine = User::factory()->client()->create();
|
|
$stranger = User::factory()->client()->create();
|
|
shareFileWith($file, $mine);
|
|
shareFileWith($file, $stranger);
|
|
|
|
$manager = User::factory()->role(SystemRole::ClientManager)->create();
|
|
$manager->assignedClients()->attach($mine->id);
|
|
|
|
$hers = FileComment::factory()->for($file)->inThreadOf($stranger)->create(['author_id' => $stranger->id]);
|
|
|
|
Sanctum::actingAs($manager, ['edit_files']);
|
|
|
|
// The file is theirs to open — one of their clients has it. Her
|
|
// comment is not, so the reply target resolves to nothing and the
|
|
// comment lands addressed to every client rather than into hers.
|
|
$this->postJson("/api/v1/files/{$file->id}/comments", [
|
|
'body' => 'Hello',
|
|
'visibility' => 'clients',
|
|
'reply_to' => $hers->id,
|
|
])->assertCreated()->assertJsonPath('data.conversation', null);
|
|
});
|
|
|
|
test('a token can edit and delete its own comment, but not somebody else\'s words', function () {
|
|
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
|
|
$client = User::factory()->client()->create();
|
|
shareFileWith($file, $client);
|
|
|
|
$mine = FileComment::factory()->for($file)->create(['author_id' => $this->admin->id]);
|
|
$theirs = FileComment::factory()->for($file)->inThreadOf($client)->create(['author_id' => $client->id]);
|
|
|
|
Sanctum::actingAs($this->admin, ['edit_others_files']);
|
|
|
|
$this->patchJson("/api/v1/comments/{$mine->id}", ['body' => 'Reworded'])
|
|
->assertOk()->assertJsonPath('data.body', 'Reworded');
|
|
|
|
$this->patchJson("/api/v1/comments/{$theirs->id}", ['body' => 'Rewritten'])->assertForbidden();
|
|
|
|
// Moderation does extend to removing one, though.
|
|
$this->deleteJson("/api/v1/comments/{$theirs->id}")->assertNoContent();
|
|
});
|
|
|
|
test('the commentable flag is readable and writable, but only under the scope that uses it', function () {
|
|
$file = File::factory()->create(['uploaded_by' => $this->admin->id, 'commentable' => false]);
|
|
|
|
Sanctum::actingAs($this->admin, ['edit_others_files']);
|
|
|
|
$this->patchJson("/api/v1/files/{$file->id}", ['commentable' => true])
|
|
->assertOk()->assertJsonPath('data.commentable', false);
|
|
|
|
$this->settings->set(Setting::CommentsScope, 'selected');
|
|
|
|
$this->patchJson("/api/v1/files/{$file->id}", ['commentable' => true])
|
|
->assertOk()->assertJsonPath('data.commentable', true);
|
|
});
|
|
|
|
test('a token with the moderation ability can list and approve what is waiting', function () {
|
|
$file = File::factory()->public()->create(['uploaded_by' => $this->admin->id]);
|
|
$pending = FileComment::factory()->for($file)->fromGuest('A passer-by')->pending()->create();
|
|
FileComment::factory()->for($file)->everyone()->create(['author_id' => $this->admin->id]);
|
|
|
|
Sanctum::actingAs($this->admin, ['moderate_comments']);
|
|
|
|
$this->getJson('/api/v1/comments/pending')
|
|
->assertOk()
|
|
->assertJsonCount(1, 'data')
|
|
->assertJsonPath('data.0.id', $pending->id)
|
|
->assertJsonPath('data.0.approved', false)
|
|
->assertJsonPath('data.0.author.type', 'guest');
|
|
|
|
$this->postJson("/api/v1/comments/{$pending->id}/approve")
|
|
->assertOk()
|
|
->assertJsonPath('data.approved', true);
|
|
|
|
expect($pending->fresh()->isPending())->toBeFalse();
|
|
|
|
// Retrying is safe: already approved, nothing changes and nothing is
|
|
// announced a second time.
|
|
$this->postJson("/api/v1/comments/{$pending->id}/approve")->assertOk();
|
|
expect($this->getJson('/api/v1/comments/pending')->json('data'))->toHaveCount(0);
|
|
});
|
|
|
|
test('moderation needs its own ability, not a file one', function () {
|
|
$file = File::factory()->public()->create(['uploaded_by' => $this->admin->id]);
|
|
$pending = FileComment::factory()->for($file)->fromGuest()->pending()->create();
|
|
|
|
Sanctum::actingAs($this->admin, ['edit_others_files']);
|
|
|
|
$this->getJson('/api/v1/comments/pending')->assertForbidden();
|
|
$this->postJson("/api/v1/comments/{$pending->id}/approve")->assertForbidden();
|
|
});
|
|
|
|
test('the queue is scoped by the library boundary, moderation ability or not', function () {
|
|
$mine = User::factory()->client()->create();
|
|
$stranger = User::factory()->client()->create();
|
|
|
|
$ours = File::factory()->public()->create(['uploaded_by' => $this->admin->id]);
|
|
$theirs = File::factory()->public()->create(['uploaded_by' => $this->admin->id]);
|
|
shareFileWith($ours, $mine);
|
|
shareFileWith($theirs, $stranger);
|
|
|
|
$visible = FileComment::factory()->for($ours)->fromGuest()->pending()->create();
|
|
$hidden = FileComment::factory()->for($theirs)->fromGuest()->pending()->create();
|
|
|
|
$manager = User::factory()->role(SystemRole::ClientManager)->create();
|
|
$manager->assignedClients()->attach($mine->id);
|
|
RolePermission::query()->create([
|
|
'role_id' => $manager->role_id,
|
|
'permission' => 'moderate_comments',
|
|
]);
|
|
|
|
Sanctum::actingAs($manager, ['moderate_comments']);
|
|
|
|
expect($this->getJson('/api/v1/comments/pending')->json('data.*.id'))->toBe([$visible->id]);
|
|
|
|
// And the boundary holds on the write, not only on the listing.
|
|
$this->postJson("/api/v1/comments/{$hidden->id}/approve")->assertForbidden();
|
|
});
|
|
|
|
// The API authenticates with a bearer token, which is a stronger claim
|
|
// than "is a person" — so a CAPTCHA configured for the browser forms must
|
|
// never reach it. Asserted here rather than assumed, because the rule
|
|
// factory is shared and adding it to one more controller would be easy.
|
|
test('a configured captcha never applies to the API', function () {
|
|
CaptchaSettings::for(CaptchaProvider::Turnstile)->fill([
|
|
'site_key' => 'site-key',
|
|
'secret_key' => 'secret-key',
|
|
])->save();
|
|
|
|
$this->settings->set(Setting::CaptchaProvider, CaptchaProvider::Turnstile->value);
|
|
foreach (CaptchaForm::cases() as $form) {
|
|
$this->settings->set($form->setting(), true);
|
|
}
|
|
Captcha::forgetDisplayCache();
|
|
CaptchaVerifier::forgetOutage();
|
|
|
|
Http::fake();
|
|
|
|
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
|
|
$client = User::factory()->client()->create();
|
|
shareFileWith($file, $client);
|
|
|
|
Sanctum::actingAs($this->admin, ['edit_others_files']);
|
|
|
|
$this->postJson("/api/v1/files/{$file->id}/comments", [
|
|
'body' => 'From an integration',
|
|
'visibility' => 'staff_only',
|
|
])->assertCreated();
|
|
|
|
Http::assertNothingSent();
|
|
|
|
$this->settings->set(Setting::CaptchaProvider, 'none');
|
|
Captcha::forgetDisplayCache();
|
|
});
|