Files
projectsend/tests/Feature/Platform/SchedulerMonitoringTest.php
T
ignacionelson e9496dc357 Give quarantined files a screen, an owner, and somebody to tell
An infected file now goes somewhere rather than nowhere. Staff holding
the new release_quarantined_files permission get a Quarantine screen
listing what was refused, who uploaded it, and what the scanner called
it. They can delete it as they always could, or release it — which
needs a written reason, a password confirmation on top of the
permission, and lands in the activity log under their name.

Only the administrator role holds that permission by default. Deciding
a threat report is wrong is a different judgement from deciding a file
is no longer needed, which is why it is not delete_files.

Two notifications, two audiences: staff who can act on it, and the
person who uploaded it — for whom this is how they learn their own
machine has something on it. The people the file was shared with are
deliberately not told about a file they never received.

`projectsend:scan-files` runs hourly: it re-queues files still waiting,
and re-scans the ones that went out unscanned while the scanner was
unreachable, since it may be back. With --existing it also works
through a library uploaded before scanning was switched on, paced by a
setting so it does not starve today's uploads.

A file that was downloadable before it was caught says so on the
screen, with its download count, because that is the case where
somebody may already have a copy.
2026-09-16 14:29:41 -03:00

225 lines
9.0 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Identity\Models\Role;
use App\Modules\Platform\Capabilities\Edition;
use App\Modules\Platform\Scheduling\ScheduledTaskRun;
use App\Modules\Platform\Scheduling\TaskRunStatus;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Testing\TestResponse;
use Inertia\Testing\AssertableInertia;
beforeEach(function () {
config()->set('projectsend.edition', Edition::Community);
$this->admin = User::factory()->create();
});
/**
* @return array<string, mixed>
*/
function schedulerPageProps(TestResponse $response): array
{
return json_decode(json_encode($response->viewData('page')), true)['props'];
}
test('the scheduler page lists every known command, flagging ones that have never run', function () {
ScheduledTaskRun::query()->create([
'command' => 'projectsend:purge-expired-files',
'status' => TaskRunStatus::Success,
'message' => null,
'duration_ms' => 42,
'ran_at' => now(),
]);
ScheduledTaskRun::query()->create([
'command' => 'projectsend:fetch-news',
'status' => TaskRunStatus::Failed,
'message' => 'Scheduled command [...] failed with exit code [1].',
'duration_ms' => null,
'ran_at' => now(),
]);
$response = $this->actingAs($this->admin)->get('/system/settings/scheduler');
$response->assertInertia(fn (AssertableInertia $page) => $page->component('system/settings/scheduler')->has('tasks', 12));
$tasks = collect(schedulerPageProps($response)['tasks'])->keyBy('command');
expect($tasks->get('projectsend:purge-expired-files')['status'])->toBe('success')
->and($tasks->get('projectsend:purge-expired-files')['duration_ms'])->toBe(42)
->and($tasks->get('projectsend:fetch-news')['status'])->toBe('failed')
->and($tasks->get('projectsend:fetch-news')['message'])->toContain('exit code [1]')
->and($tasks->get('projectsend:purge-orphan-files')['status'])->toBeNull();
});
function makeFailedJob(string $uuid, string $connection = 'database'): void
{
DB::table('failed_jobs')->insert([
'uuid' => $uuid,
'connection' => $connection,
'queue' => 'default',
'payload' => json_encode(['displayName' => 'App\\Fake']),
'exception' => "RuntimeException: boom\n#0 stack trace line",
'failed_at' => now(),
]);
}
test('failed jobs are listed with a pending-jobs count', function () {
$uuid = (string) Str::uuid();
makeFailedJob($uuid);
DB::table('jobs')->insert([
'queue' => 'default',
'payload' => json_encode(['displayName' => 'App\\Fake']),
'attempts' => 0,
'available_at' => now()->timestamp,
'created_at' => now()->timestamp,
]);
$response = $this->actingAs($this->admin)->get('/system/settings/scheduler');
$props = schedulerPageProps($response);
expect($props['pending_jobs_count'])->toBe(1)
->and($props['failed_jobs'])->toHaveCount(1)
->and($props['failed_jobs'][0]['id'])->toBe($uuid)
->and($props['failed_jobs'][0]['exception'])->toBe('RuntimeException: boom');
});
test('delete forgets a failed job', function () {
$uuid = (string) Str::uuid();
makeFailedJob($uuid);
$this->actingAs($this->admin)->delete("/system/settings/scheduler/failed-jobs/{$uuid}")->assertRedirect();
expect(DB::table('failed_jobs')->where('uuid', $uuid)->exists())->toBeFalse();
});
test('the page opens on the tasks tab, and ?tab=failed selects the failed-jobs tab', function () {
$this->actingAs($this->admin)->get('/system/settings/scheduler')
->assertInertia(fn (AssertableInertia $page) => $page->where('tab', 'tasks'));
$this->actingAs($this->admin)->get('/system/settings/scheduler?tab=failed')
->assertInertia(fn (AssertableInertia $page) => $page->where('tab', 'failed'));
});
test('failed jobs are paginated', function () {
for ($i = 0; $i < 25; $i++) {
makeFailedJob((string) Str::uuid());
}
$first = schedulerPageProps($this->actingAs($this->admin)->get('/system/settings/scheduler?tab=failed'));
expect($first['failed_jobs'])->toHaveCount(20)
->and($first['failed_total'])->toBe(25)
->and($first['failed_pagination']['last_page'])->toBe(2)
->and($first['failed_pagination']['page'])->toBe(1);
$second = schedulerPageProps($this->actingAs($this->admin)->get('/system/settings/scheduler?tab=failed&page=2'));
expect($second['failed_jobs'])->toHaveCount(5)
->and($second['failed_pagination']['page'])->toBe(2);
});
test('a page past the end redirects to the failed tab last page', function () {
makeFailedJob((string) Str::uuid());
$this->actingAs($this->admin)->get('/system/settings/scheduler?tab=failed&page=9')
->assertRedirect('/system/settings/scheduler?tab=failed');
});
test('delete all forgets every failed job', function () {
makeFailedJob((string) Str::uuid());
makeFailedJob((string) Str::uuid());
makeFailedJob((string) Str::uuid());
$this->actingAs($this->admin)->delete('/system/settings/scheduler/failed-jobs')->assertRedirect();
expect(DB::table('failed_jobs')->count())->toBe(0);
});
test('retry re-queues the job onto its original connection and forgets the failed row', function () {
// The 'database' connection (config/queue.php) writes back into the
// 'jobs' table via the app's normal DB connection — safe to exercise
// for real in tests, unlike 'redis', which needs a live server.
$uuid = (string) Str::uuid();
makeFailedJob($uuid, 'database');
$this->actingAs($this->admin)->post("/system/settings/scheduler/failed-jobs/{$uuid}/retry")->assertRedirect();
expect(DB::table('failed_jobs')->where('uuid', $uuid)->exists())->toBeFalse()
->and(DB::table('jobs')->count())->toBe(1);
});
test('the whole surface is unavailable on the cloud edition', function () {
config()->set('projectsend.edition', Edition::Cloud);
$this->actingAs($this->admin)->get('/system/settings/scheduler')->assertNotFound();
});
test('clients cannot access the scheduler page', function () {
$this->admin; // setup complete
$this->actingAs(User::factory()->client()->create())
->get('/system/settings/scheduler')
->assertRedirect(route('dashboard'));
});
test('staff without edit_settings cannot access the scheduler page', function () {
$role = Role::query()->create(['name' => 'No Settings', 'is_administrator' => false, 'is_system' => false]);
$staffer = User::factory()->create(['role_id' => $role->id]);
$this->actingAs($staffer)->get('/system/settings/scheduler')->assertForbidden();
});
test('the update check reports what it found, not just that it ran', function () {
config()->set('projectsend.version', '2.0.0');
$settings = app(Settings::class);
$settings->set(Setting::LatestVersionCheckedAt, now()->toIso8601String());
$settings->set(Setting::LatestKnownVersion, '2.5.0');
$response = $this->actingAs($this->admin)->get('/system/settings/scheduler');
$tasks = collect(schedulerPageProps($response)['tasks'])->keyBy('command');
expect($tasks->get('projectsend:check-for-updates')['detail'])->toContain('2.5.0');
});
test('a check that found nothing newer says so', function () {
config()->set('projectsend.version', '2.0.0');
$settings = app(Settings::class);
$settings->set(Setting::LatestVersionCheckedAt, now()->toIso8601String());
$settings->set(Setting::LatestKnownVersion, '2.0.0');
$response = $this->actingAs($this->admin)->get('/system/settings/scheduler');
$tasks = collect(schedulerPageProps($response)['tasks'])->keyBy('command');
expect($tasks->get('projectsend:check-for-updates')['detail'])->toBe('Up to date');
});
test('a check that has never run has nothing to report', function () {
$settings = app(Settings::class);
$settings->set(Setting::LatestVersionCheckedAt, '');
$settings->set(Setting::LatestKnownVersion, '');
$response = $this->actingAs($this->admin)->get('/system/settings/scheduler');
$tasks = collect(schedulerPageProps($response)['tasks'])->keyBy('command');
expect($tasks->get('projectsend:check-for-updates')['detail'])->toBeNull();
});
// The list on the screen and the schedule are two hand-maintained lists of
// the same thing, and they had already drifted once: the API request log
// purge ran nightly without ever appearing here, so a failure of it was
// invisible on the screen built to make failures visible.
test('every scheduled command appears on the screen', function () {
$scheduled = collect(app(Schedule::class)->events())
->map(fn ($event): string => (string) Str::of((string) $event->command)->match('/projectsend:[\w-]+/'))
->filter()
->sort()
->values();
$response = $this->actingAs($this->admin)->get('/system/settings/scheduler');
$listed = collect(schedulerPageProps($response)['tasks'])->pluck('command')->sort()->values();
expect($listed->all())->toBe($scheduled->all());
});