clusev/tests/Feature/ServiceActionAuditTest.php

92 lines
3.6 KiB
PHP

<?php
namespace Tests\Feature;
use App\Livewire\Services\Index as Services;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Models\User;
use App\Services\FleetService;
use App\Support\Confirm\ConfirmToken;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery;
use Tests\TestCase;
/**
* A systemd action that FAILS must leave a persistent, readable trail: the ephemeral toast is
* truncated, so the full systemctl reason is written to the audit log (a red service.<op>.failed
* entry with the complete output in meta) where the operator can re-read it.
*/
class ServiceActionAuditTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
private function activeServer(): Server
{
$server = Server::create(['name' => 'box', 'ip' => '10.0.0.2', 'ssh_port' => 22, 'status' => 'online']);
$server->credential()->create(['username' => 'root', 'auth_type' => 'password', 'secret' => 'x']);
session(['active_server_id' => $server->id]);
return $server;
}
private function confirmed(string $event, array $params, int $serverId): string
{
$token = ConfirmToken::issue($event, $params, '', null, $serverId);
ConfirmToken::confirm($token);
return $token;
}
public function test_failed_start_is_persisted_to_the_audit_log_with_the_full_reason(): void
{
$this->actingAs(User::factory()->create(['must_change_password' => false]));
$server = $this->activeServer();
$reason = "monit.service: Failed with result 'exit-code'.\n"
.'monit.service - Pro-active monitoring utility for unix systems: a very long detailed '
.'reason that would be truncated in a 90-char toast but must survive in full here.';
$fleet = Mockery::mock(FleetService::class);
$fleet->shouldReceive('serviceAction')->once()->andReturn(['ok' => false, 'output' => $reason]);
$fleet->shouldReceive('systemd')->andReturn(['services' => []]);
app()->instance(FleetService::class, $fleet);
$token = $this->confirmed('serviceConfirmed', ['op' => 'start', 'name' => 'monit.service'], $server->id);
Livewire::test(Services::class)->call('applyService', $token);
$event = AuditEvent::where('action', 'service.start.failed')->first();
$this->assertNotNull($event, 'a service.start.failed audit event should be written');
$this->assertTrue($event->is_error);
$this->assertSame($server->id, $event->server_id);
// The FULL reason is retained (not truncated), so it is re-readable later.
$this->assertStringContainsString('must survive in full here.', $event->meta['output']);
$this->assertStringContainsString('Failed with result', $event->meta['output']);
}
public function test_successful_action_writes_no_failure_event(): void
{
$this->actingAs(User::factory()->create(['must_change_password' => false]));
$server = $this->activeServer();
$fleet = Mockery::mock(FleetService::class);
$fleet->shouldReceive('serviceAction')->once()->andReturn(['ok' => true, 'output' => '']);
$fleet->shouldReceive('systemd')->andReturn(['services' => []]);
app()->instance(FleetService::class, $fleet);
$token = $this->confirmed('serviceConfirmed', ['op' => 'restart', 'name' => 'nginx.service'], $server->id);
Livewire::test(Services::class)->call('applyService', $token);
$this->assertSame(0, AuditEvent::where('action', 'like', 'service.%.failed')->count());
}
}