153 lines
6.3 KiB
PHP
153 lines
6.3 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Mail\AlertNotification;
|
|
use App\Models\AlertIncident;
|
|
use App\Models\AlertRule;
|
|
use App\Models\Server;
|
|
use App\Models\Setting;
|
|
use App\Models\User;
|
|
use App\Services\AlertNotifier;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Tests\TestCase;
|
|
|
|
class AlertNotifierTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private function incident(): AlertIncident
|
|
{
|
|
$rule = AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 80, 'scope_type' => 'all', 'enabled' => true]);
|
|
$server = Server::create(['name' => 'box', 'ip' => '10.0.0.1', 'ssh_port' => 22, 'status' => 'online']);
|
|
|
|
return AlertIncident::create(['alert_rule_id' => $rule->id, 'server_id' => $server->id, 'state' => 'firing', 'value' => 95, 'started_at' => now()]);
|
|
}
|
|
|
|
public function test_email_is_queued_once_per_recipient_with_a_single_to(): void
|
|
{
|
|
Mail::fake();
|
|
Setting::put('alert_email_to', "ops@example.com\nsecond@example.com");
|
|
|
|
app(AlertNotifier::class)->notify($this->incident(), resolved: false);
|
|
|
|
// One message PER recipient, each with only its own address — a shared To that included an
|
|
// undeliverable recipient would otherwise get the whole mail spam-dropped for everyone.
|
|
Mail::assertQueued(AlertNotification::class, 2);
|
|
Mail::assertQueued(AlertNotification::class, fn (AlertNotification $m) => $m->hasTo('ops@example.com') && ! $m->hasTo('second@example.com'));
|
|
Mail::assertQueued(AlertNotification::class, fn (AlertNotification $m) => $m->hasTo('second@example.com') && ! $m->hasTo('ops@example.com'));
|
|
}
|
|
|
|
public function test_falls_back_to_admin_emails_when_none_configured(): void
|
|
{
|
|
Mail::fake();
|
|
$admin = User::factory()->create(['email' => 'admin@example.com', 'must_change_password' => false]); // admin by default
|
|
|
|
app(AlertNotifier::class)->notify($this->incident(), resolved: false);
|
|
|
|
Mail::assertQueued(AlertNotification::class, fn (AlertNotification $m) => $m->hasTo('admin@example.com'));
|
|
}
|
|
|
|
public function test_a_webhook_is_posted_with_the_payload(): void
|
|
{
|
|
Mail::fake();
|
|
Http::fake();
|
|
Setting::put('alert_webhooks', 'https://8.8.8.8/incoming'); // public IP literal (no DNS, passes the SSRF check)
|
|
|
|
app(AlertNotifier::class)->notify($this->incident(), resolved: false);
|
|
|
|
Http::assertSent(fn ($request) => $request->url() === 'https://8.8.8.8/incoming'
|
|
&& $request['state'] === 'firing'
|
|
&& $request['server'] === 'box'
|
|
&& (int) $request['value'] === 95); // value is a float column (95.0)
|
|
}
|
|
|
|
public function test_a_non_http_webhook_value_is_ignored(): void
|
|
{
|
|
Mail::fake();
|
|
Http::fake();
|
|
Setting::put('alert_webhooks', "javascript:alert(1)\nnot a url");
|
|
|
|
app(AlertNotifier::class)->notify($this->incident(), resolved: false);
|
|
|
|
Http::assertNothingSent();
|
|
}
|
|
|
|
public function test_unsafe_webhook_urls_are_rejected(): void
|
|
{
|
|
$unsafe = [
|
|
'http://127.0.0.1/hook', // loopback
|
|
'https://10.0.0.5/hook', // private
|
|
'http://192.168.1.10/x', // private
|
|
'http://169.254.169.254/latest/meta-data', // link-local (cloud-metadata SSRF classic)
|
|
'http://localhost/x', // resolves to loopback
|
|
'ftp://example.com/x', // bad scheme
|
|
'httpx://8.8.8.8/x', // bad scheme
|
|
'https:///x', // no host
|
|
];
|
|
|
|
foreach ($unsafe as $url) {
|
|
$this->assertFalse(AlertNotifier::isSafeWebhookUrl($url), "should reject {$url}");
|
|
}
|
|
}
|
|
|
|
public function test_public_https_webhook_is_accepted(): void
|
|
{
|
|
$this->assertTrue(AlertNotifier::isSafeWebhookUrl('https://8.8.8.8/hook')); // public IP, no DNS needed
|
|
}
|
|
|
|
public function test_gotify_pushes_title_message_priority_with_the_token_header(): void
|
|
{
|
|
Mail::fake();
|
|
Http::fake();
|
|
Setting::put('alert_gotify_url', 'https://gotify.example.com/');
|
|
Setting::put('alert_gotify_token', Crypt::encryptString('AppTok123'));
|
|
|
|
app(AlertNotifier::class)->notify($this->incident(), resolved: false);
|
|
|
|
Http::assertSent(function ($request) {
|
|
return $request->url() === 'https://gotify.example.com/message'
|
|
&& $request->hasHeader('X-Gotify-Key', 'AppTok123')
|
|
&& $request['priority'] === 8 // firing → high priority
|
|
&& str_contains($request['message'], 'cpu');
|
|
});
|
|
}
|
|
|
|
public function test_gotify_is_skipped_when_url_or_token_missing(): void
|
|
{
|
|
Mail::fake();
|
|
Http::fake();
|
|
Setting::put('alert_gotify_url', 'https://gotify.example.com'); // token missing
|
|
|
|
app(AlertNotifier::class)->notify($this->incident(), resolved: false);
|
|
|
|
Http::assertNothingSent();
|
|
}
|
|
|
|
public function test_gotify_allows_a_private_lan_server_but_still_requires_http_scheme(): void
|
|
{
|
|
// A self-hosted Gotify on a LAN is allowed (unlike SSRF-guarded webhooks) …
|
|
$this->assertTrue(AlertNotifier::isValidGotifyUrl('http://192.168.1.50:8080'));
|
|
$this->assertTrue(AlertNotifier::isValidGotifyUrl('https://gotify.example.com'));
|
|
// … but a non-http scheme or missing host is still refused.
|
|
$this->assertFalse(AlertNotifier::isValidGotifyUrl('ftp://192.168.1.50'));
|
|
$this->assertFalse(AlertNotifier::isValidGotifyUrl('gotify://x'));
|
|
$this->assertFalse(AlertNotifier::isValidGotifyUrl('https:///nohost'));
|
|
}
|
|
|
|
public function test_a_failing_channel_is_swallowed(): void
|
|
{
|
|
Mail::fake();
|
|
Http::fake(fn () => throw new \RuntimeException('connection refused'));
|
|
Setting::put('alert_webhooks', 'https://8.8.8.8/incoming');
|
|
|
|
// Must NOT throw — a broken channel can never break the poll loop that called notify().
|
|
app(AlertNotifier::class)->notify($this->incident(), resolved: false);
|
|
|
|
$this->assertTrue(true);
|
|
}
|
|
}
|