Compare commits

...

1 Commits

Author SHA1 Message Date
Lance Pioch
c2060554ab notify about completed backups 2026-01-14 11:51:24 -05:00
11 changed files with 794 additions and 4 deletions

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Events\Backup;
use App\Events\Event;
use App\Models\Backup;
use App\Models\Server;
use App\Models\User;
use Illuminate\Queue\SerializesModels;
class BackupCompleted extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Backup $backup, public Server $server, public User $owner) {}
}

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Remote\Backups;
use App\Events\Backup\BackupCompleted;
use App\Exceptions\DisplayException;
use App\Exceptions\Http\HttpForbiddenException;
use App\Extensions\Backups\BackupManager;
@@ -79,6 +80,11 @@ class BackupStatusController extends Controller
}
});
// Fire the BackupCompleted event for successful backups.
if ($request->boolean('successful')) {
event(new BackupCompleted($model, $server, $server->user));
}
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Listeners\Backup;
use App\Events\Backup\BackupCompleted;
use App\Filament\Server\Resources\Backups\Pages\ListBackups;
use App\Notifications\BackupCompleted as BackupCompletedNotification;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
class BackupCompletedListener
{
public function handle(BackupCompleted $event): void
{
$event->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));
}
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Notifications;
use App\Filament\Server\Resources\Backups\Pages\ListBackups;
use App\Models\Backup;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class BackupCompleted extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(public Backup $backup) {}
/** @return string[] */
public function via(): array
{
return ['mail'];
}
public function toMail(User $notifiable): MailMessage
{
return (new MailMessage())
->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));
}
}

View File

@@ -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);

View File

@@ -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' => [

View File

@@ -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',

View File

@@ -0,0 +1,206 @@
<?php
namespace App\Tests\Integration\Api\Remote\Backups;
use App\Events\Backup\BackupCompleted;
use App\Models\Backup;
use App\Models\Node;
use App\Notifications\BackupCompleted as BackupCompletedNotification;
use App\Tests\Integration\IntegrationTestCase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Notification;
class BackupCompletionNotificationTest extends IntegrationTestCase
{
private Node $node;
protected function setUp(): void
{
parent::setUp();
[$user, $server] = $this->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;
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Tests\Unit\Events\Backup;
use App\Events\Backup\BackupCompleted;
use App\Models\Backup;
use App\Models\Server;
use App\Models\User;
use App\Tests\TestCase;
class BackupCompletedTest extends TestCase
{
/**
* Test that the event can be instantiated with required parameters.
*/
public function test_event_can_be_instantiated(): void
{
$backup = Backup::factory()->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);
}
}

View File

@@ -0,0 +1,270 @@
<?php
namespace App\Tests\Unit\Listeners\Backup;
use App\Events\Backup\BackupCompleted;
use App\Listeners\Backup\BackupCompletedListener;
use App\Models\Backup;
use App\Models\Server;
use App\Models\User;
use App\Notifications\BackupCompleted as BackupCompletedNotification;
use App\Tests\TestCase;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Config;
use Mockery as m;
class BackupCompletedListenerTest extends TestCase
{
/**
* Test that the listener can be instantiated.
*/
public function test_listener_can_be_instantiated(): void
{
$listener = new BackupCompletedListener();
$this->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
}
}

View File

@@ -0,0 +1,157 @@
<?php
namespace App\Tests\Unit\Notifications;
use App\Models\Backup;
use App\Models\Server;
use App\Models\User;
use App\Notifications\BackupCompleted;
use App\Tests\TestCase;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
class BackupCompletedTest extends TestCase
{
/**
* Test that the notification can be instantiated.
*/
public function test_notification_can_be_instantiated(): void
{
$backup = Backup::factory()->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);
}
}