mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 00:55:07 +00:00
c21658f6f7
Staff can give a client an expiry date on the create and edit screens, and through /api/v1/clients. When the date passes, the client is refused at sign-in and on their next request, and their API access ends too. Files and history stay, and a later date (or none) brings them back. Access is checked through one predicate, User::maySignIn(), at every door: sign-in, the web session, API tokens and the two-factor challenge. An hourly sweep also switches `active` off, so the list, its filter and seat counts agree. The sweep is not what enforces it, so a scheduler that is not running cannot keep an account open. An account cannot be active with a date that has passed. Reactivating an expired client needs a new date in the same save. The day-means-end-of-day-where-you-are rule moved out of FileExpiry into a shared DateInput, so file and account expiry read dates the same way. Requested by @Drardollan in #1310.
225 lines
9.0 KiB
PHP
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());
|
|
});
|