Files
projectsend/tests/Feature/Comments/ModerationReadPermissionTest.php
denkfabrik-li c8de16101f Gate the comment moderation surfaces on reading, not just on the library
FilePolicy::view() has two halves for staff: one of the three file keys
(upload / edit_files / edit_others_files), AND StaffLibraryScope. Every
comment surface that spans files narrowed by the library half alone.

A role holding moderate_comments and no file key therefore got a 403 on
every file in the installation while reading every comment written about
them on /comments: the text, staff-only notes, the client name a
Clients-visibility comment carries, and a visitor's IP address. The API
queue answered the same way, and approving through it hands the body back
in the response, so it was a reading door as well as a writing one.

The class says this is not supposed to happen -- across()'s own docblock
("a moderation screen is not a way around the visibility model"), the
route comment on /comments ("the list itself is still narrowed by
VisibleCommentScope, so holding the permission does not widen what a
viewer may read"), and routes/api.php ("reading and writing a comment is
gated by 'may see this file', the same three keys the file endpoints
use"). FileCommentPolicy::view() enforces it for a single comment, by
running the file's own gate first. Only the cross-file queries did not.

So they now take their files from ViewableFileScope, which is
FilePolicy::view() expressed as a query, instead of from StaffLibraryScope,
which is only its second half: across(), pendingTotal() and the API's
pending list. The permission half moves into a named method on that class,
since three modules now ask the same question.

FileCommentPolicy::moderate() gets it too, in both forms. Its row form is
otherwise unchanged -- the library check still runs by file id, so a
comment on a soft-deleted file behaves exactly as before.

No system role changes behaviour: Account Manager and System Administrator
are the two that ship with moderate_comments, and both hold upload. What
changes is a hand-built role that holds moderation and nothing else.

Seven tests. Without the fix, five go red; the other two are the premise
(that the viewer really is refused the file itself) and the guard that a
moderator who may read files still moderates the whole installation.

docs/api/openapi.json regenerated for the one changed description.
2026-08-28 06:40:42 +02:00

147 lines
5.4 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\Role;
use App\Modules\Identity\Models\RolePermission;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Laravel\Sanctum\Sanctum;
/*
|--------------------------------------------------------------------------
| Moderating is not a way to read
|--------------------------------------------------------------------------
|
| FilePolicy::view() has two halves for staff: one of the three file keys
| (upload / edit_files / edit_others_files), AND the library scope. The
| comment surfaces narrowed by the library half alone, so a role holding
| moderate_comments and nothing else got a 403 on every file while reading
| every comment written about them — text, staff-only notes, the client
| name in `conversation`, and a visitor's IP.
|
*/
beforeEach(function () {
$this->admin = User::factory()->create();
$this->settings = app(Settings::class);
$this->settings->set(Setting::CommentsScope, 'all');
$this->settings->set(Setting::CommentsAuthors, 'everyone');
$this->settings->set(Setting::PublicCommentsEnabled, true);
$this->settings->set(Setting::CommentsGuestModeration, true);
});
/** A moderator holding moderate_comments and no file key whatsoever. */
function moderatorWithoutFileKeys(): User
{
$role = Role::query()->create(['name' => 'Comment moderator']);
RolePermission::query()->insert([
['role_id' => $role->id, 'permission' => 'moderate_comments'],
]);
return User::factory()->create(['role_id' => $role->id]);
}
/** A moderator who may also read files — the shipped Account Manager shape. */
function moderatorWithFileKeys(): User
{
$role = Role::query()->create(['name' => 'Account manager shape']);
RolePermission::query()->insert([
['role_id' => $role->id, 'permission' => 'moderate_comments'],
['role_id' => $role->id, 'permission' => 'upload'],
]);
return User::factory()->create(['role_id' => $role->id]);
}
/** @return array{0: File, 1: FileComment} */
function commentedFile(User $uploader): array
{
$file = File::factory()->public()->create(['uploaded_by' => $uploader->id, 'name' => 'merger-terms']);
return [$file, FileComment::factory()->for($file)->fromGuest()->pending()->create()];
}
test('a moderator who may read no file is refused the file itself', function () {
// The premise the rest of this file rests on, measured rather than
// assumed: the door next to the comment is shut for this viewer.
[$file] = commentedFile($this->admin);
$this->actingAs(moderatorWithoutFileKeys())
->get("/files/{$file->id}/download")
->assertForbidden();
});
test('the moderation screen shows no comment about a file the viewer cannot open', function () {
[, $comment] = commentedFile($this->admin);
$props = $this->actingAs(moderatorWithoutFileKeys())
->get('/comments')->assertOk()->viewData('page')['props'];
expect(collect($props['entries'])->pluck('id')->all())->not->toContain($comment->id)
->and($props['entries'])->toBe([]);
});
test('the pending badge counts nothing a viewer may not open', function () {
commentedFile($this->admin);
$props = $this->actingAs(moderatorWithoutFileKeys())
->get('/comments')->assertOk()->viewData('page')['props'];
expect($props['pending_total'])->toBe(0)
->and($props['pending']['comments'] ?? 0)->toBe(0);
});
test('the API queue refuses a moderator without a file key', function () {
// The class form of the policy answers "does this user moderate at
// all", and without a file key the answer is no — so the queue is
// refused rather than returned empty, exactly as it is for somebody
// holding no moderation permission.
commentedFile($this->admin);
Sanctum::actingAs(moderatorWithoutFileKeys(), ['moderate_comments']);
$this->getJson('/api/v1/comments/pending')->assertForbidden();
});
test('approving is refused, so the response cannot hand back the body', function () {
[, $comment] = commentedFile($this->admin);
Sanctum::actingAs(moderatorWithoutFileKeys(), ['moderate_comments']);
$this->postJson("/api/v1/comments/{$comment->id}/approve")->assertForbidden();
expect($comment->fresh()->isPending())->toBeTrue();
});
test('deleting from the moderation screen is refused too', function () {
[, $comment] = commentedFile($this->admin);
$this->actingAs(moderatorWithoutFileKeys())
->delete("/comments/{$comment->id}/moderate")
->assertForbidden();
expect(FileComment::query()->whereKey($comment->id)->exists())->toBeTrue();
});
test('a moderator who may read files still moderates the whole installation', function () {
// Guards against narrowing by more than the permission half: this role
// is unscoped, so every comment stays in reach.
[, $comment] = commentedFile($this->admin);
$moderator = moderatorWithFileKeys();
$props = $this->actingAs($moderator)->get('/comments')->assertOk()->viewData('page')['props'];
expect(collect($props['entries'])->pluck('id')->all())->toContain($comment->id)
->and($props['pending_total'])->toBe(1);
$this->actingAs($moderator)->delete("/comments/{$comment->id}/moderate")->assertRedirect();
expect(FileComment::query()->whereKey($comment->id)->exists())->toBeFalse();
});