From f9f53f2fea1a775c51aae101b02cc3b512c134ff Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Thu, 27 Aug 2026 04:36:12 +0000 Subject: [PATCH 1/4] fix(notifications): implement toWebhook() for always-send notifications WebhookChannel::send() calls toWebhook() unconditionally, but the five notifications reachable through alwaysSendEvents did not implement it, so enabling the webhook channel turned those events into fatal queued jobs. --- .../ApiTokenExpiringNotification.php | 12 ++ .../Internal/GeneralNotification.php | 10 ++ app/Notifications/Server/ForceDisabled.php | 12 ++ app/Notifications/Server/ForceEnabled.php | 12 ++ .../SslExpirationNotification.php | 14 +++ .../Channels/WebhookChannelTest.php | 113 ++++++++++++++++++ 6 files changed, 173 insertions(+) create mode 100644 tests/Unit/Notifications/Channels/WebhookChannelTest.php diff --git a/app/Notifications/ApiTokenExpiringNotification.php b/app/Notifications/ApiTokenExpiringNotification.php index 451dd312a..c5567a3a6 100644 --- a/app/Notifications/ApiTokenExpiringNotification.php +++ b/app/Notifications/ApiTokenExpiringNotification.php @@ -100,4 +100,16 @@ public function toSlack(): SlackMessage color: SlackMessage::warningColor(), ); } + + public function toWebhook(): array + { + return [ + 'success' => false, + 'message' => "API token '{$this->tokenName}' expires on {$this->expiresAt}. Rotate this token before it expires to avoid API outages.", + 'event' => 'api_token_expiring', + 'token_name' => $this->tokenName, + 'expires_at' => $this->expiresAt, + 'url' => $this->manageUrl, + ]; + } } diff --git a/app/Notifications/Internal/GeneralNotification.php b/app/Notifications/Internal/GeneralNotification.php index 1d2367210..52e986ed6 100644 --- a/app/Notifications/Internal/GeneralNotification.php +++ b/app/Notifications/Internal/GeneralNotification.php @@ -58,4 +58,14 @@ public function toSlack(): SlackMessage color: SlackMessage::infoColor(), ); } + + public function toWebhook(): array + { + return [ + 'success' => true, + 'message' => $this->message, + 'event' => 'general', + 'url' => base_url(), + ]; + } } diff --git a/app/Notifications/Server/ForceDisabled.php b/app/Notifications/Server/ForceDisabled.php index 4b56f5860..2d2ebabaf 100644 --- a/app/Notifications/Server/ForceDisabled.php +++ b/app/Notifications/Server/ForceDisabled.php @@ -74,4 +74,16 @@ public function toSlack(): SlackMessage color: SlackMessage::errorColor() ); } + + public function toWebhook(): array + { + return [ + 'success' => false, + 'message' => "Server ({$this->server->name}) disabled because it is not paid! All automations and integrations are stopped.", + 'event' => 'server_force_disabled', + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + 'url' => base_url().'/server/'.$this->server->uuid, + ]; + } } diff --git a/app/Notifications/Server/ForceEnabled.php b/app/Notifications/Server/ForceEnabled.php index 36dad3c60..61022d36b 100644 --- a/app/Notifications/Server/ForceEnabled.php +++ b/app/Notifications/Server/ForceEnabled.php @@ -65,4 +65,16 @@ public function toSlack(): SlackMessage color: SlackMessage::successColor() ); } + + public function toWebhook(): array + { + return [ + 'success' => true, + 'message' => "Server ({$this->server->name}) enabled again!", + 'event' => 'server_force_enabled', + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + 'url' => base_url().'/server/'.$this->server->uuid, + ]; + } } diff --git a/app/Notifications/SslExpirationNotification.php b/app/Notifications/SslExpirationNotification.php index 78e1e8be9..73c7a665d 100644 --- a/app/Notifications/SslExpirationNotification.php +++ b/app/Notifications/SslExpirationNotification.php @@ -148,4 +148,18 @@ public function toSlack(): SlackMessage color: SlackMessage::warningColor() ); } + + public function toWebhook(): array + { + $resourceNames = $this->resources->pluck('name'); + + return [ + 'success' => false, + 'message' => "SSL certificates have been renewed for: {$resourceNames->join(', ')}. These resources need to be redeployed manually for the new SSL certificates to take effect.", + 'event' => 'ssl_certificate_renewal', + 'resources' => $resourceNames->values()->all(), + 'urls' => $this->urls, + 'url' => base_url(), + ]; + } } diff --git a/tests/Unit/Notifications/Channels/WebhookChannelTest.php b/tests/Unit/Notifications/Channels/WebhookChannelTest.php new file mode 100644 index 000000000..783159e69 --- /dev/null +++ b/tests/Unit/Notifications/Channels/WebhookChannelTest.php @@ -0,0 +1,113 @@ + InstanceSettings::query()->updateOrCreate(['id' => 0], [ + 'fqdn' => 'https://coolify.example.com', + ])); + Queue::fake(); + + $this->team = Team::create([ + 'name' => 'Webhook Channel Team', + 'personal_team' => false, + 'show_boarding' => false, + ]); + // Assign through the model so the `encrypted` cast on webhook_url is applied. + $settings = $this->team->webhookNotificationSettings; + $settings->webhook_enabled = true; + $settings->webhook_url = 'https://webhook.example.com/coolify'; + $settings->save(); + $this->team->refresh(); +}); + +/** + * Send a notification through the webhook channel and return the dispatched payload. + * + * @return array + */ +function deliverOverWebhook(Team $team, Notification $notification): array +{ + expect($notification->via($team))->toContain(WebhookChannel::class); + + (new WebhookChannel)->send($team, $notification); + + $payload = null; + Queue::assertPushed(SendWebhookJob::class, function (SendWebhookJob $job) use (&$payload) { + $payload = $job->payload; + + return true; + }); + + expect($payload)->toBeArray() + ->and($payload['success'])->toBeBool() + ->and($payload['message'])->toBeString()->not->toBeEmpty(); + + return $payload; +} + +it('delivers ssl certificate renewal notifications over the webhook channel', function () { + $payload = deliverOverWebhook( + $this->team, + new SslExpirationNotification([(object) ['name' => 'my-application']]) + ); + + expect($payload['event'])->toBe('ssl_certificate_renewal') + ->and($payload['resources'])->toBe(['my-application']); +}); + +it('delivers api token expiring notifications over the webhook channel', function () { + $token = new PersonalAccessToken([ + 'name' => 'ci-token', + 'expires_at' => now()->addDay(), + ]); + + $payload = deliverOverWebhook($this->team, new ApiTokenExpiringNotification($token)); + + expect($payload['event'])->toBe('api_token_expiring') + ->and($payload['token_name'])->toBe('ci-token'); +}); + +it('delivers server force enabled notifications over the webhook channel', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + + $payload = deliverOverWebhook($this->team, new ForceEnabled($server)); + + expect($payload['event'])->toBe('server_force_enabled') + ->and($payload['success'])->toBeTrue() + ->and($payload['server_uuid'])->toBe($server->uuid); +}); + +it('delivers server force disabled notifications over the webhook channel', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + + $payload = deliverOverWebhook($this->team, new ForceDisabled($server)); + + expect($payload['event'])->toBe('server_force_disabled') + ->and($payload['success'])->toBeFalse() + ->and($payload['server_uuid'])->toBe($server->uuid); +}); + +it('delivers general notifications over the webhook channel', function () { + $payload = deliverOverWebhook($this->team, new GeneralNotification('Something happened')); + + expect($payload['event'])->toBe('general') + ->and($payload['message'])->toBe('Something happened'); +}); From 27c7dc6a243d5362419e88e60e919829682054de Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:29:14 +0200 Subject: [PATCH 2/4] chore(notifications): remove broken notification interface --- app/Notifications/Notification.php | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100644 app/Notifications/Notification.php diff --git a/app/Notifications/Notification.php b/app/Notifications/Notification.php deleted file mode 100644 index d37716a8b..000000000 --- a/app/Notifications/Notification.php +++ /dev/null @@ -1,22 +0,0 @@ - Date: Sat, 5 Sep 2026 12:32:25 +0200 Subject: [PATCH 3/4] fix(notifications): hetzner deletion failure channel name --- app/Notifications/Server/HetznerDeletionFailed.php | 3 +-- tests/Unit/HetznerDeletionFailedNotificationTest.php | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/Notifications/Server/HetznerDeletionFailed.php b/app/Notifications/Server/HetznerDeletionFailed.php index bb452b054..a109a4360 100644 --- a/app/Notifications/Server/HetznerDeletionFailed.php +++ b/app/Notifications/Server/HetznerDeletionFailed.php @@ -17,8 +17,7 @@ public function __construct(public int $hetznerServerId, public int $teamId, pub public function via(object $notifiable): array { - - return $notifiable->getEnabledChannels('hetzner_deletion_failed'); + return $notifiable->getEnabledChannels('hetzner_deletion_failure'); } public function toMail(): MailMessage diff --git a/tests/Unit/HetznerDeletionFailedNotificationTest.php b/tests/Unit/HetznerDeletionFailedNotificationTest.php index 22d5e80db..bcd3f294e 100644 --- a/tests/Unit/HetznerDeletionFailedNotificationTest.php +++ b/tests/Unit/HetznerDeletionFailedNotificationTest.php @@ -19,7 +19,7 @@ ->and($notification->errorMessage)->toBe('Hetzner API error: Server not found'); }); -it('uses hetzner_deletion_failed event for channels', function () { +it('uses the always-send hetzner_deletion_failure event for channels', function () { $notification = new HetznerDeletionFailed( hetznerServerId: 12345, teamId: 1, @@ -28,7 +28,7 @@ $mockNotifiable = Mockery::mock(); $mockNotifiable->shouldReceive('getEnabledChannels') - ->with('hetzner_deletion_failed') + ->with('hetzner_deletion_failure') ->once() ->andReturn([]); From 9f6ed823ce5d1639ccc7ea5b254c0f6a7a95b877 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:33:06 +0200 Subject: [PATCH 4/4] fix(notifications): add toWebhook payload to Hetzner deletion failure notification --- app/Notifications/Server/HetznerDeletionFailed.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/Notifications/Server/HetznerDeletionFailed.php b/app/Notifications/Server/HetznerDeletionFailed.php index a109a4360..6c2712b68 100644 --- a/app/Notifications/Server/HetznerDeletionFailed.php +++ b/app/Notifications/Server/HetznerDeletionFailed.php @@ -65,4 +65,16 @@ public function toSlack(): SlackMessage color: SlackMessage::errorColor() ); } + + public function toWebhook(): array + { + return [ + 'success' => false, + 'message' => "[ACTION REQUIRED] Failed to delete Hetzner server #{$this->hetznerServerId} from Hetzner Cloud. The server has been removed from Coolify, but may still exist in your Hetzner Cloud account.", + 'event' => 'hetzner_deletion_failed', + 'hetzner_server_id' => $this->hetznerServerId, + 'error' => $this->errorMessage, + 'url' => base_url().'/servers', + ]; + } }