From c2060554ab7fc7e925652a861ec24f736acc4c3b Mon Sep 17 00:00:00 2001 From: Lance Pioch Date: Wed, 14 Jan 2026 11:51:24 -0500 Subject: [PATCH] notify about completed backups --- app/Events/Backup/BackupCompleted.php | 19 ++ .../Remote/Backups/BackupStatusController.php | 6 + .../Backup/BackupCompletedListener.php | 40 +++ app/Notifications/BackupCompleted.php | 35 +++ app/Services/Helpers/PluginService.php | 8 +- config/panel.php | 2 + lang/en/notifications.php | 3 + .../BackupCompletionNotificationTest.php | 206 +++++++++++++ .../Events/Backup/BackupCompletedTest.php | 52 ++++ .../Backup/BackupCompletedListenerTest.php | 270 ++++++++++++++++++ .../Notifications/BackupCompletedTest.php | 157 ++++++++++ 11 files changed, 794 insertions(+), 4 deletions(-) create mode 100644 app/Events/Backup/BackupCompleted.php create mode 100644 app/Listeners/Backup/BackupCompletedListener.php create mode 100644 app/Notifications/BackupCompleted.php create mode 100644 tests/Integration/Api/Remote/Backups/BackupCompletionNotificationTest.php create mode 100644 tests/Unit/Events/Backup/BackupCompletedTest.php create mode 100644 tests/Unit/Listeners/Backup/BackupCompletedListenerTest.php create mode 100644 tests/Unit/Notifications/BackupCompletedTest.php diff --git a/app/Events/Backup/BackupCompleted.php b/app/Events/Backup/BackupCompleted.php new file mode 100644 index 000000000..95f0058ca --- /dev/null +++ b/app/Events/Backup/BackupCompleted.php @@ -0,0 +1,19 @@ +boolean('successful')) { + event(new BackupCompleted($model, $server, $server->user)); + } + return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); } diff --git a/app/Listeners/Backup/BackupCompletedListener.php b/app/Listeners/Backup/BackupCompletedListener.php new file mode 100644 index 000000000..547d6f1a0 --- /dev/null +++ b/app/Listeners/Backup/BackupCompletedListener.php @@ -0,0 +1,40 @@ +backup->loadMissing('server'); + $event->owner->loadMissing('language'); + + $locale = $event->owner->language ?? 'en'; + + Notification::make() + ->success() + ->title(trans('notifications.backup_completed', locale: $locale)) + ->body(trans('notifications.backup_name_and_server', [ + 'backup' => $event->backup->name, + 'server' => $event->server->name, + ], $locale)) + ->actions([ + Action::make('view') + ->button() + ->label(trans('notifications.view_backups', locale: $locale)) + ->markAsRead() + ->url(fn () => ListBackups::getUrl(panel: 'server', tenant: $event->server)), + ]) + ->sendToDatabase($event->owner); + + if (config()->get('panel.email.send_backup_notification', true)) { + $event->owner->notify(new BackupCompletedNotification($event->backup)); + } + } +} diff --git a/app/Notifications/BackupCompleted.php b/app/Notifications/BackupCompleted.php new file mode 100644 index 000000000..cab644acf --- /dev/null +++ b/app/Notifications/BackupCompleted.php @@ -0,0 +1,35 @@ +greeting('Hello ' . $notifiable->username . '.') + ->line('Your backup has finished and is now ready.') + ->line('Backup Name: ' . $this->backup->name) + ->line('Server Name: ' . $this->backup->server->name) + ->line('Size: ' . convert_bytes_to_readable($this->backup->bytes)) + ->action('View Backups', ListBackups::getUrl(panel: 'server', tenant: $this->backup->server)); + } +} diff --git a/app/Services/Helpers/PluginService.php b/app/Services/Helpers/PluginService.php index a6ff9f06a..569efba52 100644 --- a/app/Services/Helpers/PluginService.php +++ b/app/Services/Helpers/PluginService.php @@ -257,12 +257,12 @@ class PluginService } } - public function buildAssets(): bool + public function buildAssets(bool $throw = false): bool { try { $result = Process::path(base_path())->timeout(300)->run('yarn install'); if ($result->failed()) { - throw new Exception('Could not install dependencies: ' . $result->errorOutput()); + throw new Exception('Could not install yarn dependencies: ' . $result->errorOutput()); } $result = Process::path(base_path())->timeout(600)->run('yarn build'); @@ -272,7 +272,7 @@ class PluginService return true; } catch (Exception $exception) { - if ($this->isDevModeActive()) { + if ($throw || $this->isDevModeActive()) { throw ($exception); } @@ -296,7 +296,7 @@ class PluginService } } - $this->buildAssets(); + $this->buildAssets($plugin->isTheme()); $this->runPluginMigrations($plugin); diff --git a/config/panel.php b/config/panel.php index 848b4761d..e5506d023 100644 --- a/config/panel.php +++ b/config/panel.php @@ -48,6 +48,8 @@ return [ 'send_install_notification' => env('PANEL_SEND_INSTALL_NOTIFICATION', true), // Should an email be sent to a server owner whenever their server is reinstalled? 'send_reinstall_notification' => env('PANEL_SEND_REINSTALL_NOTIFICATION', true), + // Should an email be sent to a server owner whenever their server completes a backup? + 'send_backup_notification' => env('PANEL_SEND_BACKUP_NOTIFICATION', true), ], 'filament' => [ diff --git a/lang/en/notifications.php b/lang/en/notifications.php index 5005259f2..bf6921eca 100644 --- a/lang/en/notifications.php +++ b/lang/en/notifications.php @@ -6,6 +6,9 @@ return [ 'installation_failed' => 'Server Installation Failed', 'reinstallation_completed' => 'Server Reinstallation Completed', 'reinstallation_failed' => 'Server Reinstallation Failed', + 'backup_completed' => 'Backup Completed', + 'backup_name_and_server' => 'Backup :backup on server :server has completed.', + 'view_backups' => 'View Backups', 'failed' => 'Failed', 'user_added' => [ 'title' => 'Added to Server', diff --git a/tests/Integration/Api/Remote/Backups/BackupCompletionNotificationTest.php b/tests/Integration/Api/Remote/Backups/BackupCompletionNotificationTest.php new file mode 100644 index 000000000..0e1a19283 --- /dev/null +++ b/tests/Integration/Api/Remote/Backups/BackupCompletionNotificationTest.php @@ -0,0 +1,206 @@ +generateTestAccount(); + $this->node = $server->node; + } + + public function test_backup_completed_event_is_fired_on_successful_completion(): void + { + Event::fake([BackupCompleted::class]); + + [$user, $server] = $this->generateTestAccount(); + + $backup = Backup::factory()->create([ + 'server_id' => $server->id, + 'is_successful' => false, + 'completed_at' => null, + ]); + + $this->withNodeAuthorization($server->node) + ->postJson("/api/remote/backups/{$backup->uuid}", [ + 'successful' => true, + 'checksum' => 'abc123', + 'checksum_type' => 'sha256', + 'size' => 1024000, + ]) + ->assertNoContent(); + + Event::assertDispatched(BackupCompleted::class, function ($event) use ($backup, $server) { + return $event->backup->id === $backup->id + && $event->server->id === $server->id + && $event->owner->id === $server->user->id; + }); + } + + public function test_event_not_fired_on_failed_backup(): void + { + Event::fake([BackupCompleted::class]); + + [$user, $server] = $this->generateTestAccount(); + + $backup = Backup::factory()->create([ + 'server_id' => $server->id, + 'is_successful' => false, + 'completed_at' => null, + ]); + + $this->withNodeAuthorization($server->node) + ->postJson("/api/remote/backups/{$backup->uuid}", [ + 'successful' => false, + ]) + ->assertNoContent(); + + Event::assertNotDispatched(BackupCompleted::class); + } + + public function test_panel_notification_created_on_backup_completion(): void + { + // Don't fake events for this test - we want the full flow + Event::fake([]); + + [$user, $server] = $this->generateTestAccount(); + + $backup = Backup::factory()->create([ + 'server_id' => $server->id, + 'is_successful' => false, + 'completed_at' => null, + ]); + + $this->withNodeAuthorization($server->node) + ->postJson("/api/remote/backups/{$backup->uuid}", [ + 'successful' => true, + 'checksum' => 'abc123', + 'checksum_type' => 'sha256', + 'size' => 1024000, + ]) + ->assertNoContent(); + + // Verify notification exists in database + $this->assertDatabaseHas('notifications', [ + 'notifiable_type' => 'App\Models\User', + 'notifiable_id' => $server->user->id, + 'type' => 'Filament\Notifications\DatabaseNotification', + ]); + } + + public function test_email_notification_sent_when_config_enabled(): void + { + Notification::fake(); + Event::fake([]); // Clear event fakes to allow real dispatch + Config::set('panel.email.send_backup_notification', true); + + [$user, $server] = $this->generateTestAccount(); + + $backup = Backup::factory()->create([ + 'server_id' => $server->id, + 'is_successful' => false, + 'completed_at' => null, + ]); + + $this->withNodeAuthorization($server->node) + ->postJson("/api/remote/backups/{$backup->uuid}", [ + 'successful' => true, + 'checksum' => 'abc123', + 'checksum_type' => 'sha256', + 'size' => 1024000, + ]) + ->assertNoContent(); + + Notification::assertSentTo( + [$server->user], + BackupCompletedNotification::class, + function ($notification, $channels) use ($backup) { + return $notification->backup->id === $backup->id; + } + ); + } + + public function test_email_notification_not_sent_when_config_disabled(): void + { + Notification::fake(); + Event::fake([]); // Clear event fakes to allow real dispatch + Config::set('panel.email.send_backup_notification', false); + + [$user, $server] = $this->generateTestAccount(); + + $backup = Backup::factory()->create([ + 'server_id' => $server->id, + 'is_successful' => false, + 'completed_at' => null, + ]); + + $this->withNodeAuthorization($server->node) + ->postJson("/api/remote/backups/{$backup->uuid}", [ + 'successful' => true, + 'checksum' => 'abc123', + 'checksum_type' => 'sha256', + 'size' => 1024000, + ]) + ->assertNoContent(); + + // Email notification should not be sent when config is disabled + Notification::assertNothingSent(); + } + + public function test_backup_model_updated_correctly_on_successful_completion(): void + { + Event::fake([BackupCompleted::class]); + + [$user, $server] = $this->generateTestAccount(); + + $backup = Backup::factory()->create([ + 'server_id' => $server->id, + 'is_successful' => false, + 'completed_at' => null, + 'checksum' => null, + 'bytes' => 0, + ]); + + $this->withNodeAuthorization($server->node) + ->postJson("/api/remote/backups/{$backup->uuid}", [ + 'successful' => true, + 'checksum' => 'abc123', + 'checksum_type' => 'sha256', + 'size' => 1024000, + ]) + ->assertNoContent(); + + $backup->refresh(); + + $this->assertTrue($backup->is_successful); + $this->assertEquals('sha256:abc123', $backup->checksum); + $this->assertEquals(1024000, $backup->bytes); + $this->assertNotNull($backup->completed_at); + } + + /** + * Sets the authorization header for node authentication. + */ + protected function withNodeAuthorization(Node $node): self + { + $token = $node->daemon_token_id . '.' . $node->daemon_token; + + $this->withHeader('Authorization', 'Bearer ' . $token); + + return $this; + } +} diff --git a/tests/Unit/Events/Backup/BackupCompletedTest.php b/tests/Unit/Events/Backup/BackupCompletedTest.php new file mode 100644 index 000000000..bee665179 --- /dev/null +++ b/tests/Unit/Events/Backup/BackupCompletedTest.php @@ -0,0 +1,52 @@ +make(); + $server = Server::factory()->make(); + $user = User::factory()->make(); + + $event = new BackupCompleted($backup, $server, $user); + + $this->assertInstanceOf(BackupCompleted::class, $event); + } + + /** + * Test that the event properties are accessible. + */ + public function test_event_properties_are_accessible(): void + { + $backup = Backup::factory()->make(); + $server = Server::factory()->make(); + $user = User::factory()->make(); + + $event = new BackupCompleted($backup, $server, $user); + + $this->assertSame($backup, $event->backup); + $this->assertSame($server, $event->server); + $this->assertSame($user, $event->owner); + } + + /** + * Test that the event uses SerializesModels trait. + */ + public function test_event_uses_serializes_models_trait(): void + { + $traits = class_uses(BackupCompleted::class); + + $this->assertContains('Illuminate\Queue\SerializesModels', $traits); + } +} diff --git a/tests/Unit/Listeners/Backup/BackupCompletedListenerTest.php b/tests/Unit/Listeners/Backup/BackupCompletedListenerTest.php new file mode 100644 index 000000000..dfafb1caa --- /dev/null +++ b/tests/Unit/Listeners/Backup/BackupCompletedListenerTest.php @@ -0,0 +1,270 @@ +assertInstanceOf(BackupCompletedListener::class, $listener); + } + + /** + * Test that the listener sends a panel notification to the user. + */ + public function test_listener_sends_panel_notification(): void + { + // Create test models + $user = User::factory()->make(['id' => 1, 'language' => 'en']); + $server = Server::factory()->make(['id' => 1, 'name' => 'Test Server']); + $backup = Backup::factory()->make([ + 'id' => 1, + 'name' => 'test-backup.tar.gz', + 'bytes' => 1024000, + ]); + + // Set up relationships + $backup->setRelation('server', $server); + $user->setRelation('language', 'en'); + + // Create event + $event = new BackupCompleted($backup, $server, $user); + + // Mock Notification facade + Notification::shouldReceive('make') + ->once() + ->andReturnSelf(); + Notification::shouldReceive('success') + ->once() + ->andReturnSelf(); + Notification::shouldReceive('title') + ->once() + ->with(m::type('string')) + ->andReturnSelf(); + Notification::shouldReceive('body') + ->once() + ->with(m::type('string')) + ->andReturnSelf(); + Notification::shouldReceive('actions') + ->once() + ->with(m::type('array')) + ->andReturnSelf(); + Notification::shouldReceive('sendToDatabase') + ->once() + ->with($user) + ->andReturnNull(); + + // Mock config to disable email notification for this test + Config::shouldReceive('get') + ->with('panel.email.send_backup_notification', true) + ->once() + ->andReturn(false); + + // Handle the event + $listener = new BackupCompletedListener(); + $listener->handle($event); + } + + /** + * Test that the listener sends email notification when config is enabled. + */ + public function test_listener_sends_email_when_config_enabled(): void + { + // Create test models with notification spy + $user = m::mock(User::class)->makePartial(); + $user->shouldReceive('getAttribute')->with('language')->andReturn('en'); + $user->shouldReceive('loadMissing')->with('language')->andReturnSelf(); + + $server = Server::factory()->make(['id' => 1, 'name' => 'Test Server']); + $backup = Backup::factory()->make([ + 'id' => 1, + 'name' => 'test-backup.tar.gz', + 'bytes' => 1024000, + ]); + + // Set up relationships + $backup->setRelation('server', $server); + $backup->shouldReceive('loadMissing')->with('server')->andReturnSelf(); + + // Create event + $event = new BackupCompleted($backup, $server, $user); + + // Mock Notification facade + Notification::shouldReceive('make')->andReturnSelf(); + Notification::shouldReceive('success')->andReturnSelf(); + Notification::shouldReceive('title')->andReturnSelf(); + Notification::shouldReceive('body')->andReturnSelf(); + Notification::shouldReceive('actions')->andReturnSelf(); + Notification::shouldReceive('sendToDatabase')->andReturnNull(); + + // Mock config to enable email notification + Config::shouldReceive('get') + ->with('panel.email.send_backup_notification', true) + ->once() + ->andReturn(true); + + // Expect notify to be called with BackupCompletedNotification + $user->shouldReceive('notify') + ->once() + ->with(m::type(BackupCompletedNotification::class)) + ->andReturnNull(); + + // Handle the event + $listener = new BackupCompletedListener(); + $listener->handle($event); + } + + /** + * Test that the listener does not send email notification when config is disabled. + */ + public function test_listener_does_not_send_email_when_config_disabled(): void + { + // Create test models + $user = m::mock(User::class)->makePartial(); + $user->shouldReceive('getAttribute')->with('language')->andReturn('en'); + $user->shouldReceive('loadMissing')->with('language')->andReturnSelf(); + + $server = Server::factory()->make(['id' => 1, 'name' => 'Test Server']); + $backup = Backup::factory()->make([ + 'id' => 1, + 'name' => 'test-backup.tar.gz', + 'bytes' => 1024000, + ]); + + // Set up relationships + $backup->setRelation('server', $server); + $backup->shouldReceive('loadMissing')->with('server')->andReturnSelf(); + + // Create event + $event = new BackupCompleted($backup, $server, $user); + + // Mock Notification facade + Notification::shouldReceive('make')->andReturnSelf(); + Notification::shouldReceive('success')->andReturnSelf(); + Notification::shouldReceive('title')->andReturnSelf(); + Notification::shouldReceive('body')->andReturnSelf(); + Notification::shouldReceive('actions')->andReturnSelf(); + Notification::shouldReceive('sendToDatabase')->andReturnNull(); + + // Mock config to disable email notification + Config::shouldReceive('get') + ->with('panel.email.send_backup_notification', true) + ->once() + ->andReturn(false); + + // Expect notify to NOT be called + $user->shouldNotReceive('notify'); + + // Handle the event + $listener = new BackupCompletedListener(); + $listener->handle($event); + } + + /** + * Test that the listener handles null language gracefully. + */ + public function test_listener_handles_null_language(): void + { + // Create test models with null language + $user = User::factory()->make(['id' => 1, 'language' => null]); + $server = Server::factory()->make(['id' => 1, 'name' => 'Test Server']); + $backup = Backup::factory()->make([ + 'id' => 1, + 'name' => 'test-backup.tar.gz', + 'bytes' => 1024000, + ]); + + // Set up relationships + $backup->setRelation('server', $server); + $user->setRelation('language', null); + + // Create event + $event = new BackupCompleted($backup, $server, $user); + + // Mock Notification facade - should still work with default locale + Notification::shouldReceive('make')->once()->andReturnSelf(); + Notification::shouldReceive('success')->once()->andReturnSelf(); + Notification::shouldReceive('title')->once()->andReturnSelf(); + Notification::shouldReceive('body')->once()->andReturnSelf(); + Notification::shouldReceive('actions')->once()->andReturnSelf(); + Notification::shouldReceive('sendToDatabase')->once()->andReturnNull(); + + // Mock config + Config::shouldReceive('get') + ->with('panel.email.send_backup_notification', true) + ->once() + ->andReturn(false); + + // Handle the event - should not throw exception + $listener = new BackupCompletedListener(); + $listener->handle($event); + + // If we get here without exception, the test passes + $this->assertTrue(true); + } + + /** + * Test that the listener loads missing relationships. + */ + public function test_listener_loads_missing_relationships(): void + { + // Create mocked models to verify loadMissing calls + $user = m::mock(User::class)->makePartial(); + $server = Server::factory()->make(['id' => 1, 'name' => 'Test Server']); + $backup = m::mock(Backup::class)->makePartial(); + + // Expect loadMissing to be called + $backup->shouldReceive('loadMissing') + ->once() + ->with('server') + ->andReturnSelf(); + + $user->shouldReceive('loadMissing') + ->once() + ->with('language') + ->andReturnSelf(); + + $user->shouldReceive('getAttribute')->with('language')->andReturn('en'); + $backup->shouldReceive('getAttribute')->with('server')->andReturn($server); + $backup->shouldReceive('getAttribute')->with('name')->andReturn('test-backup.tar.gz'); + + // Create event + $event = new BackupCompleted($backup, $server, $user); + + // Mock Notification facade + Notification::shouldReceive('make')->andReturnSelf(); + Notification::shouldReceive('success')->andReturnSelf(); + Notification::shouldReceive('title')->andReturnSelf(); + Notification::shouldReceive('body')->andReturnSelf(); + Notification::shouldReceive('actions')->andReturnSelf(); + Notification::shouldReceive('sendToDatabase')->andReturnNull(); + + // Mock config + Config::shouldReceive('get') + ->with('panel.email.send_backup_notification', true) + ->once() + ->andReturn(false); + + // Handle the event + $listener = new BackupCompletedListener(); + $listener->handle($event); + + // Mockery will automatically verify the expectations + } +} diff --git a/tests/Unit/Notifications/BackupCompletedTest.php b/tests/Unit/Notifications/BackupCompletedTest.php new file mode 100644 index 000000000..e06786eea --- /dev/null +++ b/tests/Unit/Notifications/BackupCompletedTest.php @@ -0,0 +1,157 @@ +make(); + + $notification = new BackupCompleted($backup); + + $this->assertInstanceOf(BackupCompleted::class, $notification); + } + + /** + * Test that the notification properties are accessible. + */ + public function test_notification_properties_are_accessible(): void + { + $backup = Backup::factory()->make(); + + $notification = new BackupCompleted($backup); + + $this->assertSame($backup, $notification->backup); + } + + /** + * Test that the notification implements ShouldQueue. + */ + public function test_notification_implements_should_queue(): void + { + $this->assertContains(ShouldQueue::class, class_implements(BackupCompleted::class)); + } + + /** + * Test that the notification uses Queueable trait. + */ + public function test_notification_uses_queueable_trait(): void + { + $traits = class_uses(BackupCompleted::class); + + $this->assertContains('Illuminate\Bus\Queueable', $traits); + } + + /** + * Test that via method returns mail channel. + */ + public function test_via_method_returns_mail_channel(): void + { + $backup = Backup::factory()->make(); + + $notification = new BackupCompleted($backup); + + $this->assertEquals(['mail'], $notification->via()); + } + + /** + * Test that toMail returns a MailMessage instance. + */ + public function test_to_mail_returns_mail_message(): void + { + $server = Server::factory()->make(['name' => 'Test Server']); + $backup = Backup::factory()->make([ + 'name' => 'test-backup.tar.gz', + 'bytes' => 1024000, + ]); + $backup->setRelation('server', $server); + + $user = User::factory()->make(['username' => 'testuser']); + + $notification = new BackupCompleted($backup); + $mailMessage = $notification->toMail($user); + + $this->assertInstanceOf(MailMessage::class, $mailMessage); + } + + /** + * Test that toMail includes correct greeting. + */ + public function test_to_mail_includes_greeting(): void + { + $server = Server::factory()->make(['name' => 'Test Server']); + $backup = Backup::factory()->make([ + 'name' => 'test-backup.tar.gz', + 'bytes' => 1024000, + ]); + $backup->setRelation('server', $server); + + $user = User::factory()->make(['username' => 'testuser']); + + $notification = new BackupCompleted($backup); + $mailMessage = $notification->toMail($user); + + $this->assertEquals('Hello testuser.', $mailMessage->greeting); + } + + /** + * Test that toMail includes backup details. + */ + public function test_to_mail_includes_backup_details(): void + { + $server = Server::factory()->make(['name' => 'Test Server']); + $backup = Backup::factory()->make([ + 'name' => 'test-backup.tar.gz', + 'bytes' => 1024000, + ]); + $backup->setRelation('server', $server); + + $user = User::factory()->make(['username' => 'testuser']); + + $notification = new BackupCompleted($backup); + $mailMessage = $notification->toMail($user); + + // Check that the intro lines contain the expected information + $this->assertContains('Your backup has finished and is now ready.', $mailMessage->introLines); + $this->assertContains('Backup Name: test-backup.tar.gz', $mailMessage->introLines); + $this->assertContains('Server Name: Test Server', $mailMessage->introLines); + $this->assertContains('Size: ' . convert_bytes_to_readable(1024000), $mailMessage->introLines); + } + + /** + * Test that toMail includes action button. + */ + public function test_to_mail_includes_action_button(): void + { + $server = Server::factory()->make([ + 'id' => 1, + 'name' => 'Test Server', + ]); + $backup = Backup::factory()->make([ + 'name' => 'test-backup.tar.gz', + 'bytes' => 1024000, + ]); + $backup->setRelation('server', $server); + + $user = User::factory()->make(['username' => 'testuser']); + + $notification = new BackupCompleted($backup); + $mailMessage = $notification->toMail($user); + + // Check that action button exists + $this->assertEquals('View Backups', $mailMessage->actionText); + $this->assertNotEmpty($mailMessage->actionUrl); + } +}