Files
projectsend/tests/Feature/Comments/CommentVisibilityTest.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

211 lines
9.1 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Comments\Access\VisibleCommentScope;
use App\Modules\Comments\CommentVisibility;
use App\Modules\Comments\Models\FileComment;
use App\Modules\Files\Models\File;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Models\RolePermission;
use App\Modules\Identity\Permissions\SystemRole;
/**
* The rules in Access\VisibleCommentScope, asserted directly against the
* scope rather than through HTTP — every surface funnels into this one
* class, so this is where the privacy model is either right or wrong.
*
* The cross-client cases are the reason the feature is shaped this way at
* all: "people with access" must never mean "every client this file
* happens to be shared with", because that leaks the existence of one
* customer to another.
*/
beforeEach(function () {
// Every HTTP feature test needs a staff user or EnsureSetupIsComplete
// redirects; kept here too since these build users either way.
$this->admin = User::factory()->create();
$this->scope = app(VisibleCommentScope::class);
});
/**
* @return list<int>
*/
function visibleCommentIds(?User $viewer, File $file): array
{
return app(VisibleCommentScope::class)->for($viewer, $file)->pluck('id')->map(intval(...))->all();
}
test('one client never sees another client\'s thread on a file shared with both', function () {
$file = File::factory()->create();
$alice = User::factory()->client()->create();
$bob = User::factory()->client()->create();
$fromAlice = FileComment::factory()->for($file)->inThreadOf($alice)->create(['author_id' => $alice->id]);
$fromBob = FileComment::factory()->for($file)->inThreadOf($bob)->create(['author_id' => $bob->id]);
expect(visibleCommentIds($alice, $file))->toBe([$fromAlice->id])
->and(visibleCommentIds($bob, $file))->toBe([$fromBob->id]);
});
test('a staff reply into one client\'s thread stays out of the other\'s', function () {
$file = File::factory()->create();
$alice = User::factory()->client()->create();
$bob = User::factory()->client()->create();
$reply = FileComment::factory()->for($file)->inThreadOf($alice)->create(['author_id' => $this->admin->id]);
expect(visibleCommentIds($alice, $file))->toBe([$reply->id])
->and(visibleCommentIds($bob, $file))->toBe([]);
});
test('unscoped staff see every thread on a file, including internal notes', function () {
$file = File::factory()->create();
$alice = User::factory()->client()->create();
$bob = User::factory()->client()->create();
$fromAlice = FileComment::factory()->for($file)->inThreadOf($alice)->create(['author_id' => $alice->id]);
$fromBob = FileComment::factory()->for($file)->inThreadOf($bob)->create(['author_id' => $bob->id]);
// No client context at all: a staff-only note.
$note = FileComment::factory()->for($file)->create(['author_id' => $this->admin->id]);
expect(visibleCommentIds($this->admin, $file))
->toEqualCanonicalizing([$fromAlice->id, $fromBob->id, $note->id]);
});
test('an internal note is invisible to the client whose file it is on', function () {
$file = File::factory()->create();
$client = User::factory()->client()->create();
FileComment::factory()->for($file)->create(['author_id' => $this->admin->id]);
expect(visibleCommentIds($client, $file))->toBe([]);
});
test('an "only me" comment is invisible to everyone but its author, staff included', function () {
$file = File::factory()->create();
$client = User::factory()->client()->create();
$note = FileComment::factory()->for($file)->onlyMe()->create(['author_id' => $client->id]);
expect(visibleCommentIds($client, $file))->toBe([$note->id])
->and(visibleCommentIds($this->admin, $file))->toBe([])
->and(visibleCommentIds(null, $file))->toBe([]);
});
test('a public comment reaches guests only while the file is still public', function () {
$file = File::factory()->public()->create();
$comment = FileComment::factory()->for($file)->everyone()->create(['author_id' => $this->admin->id]);
expect(visibleCommentIds(null, $file))->toBe([$comment->id]);
$file->forceFill(['public' => false])->save();
// Publicness is re-derived per read, so retracting the file retracts
// the comment with it — for guests. Staff keep their history.
expect(visibleCommentIds(null, $file->fresh()))->toBe([])
->and(visibleCommentIds($this->admin, $file->fresh()))->toBe([$comment->id]);
});
test('a guest sees nothing at all on a file that is not public', function () {
$file = File::factory()->create();
FileComment::factory()->for($file)->everyone()->create(['author_id' => $this->admin->id]);
expect(visibleCommentIds(null, $file))->toBe([]);
});
test('a pending comment is invisible until approved, except to moderators', function () {
$file = File::factory()->public()->create();
$client = User::factory()->client()->create();
$pending = FileComment::factory()->for($file)->fromGuest()->pending()->create();
expect(visibleCommentIds(null, $file))->toBe([])
->and(visibleCommentIds($client, $file))->toBe([])
// The seeded administrator holds every permission by construction.
->and(visibleCommentIds($this->admin, $file))->toBe([$pending->id]);
$pending->forceFill(['approved_at' => now()])->save();
expect(visibleCommentIds(null, $file))->toBe([$pending->id]);
});
test('a staff member without moderation rights does not see pending comments', function () {
$file = File::factory()->public()->create();
$role = Role::query()->create(['name' => 'Files only', 'is_system' => false, 'is_administrator' => false]);
RolePermission::query()->insert([['role_id' => $role->id, 'permission' => 'edit_others_files']]);
$staff = User::factory()->create(['role_id' => $role->id]);
$approved = FileComment::factory()->for($file)->everyone()->create(['author_id' => $this->admin->id]);
FileComment::factory()->for($file)->fromGuest()->pending()->create();
expect(visibleCommentIds($staff, $file))->toBe([$approved->id]);
});
test('a client-scoped staff member sees their client\'s thread and not the other\'s on the same file', function () {
$file = File::factory()->create();
$mine = User::factory()->client()->create();
$theirs = User::factory()->client()->create();
$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]);
$note = FileComment::factory()->for($file)->create(['author_id' => $this->admin->id]);
// Internal notes belong to no client, so they stay in scope.
expect(visibleCommentIds($manager, $file))->toEqualCanonicalizing([$visible->id, $note->id]);
});
test('a soft-deleted comment disappears from every viewer', function () {
$file = File::factory()->public()->create();
$comment = FileComment::factory()->for($file)->everyone()->create(['author_id' => $this->admin->id]);
$comment->delete();
expect(visibleCommentIds($this->admin, $file))->toBe([])
->and(visibleCommentIds(null, $file))->toBe([]);
});
test('the visibility column round-trips as an enum', function () {
$comment = FileComment::factory()->onlyMe()->create();
expect($comment->fresh()->visibility)->toBe(CommentVisibility::OnlyMe);
});
test('a message to the clients reaches the team as well as the clients', function () {
$file = File::factory()->create();
$alice = User::factory()->client()->create();
$bob = User::factory()->client()->create();
$colleague = User::factory()->create();
// No conversation of its own: staff addressing everyone on the file.
// The composer calls this "Staff and clients", so this is the test
// that the label is true.
$message = FileComment::factory()->for($file)->toAllClients()->create(['author_id' => $this->admin->id]);
expect(visibleCommentIds($this->admin, $file))->toBe([$message->id])
->and(visibleCommentIds($colleague, $file))->toBe([$message->id])
->and(visibleCommentIds($alice, $file))->toBe([$message->id])
->and(visibleCommentIds($bob, $file))->toBe([$message->id])
// Still not the open web.
->and(visibleCommentIds(null, $file))->toBe([]);
});
test('a client writing to that same audience reaches the team and nobody else', function () {
$file = File::factory()->create();
$alice = User::factory()->client()->create();
$bob = User::factory()->client()->create();
// The same stored audience, written from the other end: a client's
// comment carries their own conversation, so it never fans out to the
// other clients. Which is why they see the option called "Staff".
$question = FileComment::factory()->for($file)->inThreadOf($alice)->create(['author_id' => $alice->id]);
expect(visibleCommentIds($this->admin, $file))->toBe([$question->id])
->and(visibleCommentIds($alice, $file))->toBe([$question->id])
->and(visibleCommentIds($bob, $file))->toBe([]);
});