Files
projectsend/tests/Feature/Platform/SchedulerMonitoringTest.php
denkfabrik-li 933eaa2ba4 Send mail through Microsoft Graph as an admin-connected mailbox
Adds "Microsoft 365 (OAuth)" to the Email settings provider dropdown.
Selecting it swaps the SMTP form for an app registration (client id,
secret, optional tenant) and a "Connect mailbox" flow: the admin signs
into the mailbox the installation should send as, and outgoing email
goes through Graph sendMail as that mailbox — no password, no app
password, no SMTP AUTH, which Microsoft is winding down.

Delegated flow on purpose: it needs no admin consent and works for
work/school and personal accounts alike. Its one weakness — a grant
can die silently behind a password reset or a Conditional Access
change — is answered by a daily scheduled refresh that keeps the
token alive and, on a dead grant, warns the settings admins once
in-app and on the settings page instead of letting mail stop quietly.

Tokens and the client secret live encrypted in their own row and are
read fresh at send time, never through the boot-config cache. The
stored SMTP transport survives a provider switch untouched.
2026-08-23 22:46:24 +02: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', 11));
$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());
});