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.
This commit is contained in:
Ousama Ben Younes
2026-08-27 04:45:39 +00:00
parent 29c7b043f3
commit f9f53f2fea
6 changed files with 173 additions and 0 deletions
@@ -100,4 +100,16 @@ class ApiTokenExpiringNotification extends CustomEmailNotification
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,
];
}
}
@@ -58,4 +58,14 @@ class GeneralNotification extends Notification implements ShouldQueue
color: SlackMessage::infoColor(),
);
}
public function toWebhook(): array
{
return [
'success' => true,
'message' => $this->message,
'event' => 'general',
'url' => base_url(),
];
}
}
@@ -74,4 +74,16 @@ class ForceDisabled extends CustomEmailNotification
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,
];
}
}
+12
View File
@@ -65,4 +65,16 @@ class ForceEnabled extends CustomEmailNotification
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,
];
}
}
@@ -148,4 +148,18 @@ class SslExpirationNotification extends CustomEmailNotification
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(),
];
}
}
@@ -0,0 +1,113 @@
<?php
use App\Jobs\SendWebhookJob;
use App\Models\InstanceSettings;
use App\Models\PersonalAccessToken;
use App\Models\Server;
use App\Models\Team;
use App\Notifications\ApiTokenExpiringNotification;
use App\Notifications\Channels\WebhookChannel;
use App\Notifications\Internal\GeneralNotification;
use App\Notifications\Server\ForceDisabled;
use App\Notifications\Server\ForceEnabled;
use App\Notifications\SslExpirationNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
// base_url() resolves the instance settings singleton, which lives at the id = 0 sentinel.
InstanceSettings::unguarded(fn () => 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<string, mixed>
*/
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');
});