clusev/tests/Feature/AlertNotifierTest.php

113 lines
4.5 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\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_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);
}
}