'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); } }