213 lines
9.7 KiB
PHP
213 lines
9.7 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\AlertIncident;
|
|
use App\Models\AlertRule;
|
|
use App\Models\Server;
|
|
use App\Models\ServerGroup;
|
|
use App\Services\AlertEvaluator;
|
|
use App\Services\AlertNotifier;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Mockery;
|
|
use Tests\TestCase;
|
|
|
|
class AlertEvaluatorTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
Mockery::close();
|
|
parent::tearDown();
|
|
}
|
|
|
|
/** Evaluator with a notifier spy so no real mail/http is sent; returns [$evaluator, $notifier]. */
|
|
private function make(): array
|
|
{
|
|
$notifier = Mockery::mock(AlertNotifier::class);
|
|
|
|
return [new AlertEvaluator($notifier), $notifier];
|
|
}
|
|
|
|
private function server(string $ip = '10.0.0.1'): Server
|
|
{
|
|
return Server::create(['name' => 'box', 'ip' => $ip, 'ssh_port' => 22, 'status' => 'online']);
|
|
}
|
|
|
|
public function test_a_numeric_breach_opens_a_firing_incident_and_notifies(): void
|
|
{
|
|
[$eval, $notifier] = $this->make();
|
|
$notifier->shouldReceive('notify')->once()->with(Mockery::type(AlertIncident::class), false);
|
|
AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 80, 'scope_type' => 'all', 'enabled' => true]);
|
|
$server = $this->server();
|
|
|
|
$eval->evaluate($server, ['cpu' => 95, 'mem' => 10, 'disk' => 10, 'load' => 1], 'online');
|
|
|
|
$this->assertDatabaseHas('alert_incidents', ['server_id' => $server->id, 'state' => 'firing', 'value' => 95]);
|
|
}
|
|
|
|
public function test_firing_a_new_incident_busts_the_sidebar_firing_count_cache(): void
|
|
{
|
|
[$eval, $notifier] = $this->make();
|
|
$notifier->shouldReceive('notify');
|
|
AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 80, 'scope_type' => 'all', 'enabled' => true]);
|
|
$server = $this->server();
|
|
|
|
// Warm the badge-count cache while nothing is firing.
|
|
$this->assertSame(0, AlertIncident::firingCount());
|
|
|
|
// A breach opens an incident — the sidebar badge must reflect it at once (cache busted),
|
|
// not stay at the cached 0 until the TTL expires.
|
|
$eval->evaluate($server, ['cpu' => 95, 'mem' => 10, 'disk' => 10, 'load' => 1], 'online');
|
|
|
|
$this->assertSame(1, AlertIncident::firingCount());
|
|
}
|
|
|
|
public function test_a_sustained_breach_does_not_open_a_second_incident(): void
|
|
{
|
|
[$eval, $notifier] = $this->make();
|
|
$notifier->shouldReceive('notify')->once(); // fired once, deduped after
|
|
AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 80, 'scope_type' => 'all', 'enabled' => true]);
|
|
$server = $this->server();
|
|
|
|
$eval->evaluate($server, ['cpu' => 95], 'online');
|
|
$eval->evaluate($server, ['cpu' => 96], 'online'); // still breached → dedup
|
|
|
|
$this->assertSame(1, AlertIncident::where('state', 'firing')->count());
|
|
}
|
|
|
|
public function test_recovery_resolves_the_incident_and_notifies(): void
|
|
{
|
|
[$eval, $notifier] = $this->make();
|
|
$notifier->shouldReceive('notify')->once()->with(Mockery::type(AlertIncident::class), false);
|
|
$notifier->shouldReceive('notify')->once()->with(Mockery::type(AlertIncident::class), true);
|
|
AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 80, 'scope_type' => 'all', 'enabled' => true]);
|
|
$server = $this->server();
|
|
|
|
$eval->evaluate($server, ['cpu' => 95], 'online'); // fire
|
|
$eval->evaluate($server, ['cpu' => 20], 'online'); // recover
|
|
|
|
$this->assertSame(0, AlertIncident::where('state', 'firing')->count());
|
|
$this->assertDatabaseHas('alert_incidents', ['state' => 'resolved']);
|
|
}
|
|
|
|
public function test_lt_comparator_fires_below_the_threshold(): void
|
|
{
|
|
[$eval, $notifier] = $this->make();
|
|
$notifier->shouldReceive('notify')->once();
|
|
AlertRule::create(['name' => 'Disk free low', 'metric' => 'disk', 'comparator' => 'lt', 'threshold' => 10, 'scope_type' => 'all', 'enabled' => true]);
|
|
$server = $this->server();
|
|
|
|
$eval->evaluate($server, ['disk' => 5], 'online');
|
|
|
|
$this->assertDatabaseHas('alert_incidents', ['state' => 'firing', 'value' => 5]);
|
|
}
|
|
|
|
public function test_offline_rule_fires_when_not_online_and_skips_numeric(): void
|
|
{
|
|
[$eval, $notifier] = $this->make();
|
|
$notifier->shouldReceive('notify')->once()->with(Mockery::type(AlertIncident::class), false);
|
|
AlertRule::create(['name' => 'Down', 'metric' => 'offline', 'scope_type' => 'all', 'enabled' => true]);
|
|
AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 1, 'scope_type' => 'all', 'enabled' => true]);
|
|
$server = $this->server();
|
|
|
|
// Offline poll: stale metrics, status offline → only the offline rule fires, cpu is skipped.
|
|
$eval->evaluate($server, [], 'offline');
|
|
|
|
$this->assertSame(1, AlertIncident::where('state', 'firing')->count());
|
|
$this->assertDatabaseHas('alert_incidents', ['alert_rule_id' => AlertRule::where('metric', 'offline')->first()->id]);
|
|
}
|
|
|
|
public function test_offline_incident_resolves_when_the_server_comes_back(): void
|
|
{
|
|
[$eval, $notifier] = $this->make();
|
|
$notifier->shouldReceive('notify')->twice();
|
|
AlertRule::create(['name' => 'Down', 'metric' => 'offline', 'scope_type' => 'all', 'enabled' => true]);
|
|
$server = $this->server();
|
|
|
|
$eval->evaluate($server, [], 'offline'); // fire
|
|
$eval->evaluate($server, ['cpu' => 5], 'online'); // back online → resolve
|
|
|
|
$this->assertSame(0, AlertIncident::where('state', 'firing')->count());
|
|
}
|
|
|
|
public function test_a_fractional_load_breach_fires_and_is_not_truncated(): void
|
|
{
|
|
[$eval, $notifier] = $this->make();
|
|
$notifier->shouldReceive('notify')->once();
|
|
// Regression: load 1.5 with threshold 1 used to truncate to `1 > 1` = false and never fire.
|
|
AlertRule::create(['name' => 'Load', 'metric' => 'load', 'comparator' => 'gt', 'threshold' => 1, 'scope_type' => 'all', 'enabled' => true]);
|
|
|
|
$eval->evaluate($this->server(), ['cpu' => 5, 'load' => 1.5], 'online');
|
|
|
|
$this->assertDatabaseHas('alert_incidents', ['state' => 'firing', 'value' => 1.5]);
|
|
}
|
|
|
|
public function test_firing_key_is_a_unique_db_invariant(): void
|
|
{
|
|
$rule = AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 80, 'scope_type' => 'all', 'enabled' => true]);
|
|
$server = $this->server();
|
|
$key = $rule->id.':'.$server->id;
|
|
AlertIncident::create(['alert_rule_id' => $rule->id, 'server_id' => $server->id, 'state' => 'firing', 'value' => 90, 'firing_key' => $key, 'started_at' => now()]);
|
|
|
|
// A concurrent tick opening the same firing incident hits the unique index → no duplicate.
|
|
$this->expectException(QueryException::class);
|
|
AlertIncident::create(['alert_rule_id' => $rule->id, 'server_id' => $server->id, 'state' => 'firing', 'value' => 91, 'firing_key' => $key, 'started_at' => now()]);
|
|
}
|
|
|
|
public function test_a_resolved_incident_frees_the_firing_key_for_a_re_breach(): void
|
|
{
|
|
[$eval, $notifier] = $this->make();
|
|
$notifier->shouldReceive('notify')->times(3); // fire, resolve, fire again
|
|
AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 80, 'scope_type' => 'all', 'enabled' => true]);
|
|
$server = $this->server();
|
|
|
|
$eval->evaluate($server, ['cpu' => 95], 'online'); // fire
|
|
$eval->evaluate($server, ['cpu' => 10], 'online'); // resolve → firing_key nulled
|
|
$eval->evaluate($server, ['cpu' => 95], 'online'); // re-breach can claim the key again
|
|
|
|
$this->assertSame(1, AlertIncident::where('state', 'firing')->count());
|
|
$this->assertSame(2, AlertIncident::count()); // one resolved + one new firing
|
|
}
|
|
|
|
public function test_a_disabled_rule_never_fires(): void
|
|
{
|
|
[$eval, $notifier] = $this->make();
|
|
$notifier->shouldReceive('notify')->never();
|
|
AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 1, 'scope_type' => 'all', 'enabled' => false]);
|
|
|
|
$eval->evaluate($this->server(), ['cpu' => 99], 'online');
|
|
|
|
$this->assertDatabaseCount('alert_incidents', 0);
|
|
}
|
|
|
|
public function test_scope_server_only_targets_that_server(): void
|
|
{
|
|
[$eval, $notifier] = $this->make();
|
|
$notifier->shouldReceive('notify')->never();
|
|
$target = $this->server('10.0.0.1');
|
|
$other = $this->server('10.0.0.2');
|
|
AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 50, 'scope_type' => 'server', 'scope_id' => $target->id, 'enabled' => true]);
|
|
|
|
$eval->evaluate($other, ['cpu' => 99], 'online'); // not the targeted server → nothing
|
|
|
|
$this->assertDatabaseCount('alert_incidents', 0);
|
|
}
|
|
|
|
public function test_scope_group_targets_only_members(): void
|
|
{
|
|
[$eval, $notifier] = $this->make();
|
|
$notifier->shouldReceive('notify')->once();
|
|
$group = ServerGroup::create(['name' => 'prod', 'color' => 'online']);
|
|
$member = $this->server('10.0.0.1');
|
|
$group->servers()->attach($member->id);
|
|
AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 50, 'scope_type' => 'group', 'scope_id' => $group->id, 'enabled' => true]);
|
|
|
|
$eval->evaluate($member, ['cpu' => 99], 'online');
|
|
|
|
$this->assertDatabaseHas('alert_incidents', ['server_id' => $member->id, 'state' => 'firing']);
|
|
}
|
|
}
|