Files
projectsend/tests/Feature/Groups/PublicGroupsTest.php
T
ignacionelson 57540164fa Read a file from the disk it is actually on, everywhere
Two routes still assumed every file sits on local disk, which stopped
being true the moment external storage was switched on. A share link
answered with X-Accel-Redirect whatever the file's disk said, pointing
nginx at a path it has nothing behind; a public listing built a
thumbnail from Storage::disk('files')->path(), which for an externally
stored file is a path nobody ever wrote. Both fail only for installs
using S3, and only on those two routes, so the same file downloading
correctly from the file manager made the share link look like the
broken thing rather than where the file lives.

Neither is a new rule. FileDownloadController and
FileThumbnailController already did it right, which is the actual
finding: the knowledge was sitting in a private method on one class and
inline in another, so the next caller could not inherit it and did not.
Both are now objects with one job.

StoredFileResponse replaces InlineFileResponse and grows an
attachment() alongside inline(), since the two differ only by
disposition. LocalSourceFile takes a closure rather than returning a
path: the version that returned one also left the caller to unlink it,
and both of those are exactly the mistakes made here.

The regression tests fail against the previous controllers — checked in
both directions rather than assumed.
2026-08-24 16:24:39 -03:00

398 lines
18 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Files\Models\Category;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Modules\Groups\Models\Group;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Testing\TestResponse;
/**
* @return array<string, mixed>
*/
function publicPageProps(TestResponse $response): array
{
$page = json_decode(json_encode($response->viewData('page')), true);
return $page['props'];
}
beforeEach(function () {
Storage::fake('files');
// EnsureSetupIsComplete redirects every guest request to /setup until
// a staff account exists — an unrelated concern to these tests, but
// one they need to satisfy just like every other feature test does.
User::factory()->create();
app(Settings::class)->set(Setting::PublicListingEnabled, true);
app(Settings::class)->set(Setting::PublicListingSlug, 'public');
app(Settings::class)->set(Setting::Theme, 'default');
});
test('the directory 404s when disabled, but a specific public group page and its downloads still work', function () {
// PublicListingEnabled only gates the browsable directory (index()) —
// a public group's own page/downloads are independent of it, exactly
// like a share link isn't gated by any global toggle.
app(Settings::class)->set(Setting::PublicListingEnabled, false);
$group = Group::query()->create(['name' => 'Open Group', 'public' => true]);
$staff = User::factory()->create();
$file = publicListingFile();
$this->actingAs($staff)->post("/files/{$file->id}/assignments", ['type' => 'group', 'id' => $group->id]);
auth()->logout();
$this->get('/public')->assertNotFound();
$this->get("/public/{$group->slug}")->assertOk();
$this->get("/public/files/{$file->slug}/download")->assertOk();
});
test('the wrong base slug 404s even when enabled', function () {
$group = Group::query()->create(['name' => 'Open Group', 'public' => true]);
$this->get('/wrong-base')->assertNotFound();
$this->get("/wrong-base/{$group->slug}")->assertNotFound();
});
test('the index lists public groups and standalone public files without leaking private ones', function () {
$publicGroup = Group::query()->create(['name' => 'Open Group', 'public' => true]);
$privateGroup = Group::query()->create(['name' => 'Closed Group', 'public' => false]);
$standalone = publicListingFile(['name' => 'Standalone']);
$privateOnly = publicListingFile(['name' => 'Private Only', 'public' => false]);
// Assigned only to a private group but itself flagged public: still
// shows on the front page (the file flag is independent of any group).
$orphanOfPrivateGroup = publicListingFile(['name' => 'Orphan Of Private']);
$this->actingAs(User::factory()->create())
->post("/files/{$orphanOfPrivateGroup->id}/assignments", ['type' => 'group', 'id' => $privateGroup->id]);
$inPublicGroup = publicListingFile(['name' => 'In Public Group']);
$this->actingAs(User::factory()->create())
->post("/files/{$inPublicGroup->id}/assignments", ['type' => 'group', 'id' => $publicGroup->id]);
// actingAs() persists across requests within a test — logout so the
// listing is viewed as a genuine guest, not still the staff member.
auth()->logout();
$response = $this->get('/public');
$response->assertInertia(
fn ($page) => $page
->component('public/themes/default/index')
->has('groups', 1)
->where('groups.0.name', 'Open Group')
->has('files', 2),
);
$names = collect(publicPageProps($response)['files'])->pluck('name');
expect($names)->toContain('Standalone')
->toContain('Orphan Of Private')
->not->toContain('Private Only')
->not->toContain('In Public Group');
expect((string) $response->getContent())->not->toContain('Closed Group');
});
test('a public group page lists its directly assigned and folder-subtree files, filtered to public ones', function () {
$group = Group::query()->create(['name' => 'Design Team', 'public' => true]);
$staff = User::factory()->create();
$direct = publicListingFile(['name' => 'Direct']);
$this->actingAs($staff)->post("/files/{$direct->id}/assignments", ['type' => 'group', 'id' => $group->id]);
$notPublic = publicListingFile(['name' => 'Not Public', 'public' => false]);
$this->actingAs($staff)->post("/files/{$notPublic->id}/assignments", ['type' => 'group', 'id' => $group->id]);
$folder = Folder::query()->create(['name' => 'Shared Folder']);
$this->actingAs($staff)->post("/folders/{$folder->id}/assignments", ['type' => 'group', 'id' => $group->id]);
$viaFolder = publicListingFile(['name' => 'Via Folder', 'folder_id' => $folder->id]);
$elsewhere = publicListingFile(['name' => 'Elsewhere']);
// actingAs() persists across requests within a test — logout so the
// group page is viewed as a genuine guest, not still the staff member.
auth()->logout();
$response = $this->get("/public/{$group->slug}");
$response->assertInertia(
fn ($page) => $page->component('public/themes/default/group')->where('group.name', 'Design Team'),
);
$names = collect(publicPageProps($response)['files'])->pluck('name');
expect($names)->toContain('Direct')
->toContain('Via Folder')
->not->toContain('Not Public')
->not->toContain('Elsewhere');
});
test('selecting the compact theme renders the compact components', function () {
app(Settings::class)->set(Setting::Theme, 'compact');
$group = Group::query()->create(['name' => 'Open Group', 'public' => true]);
$file = publicListingFile();
$this->get('/public')->assertInertia(fn ($page) => $page->component('public/themes/compact/index'));
$this->get("/public/{$group->slug}")->assertInertia(fn ($page) => $page->component('public/themes/compact/group'));
$this->get("/public/files/{$file->slug}")->assertInertia(fn ($page) => $page->component('public/themes/compact/file'));
});
test('selecting the drive theme renders the drive components with a mime_type per file', function () {
app(Settings::class)->set(Setting::Theme, 'drive');
$group = Group::query()->create(['name' => 'Open Group', 'public' => true]);
$file = publicListingFile();
$indexResponse = $this->get('/public');
$indexResponse->assertInertia(fn ($page) => $page->component('public/themes/drive/index'));
expect(collect(publicPageProps($indexResponse)['files'])->firstWhere('name', $file->name)['mime_type'] ?? null)
->toBe('application/pdf');
$this->get("/public/{$group->slug}")->assertInertia(fn ($page) => $page->component('public/themes/drive/group'));
$this->get("/public/files/{$file->slug}")->assertInertia(fn ($page) => $page->component('public/themes/drive/file'));
});
test('an unknown or unavailable stored theme falls back to default rather than a broken page', function () {
app(Settings::class)->set(Setting::Theme, 'does-not-exist');
$this->get('/public')->assertInertia(fn ($page) => $page->component('public/themes/default/index'));
});
test('selecting the gallery theme renders the gallery components', function () {
app(Settings::class)->set(Setting::Theme, 'gallery');
$group = Group::query()->create(['name' => 'Open Group', 'public' => true]);
$file = publicListingFile();
$this->get('/public')->assertInertia(fn ($page) => $page->component('public/themes/gallery/index'));
$this->get("/public/{$group->slug}")->assertInertia(fn ($page) => $page->component('public/themes/gallery/group'));
$this->get("/public/files/{$file->slug}")->assertInertia(fn ($page) => $page->component('public/themes/gallery/file'));
});
test('a private group 404s even with its correct slug', function () {
$group = Group::query()->create(['name' => 'Closed Group', 'public' => false]);
$this->get("/public/{$group->slug}")->assertNotFound();
});
test('download serves a public file and 404s a non-public one regardless of group state', function () {
$group = Group::query()->create(['name' => 'Open Group', 'public' => true]);
$staff = User::factory()->create();
$public = publicListingFile(['name' => 'Downloadable']);
$this->actingAs($staff)->post("/files/{$public->id}/assignments", ['type' => 'group', 'id' => $group->id]);
$notPublic = publicListingFile(['name' => 'Not Downloadable', 'public' => false]);
$this->actingAs($staff)->post("/files/{$notPublic->id}/assignments", ['type' => 'group', 'id' => $group->id]);
$this->get("/public/files/{$public->slug}/download")
->assertOk()
->assertHeader('X-Accel-Redirect', '/protected-files/'.$public->path);
expect(ActivityLog::query()->where('action', Action::PublicFileDownloaded)->where('subject_name', 'Downloadable')->exists())->toBeTrue();
$this->get("/public/files/{$notPublic->slug}/download")->assertNotFound();
});
test('an expired public file 404s on its detail, thumbnail, and download routes, and drops out of the standalone listing', function () {
$expired = publicListingFile(['name' => 'Expired', 'expires_at' => now()->subDay()]);
$this->get(route('public.file', ['public', $expired->slug]))->assertNotFound();
$this->get(route('public.thumbnail', ['public', $expired->slug]))->assertNotFound();
$this->get(route('public.download', ['public', $expired->slug]))->assertNotFound();
$this->get('/public')->assertInertia(
fn ($page) => $page->where('files', fn ($files) => collect($files)->pluck('name')->doesntContain('Expired')),
);
});
test('an expired file also drops out of its public group\'s page', function () {
$group = Group::query()->create(['name' => 'Open Group', 'public' => true]);
$staff = User::factory()->create();
$expired = publicListingFile(['name' => 'Expired In Group', 'expires_at' => now()->subDay()]);
$this->actingAs($staff)->post("/files/{$expired->id}/assignments", ['type' => 'group', 'id' => $group->id]);
$this->get("/public/{$group->slug}")->assertInertia(
fn ($page) => $page->has('files', 0),
);
});
test('the file listing rows on the directory and group pages link to a details page', function () {
$group = Group::query()->create(['name' => 'Open Group', 'public' => true]);
$staff = User::factory()->create();
// Standalone (no group) — appears on the front directory.
$standalone = publicListingFile(['name' => 'Standalone']);
// Assigned to the public group — appears on its page instead.
$inGroup = publicListingFile(['name' => 'In Group']);
$this->actingAs($staff)->post("/files/{$inGroup->id}/assignments", ['type' => 'group', 'id' => $group->id]);
auth()->logout();
$indexProps = publicPageProps($this->get('/public'));
expect(collect($indexProps['files'])->firstWhere('name', 'Standalone')['url'] ?? null)
->toBe(route('public.file', ['public', $standalone->slug]));
$groupProps = publicPageProps($this->get("/public/{$group->slug}"));
expect(collect($groupProps['files'])->firstWhere('name', 'In Group')['url'] ?? null)
->toBe(route('public.file', ['public', $inGroup->slug]));
});
test('a public file\'s details page shows a thumbnail url only when the mime type supports it', function () {
$staff = User::factory()->create();
$image = publicListingImageFile($staff);
$pdf = publicListingFile();
auth()->logout();
$imageResponse = $this->get(route('public.file', ['public', $image->slug]));
$imageResponse->assertInertia(
fn ($page) => $page->component('public/themes/default/file')
->where('file.name', $image->name)
->where('thumbnail_url', route('public.thumbnail', ['public', $image->slug])),
);
$pdfResponse = $this->get(route('public.file', ['public', $pdf->slug]));
$pdfResponse->assertInertia(fn ($page) => $page->where('thumbnail_url', null));
});
test('the directory and group listings carry a thumbnail_url per file too, not just the details page', function () {
$staff = User::factory()->create();
$group = Group::query()->create(['name' => 'Open Group', 'public' => true]);
$image = publicListingImageFile($staff);
$this->actingAs($staff)->post("/files/{$image->id}/assignments", ['type' => 'group', 'id' => $group->id]);
$pdf = publicListingFile();
auth()->logout();
$indexProps = publicPageProps($this->get('/public'));
$pdfEntry = collect($indexProps['files'])->firstWhere('name', $pdf->name);
expect($pdfEntry)->not->toBeNull()
->and($pdfEntry['thumbnail_url'])->toBeNull();
$groupProps = publicPageProps($this->get("/public/{$group->slug}"));
expect(collect($groupProps['files'])->firstWhere('name', $image->name)['thumbnail_url'] ?? null)
->toBe(route('public.thumbnail', ['public', $image->slug]));
});
test('a non-public file\'s details page 404s', function () {
$file = publicListingFile(['public' => false]);
$this->get(route('public.file', ['public', $file->slug]))->assertNotFound();
});
test('the public thumbnail route generates and serves a thumbnail for a public image, and 404s otherwise', function () {
$staff = User::factory()->create();
$image = publicListingImageFile($staff);
$pdf = publicListingFile();
$privateImage = publicListingImageFile($staff);
$privateImage->update(['public' => false]);
auth()->logout();
$this->get(route('public.thumbnail', ['public', $image->slug]))
->assertOk()
->assertHeader('Content-Type', 'image/jpeg');
// The external variant, and only that one: a public visitor is never
// staff, and caching their thumbnail where a staff request would look
// for it would hand the staff file manager whatever a public listener
// drew on it (the cloud-modules watermark, today).
expect(Storage::disk('files')->exists("thumbnails/external/{$image->id}.jpg"))->toBeTrue()
->and(Storage::disk('files')->exists("thumbnails/{$image->id}.jpg"))->toBeFalse();
$this->get(route('public.thumbnail', ['public', $pdf->slug]))->assertNotFound();
$this->get(route('public.thumbnail', ['public', $privateImage->slug]))->assertNotFound();
});
test('a public thumbnail renders from external storage rather than a local path that does not exist', function () {
// The bug this covers: this route read its *source* through
// Storage::disk('files')->path(), which for an externally stored file
// is a path nothing ever wrote. The rendition is still cached locally
// — only the source moves. FileThumbnailController already handled
// this; the public twin did not.
Storage::fake('files_external');
$staff = User::factory()->create();
$image = publicListingImageFile($staff);
// Restage the bytes where an install with external storage configured
// would have put them, and remove the local copy so a local path
// cannot accidentally satisfy the request.
Storage::disk('files_external')->put($image->path, Storage::disk('files')->get($image->path));
Storage::disk('files')->delete($image->path);
$image->update(['disk' => 'files_external']);
auth()->logout();
$this->get(route('public.thumbnail', ['public', $image->slug]))
->assertOk()
->assertHeader('Content-Type', 'image/jpeg');
expect(Storage::disk('files')->exists("thumbnails/external/{$image->id}.jpg"))->toBeTrue();
});
test('existing literal routes are unaffected by the new catch-all public routes', function () {
$this->actingAs(User::factory()->create())->get('/dashboard')->assertOk();
$this->actingAs(User::factory()->create())->get('/files')->assertOk();
});
test('a public file carries its categories on the directory, a group page, and its own details page', function () {
// The /categories screen tells admins that everyone who can reach a
// file sees the labels on it. That is only true if every guest-facing
// surface actually sends them — this is the test that keeps it true.
$category = Category::query()->create(['name' => 'Tenders', 'color' => 'blue']);
$group = Group::query()->create(['name' => 'Open Group', 'public' => true]);
$staff = User::factory()->create();
$standalone = publicListingFile(['name' => 'Standalone']);
$standalone->categories()->attach($category->id);
$inGroup = publicListingFile(['name' => 'In Group']);
$inGroup->categories()->attach($category->id);
$this->actingAs($staff)->post("/files/{$inGroup->id}/assignments", ['type' => 'group', 'id' => $group->id]);
auth()->logout();
$expected = [['id' => $category->id, 'name' => 'Tenders', 'color' => 'blue']];
$indexProps = publicPageProps($this->get('/public'));
expect(collect($indexProps['files'])->firstWhere('name', 'Standalone')['categories'] ?? null)->toBe($expected);
$groupProps = publicPageProps($this->get("/public/{$group->slug}"));
expect(collect($groupProps['files'])->firstWhere('name', 'In Group')['categories'] ?? null)->toBe($expected);
$this->get(route('public.file', ['public', $standalone->slug]))
->assertInertia(fn ($page) => $page->where('file.categories', $expected));
});
test('an uncategorised public file sends an empty list, not a missing key', function () {
$file = publicListingFile(['name' => 'Bare']);
$props = publicPageProps($this->get('/public'));
expect(collect($props['files'])->firstWhere('name', 'Bare')['categories'] ?? null)->toBe([]);
$this->get(route('public.file', ['public', $file->slug]))
->assertInertia(fn ($page) => $page->where('file.categories', []));
});
test('the public listings load categories in one query instead of one per row', function () {
$category = Category::query()->create(['name' => 'Tenders']);
foreach (range(1, 5) as $i) {
publicListingFile(['name' => "File {$i}"])->categories()->attach($category->id);
}
DB::enableQueryLog();
$this->get('/public')->assertOk();
$categoryQueries = collect(DB::getQueryLog())
->filter(fn (array $query): bool => str_contains($query['query'], 'category_file'))
->count();
DB::disableQueryLog();
expect($categoryQueries)->toBe(1);
});