287 lines
11 KiB
PHP
287 lines
11 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Livewire\Alerts\Index;
|
|
use App\Models\AlertIncident;
|
|
use App\Models\AlertRule;
|
|
use App\Models\Server;
|
|
use App\Models\ServerGroup;
|
|
use App\Models\Setting;
|
|
use App\Models\User;
|
|
use App\Support\Confirm\ConfirmToken;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Livewire\Livewire;
|
|
use Tests\TestCase;
|
|
|
|
class AlertsComponentTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private function admin(): User
|
|
{
|
|
return User::factory()->create(['must_change_password' => false]);
|
|
}
|
|
|
|
private function operator(): User
|
|
{
|
|
return User::factory()->operator()->create(['must_change_password' => false]);
|
|
}
|
|
|
|
private function viewer(): User
|
|
{
|
|
return User::factory()->viewer()->create(['must_change_password' => false]);
|
|
}
|
|
|
|
// ── route gating (manage-panel = admin) ─────────────────────────────────────
|
|
|
|
public function test_viewer_cannot_open_alerts(): void
|
|
{
|
|
$this->actingAs($this->viewer())->get('/alerts')->assertForbidden();
|
|
}
|
|
|
|
public function test_operator_cannot_open_alerts(): void
|
|
{
|
|
$this->actingAs($this->operator())->get('/alerts')->assertForbidden();
|
|
}
|
|
|
|
public function test_admin_can_open_alerts(): void
|
|
{
|
|
$this->actingAs($this->admin())->get('/alerts')->assertOk();
|
|
}
|
|
|
|
// ── rule create ─────────────────────────────────────────────────────────────
|
|
|
|
public function test_admin_creates_a_fleet_wide_numeric_rule(): void
|
|
{
|
|
$this->actingAs($this->admin());
|
|
|
|
Livewire::test(Index::class)
|
|
->set('name', 'CPU high')
|
|
->set('metric', 'cpu')
|
|
->set('comparator', 'gt')
|
|
->set('threshold', 90)
|
|
->set('scopeType', 'all')
|
|
->call('createRule')
|
|
->assertHasNoErrors();
|
|
|
|
$this->assertDatabaseHas('alert_rules', ['name' => 'CPU high', 'metric' => 'cpu', 'threshold' => 90, 'scope_type' => 'all', 'enabled' => true]);
|
|
$this->assertDatabaseHas('audit_events', ['action' => 'alert.rule_create', 'target' => 'CPU high']);
|
|
}
|
|
|
|
public function test_group_scope_resolves_the_uuid_to_an_id(): void
|
|
{
|
|
$this->actingAs($this->admin());
|
|
$group = ServerGroup::create(['name' => 'prod', 'color' => 'online']);
|
|
|
|
Livewire::test(Index::class)
|
|
->set('name', 'prod cpu')
|
|
->set('metric', 'cpu')
|
|
->set('scopeType', 'group')
|
|
->set('scopeUuid', $group->uuid)
|
|
->call('createRule')
|
|
->assertHasNoErrors();
|
|
|
|
$this->assertDatabaseHas('alert_rules', ['name' => 'prod cpu', 'scope_type' => 'group', 'scope_id' => $group->id]);
|
|
}
|
|
|
|
public function test_offline_metric_stores_threshold_zero(): void
|
|
{
|
|
$this->actingAs($this->admin());
|
|
|
|
Livewire::test(Index::class)
|
|
->set('name', 'down')
|
|
->set('metric', 'offline')
|
|
->set('threshold', 80)
|
|
->set('scopeType', 'all')
|
|
->call('createRule')
|
|
->assertHasNoErrors();
|
|
|
|
$this->assertDatabaseHas('alert_rules', ['name' => 'down', 'metric' => 'offline', 'threshold' => 0]);
|
|
}
|
|
|
|
public function test_create_rejects_an_unknown_metric(): void
|
|
{
|
|
$this->actingAs($this->admin());
|
|
|
|
Livewire::test(Index::class)
|
|
->set('name', 'x')
|
|
->set('metric', 'temperature')
|
|
->call('createRule')
|
|
->assertHasErrors('metric');
|
|
|
|
$this->assertDatabaseCount('alert_rules', 0);
|
|
}
|
|
|
|
// ── toggle / delete ─────────────────────────────────────────────────────────
|
|
|
|
public function test_toggle_flips_enabled(): void
|
|
{
|
|
$this->actingAs($this->admin());
|
|
$rule = AlertRule::create(['name' => 'r', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 80, 'scope_type' => 'all', 'enabled' => true]);
|
|
|
|
Livewire::test(Index::class)->call('toggleRule', $rule->uuid);
|
|
|
|
$this->assertFalse($rule->fresh()->enabled);
|
|
}
|
|
|
|
public function test_admin_deletes_a_rule_via_a_confirmed_token(): void
|
|
{
|
|
$this->actingAs($this->admin());
|
|
$rule = AlertRule::create(['name' => 'gone', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 80, 'scope_type' => 'all', 'enabled' => true]);
|
|
|
|
$token = ConfirmToken::issue('alertRuleDeleted', ['uuid' => $rule->uuid], 'alert.rule_delete', $rule->name, null);
|
|
ConfirmToken::confirm($token);
|
|
|
|
Livewire::test(Index::class)->call('deleteRule', $token)->assertOk();
|
|
|
|
$this->assertDatabaseMissing('alert_rules', ['id' => $rule->id]);
|
|
}
|
|
|
|
// ── channels ────────────────────────────────────────────────────────────────
|
|
|
|
public function test_admin_saves_notification_channels(): void
|
|
{
|
|
$this->actingAs($this->admin());
|
|
|
|
Livewire::test(Index::class)
|
|
->set('emailTo', "ops@example.com\n")
|
|
->set('webhooks', 'https://8.8.8.8/x') // public IP literal — passes the SSRF host check
|
|
->call('saveChannels');
|
|
|
|
$this->assertSame('ops@example.com', Setting::get('alert_email_to'));
|
|
$this->assertSame('https://8.8.8.8/x', Setting::get('alert_webhooks'));
|
|
$this->assertDatabaseHas('audit_events', ['action' => 'alert.channels_update']);
|
|
}
|
|
|
|
public function test_save_channels_rejects_an_unsafe_webhook_url(): void
|
|
{
|
|
$this->actingAs($this->admin());
|
|
|
|
Livewire::test(Index::class)
|
|
->set('webhooks', 'http://169.254.169.254/latest/meta-data') // SSRF to cloud metadata
|
|
->call('saveChannels')
|
|
->assertHasErrors('webhooks');
|
|
|
|
$this->assertNull(Setting::get('alert_webhooks')); // not stored
|
|
}
|
|
|
|
// ── incidents render ────────────────────────────────────────────────────────
|
|
|
|
public function test_active_incidents_are_shown(): void
|
|
{
|
|
$this->actingAs($this->admin());
|
|
$rule = AlertRule::create(['name' => 'CPU high', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 80, 'scope_type' => 'all', 'enabled' => true]);
|
|
$server = Server::create(['name' => 'web1', 'ip' => '10.0.0.1', 'ssh_port' => 22, 'status' => 'online']);
|
|
AlertIncident::create(['alert_rule_id' => $rule->id, 'server_id' => $server->id, 'state' => 'firing', 'value' => 95, 'started_at' => now()]);
|
|
|
|
Livewire::test(Index::class)
|
|
->assertSee('CPU high')
|
|
->assertSee('web1');
|
|
}
|
|
// ── delivery-channel warning + Gotify ───────────────────────────────────────
|
|
|
|
public function test_no_channel_warning_shows_when_nothing_is_configured(): void
|
|
{
|
|
Livewire::actingAs($this->admin())
|
|
->test(Index::class)
|
|
->assertSee(__('alerts.no_channel_title'));
|
|
}
|
|
|
|
public function test_no_channel_warning_hidden_once_gotify_is_configured(): void
|
|
{
|
|
Setting::put('alert_gotify_url', 'https://gotify.example.com');
|
|
Setting::put('alert_gotify_token', Crypt::encryptString('tok'));
|
|
|
|
Livewire::actingAs($this->admin())
|
|
->test(Index::class)
|
|
->assertDontSee(__('alerts.no_channel_title'));
|
|
}
|
|
|
|
public function test_saving_gotify_stores_url_and_encrypts_the_token(): void
|
|
{
|
|
Livewire::actingAs($this->admin())
|
|
->test(Index::class)
|
|
->set('gotifyUrl', 'https://gotify.example.com')
|
|
->set('gotifyToken', 'SuperSecretTok')
|
|
->call('saveChannels')
|
|
->assertHasNoErrors()
|
|
->assertSet('gotifyToken', '') // write-only: cleared after save
|
|
->assertSet('gotifyTokenSet', true);
|
|
|
|
$this->assertSame('https://gotify.example.com', Setting::get('alert_gotify_url'));
|
|
$stored = (string) Setting::get('alert_gotify_token');
|
|
$this->assertNotSame('', $stored);
|
|
$this->assertNotSame('SuperSecretTok', $stored); // encrypted, not plaintext
|
|
$this->assertSame('SuperSecretTok', Crypt::decryptString($stored));
|
|
}
|
|
|
|
public function test_an_invalid_gotify_url_is_rejected(): void
|
|
{
|
|
Livewire::actingAs($this->admin())
|
|
->test(Index::class)
|
|
->set('gotifyUrl', 'ftp://nope')
|
|
->call('saveChannels')
|
|
->assertHasErrors('gotifyUrl');
|
|
|
|
$this->assertNull(Setting::get('alert_gotify_url'));
|
|
}
|
|
|
|
public function test_clear_gotify_token_removes_it(): void
|
|
{
|
|
Setting::put('alert_gotify_token', Crypt::encryptString('tok'));
|
|
|
|
Livewire::actingAs($this->admin())
|
|
->test(Index::class)
|
|
->assertSet('gotifyTokenSet', true)
|
|
->call('clearGotifyToken')
|
|
->assertSet('gotifyTokenSet', false);
|
|
|
|
$this->assertNull(Setting::get('alert_gotify_token'));
|
|
}
|
|
|
|
public function test_changing_gotify_url_without_a_new_token_clears_the_stored_token(): void
|
|
{
|
|
Setting::put('alert_gotify_url', 'https://old.example.com');
|
|
Setting::put('alert_gotify_token', Crypt::encryptString('oldtok'));
|
|
|
|
// Admin retargets the URL but leaves the token field blank → the old token must NOT survive
|
|
// to be sent to the new endpoint (token-exfil-via-retarget guard).
|
|
Livewire::actingAs($this->admin())
|
|
->test(Index::class)
|
|
->set('gotifyUrl', 'https://new.example.com')
|
|
->set('gotifyToken', '')
|
|
->call('saveChannels')
|
|
->assertSet('gotifyTokenSet', false);
|
|
|
|
$this->assertSame('https://new.example.com', Setting::get('alert_gotify_url'));
|
|
$this->assertNull(Setting::get('alert_gotify_token'));
|
|
}
|
|
|
|
public function test_no_channel_warning_shows_when_gotify_token_is_absent_even_with_a_url(): void
|
|
{
|
|
// URL set but no token → the sender would skip it, so the banner must still warn.
|
|
Setting::put('alert_gotify_url', 'https://gotify.example.com');
|
|
|
|
Livewire::actingAs($this->admin())
|
|
->test(Index::class)
|
|
->assertSee(__('alerts.no_channel_title'));
|
|
}
|
|
|
|
public function test_changing_url_with_a_fresh_token_stores_the_new_token_not_the_old(): void
|
|
{
|
|
Setting::put('alert_gotify_url', 'https://old.example.com');
|
|
Setting::put('alert_gotify_token', Crypt::encryptString('OLD'));
|
|
|
|
Livewire::actingAs($this->admin())
|
|
->test(Index::class)
|
|
->set('gotifyUrl', 'https://new.example.com')
|
|
->set('gotifyToken', 'NEW')
|
|
->call('saveChannels')
|
|
->assertSet('gotifyTokenSet', true);
|
|
|
|
$this->assertSame('NEW', Crypt::decryptString((string) Setting::get('alert_gotify_token')));
|
|
}
|
|
}
|