109 lines
4.1 KiB
PHP
109 lines
4.1 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_to_the_configured_recipients(): void
|
|
{
|
|
Mail::fake();
|
|
Setting::put('alert_email_to', "ops@example.com\nsecond@example.com");
|
|
|
|
app(AlertNotifier::class)->notify($this->incident(), resolved: false);
|
|
|
|
Mail::assertQueued(AlertNotification::class, fn (AlertNotification $m) => $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);
|
|
}
|
|
}
|