clusev/tests/Feature/DockerComponentTest.php

190 lines
6.7 KiB
PHP

<?php
namespace Tests\Feature;
use App\Exceptions\DockerNotInstalled;
use App\Livewire\Docker\Index;
use App\Livewire\Modals\ContainerLogs;
use App\Models\HostCredential;
use App\Models\Server;
use App\Models\User;
use App\Services\DockerService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery;
use Tests\TestCase;
/**
* The sidebar Docker page targets the CLUSEV HOST and is admin-only (manage-fleet). Per-server
* container management lives in ServerDockerTest. This also covers the shared ContainerLogs modal.
*/
class DockerComponentTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
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 server(): 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']);
return $server;
}
private function hostCred(): HostCredential
{
return HostCredential::create([
'host' => 'host.docker.internal', 'port' => 22,
'username' => 'root', 'auth_type' => 'password', 'secret' => 'x',
]);
}
private function stubDocker(array $containers = []): DockerService
{
$docker = Mockery::mock(DockerService::class);
$docker->shouldReceive('containers')->andReturn($containers);
app()->instance(DockerService::class, $docker);
return $docker;
}
public function test_admin_sees_the_host_containers(): void
{
$this->actingAs($this->admin());
$this->hostCred();
$this->stubDocker([['id' => 'h1', 'name' => 'clusev-app-1', 'image' => 'clusev-app:prod', 'state' => 'running', 'status' => 'Up', 'ports' => '']]);
Livewire::test(Index::class)
->call('load')
->assertOk()
->assertSet('connected', true)
->assertSee('clusev-app-1');
}
public function test_non_admin_is_refused_the_host_docker_route(): void
{
$this->actingAs($this->operator());
$this->get(route('docker'))->assertForbidden();
}
public function test_host_not_configured_state_when_no_login_exists(): void
{
$this->actingAs($this->admin());
$this->stubDocker();
Livewire::test(Index::class)
->call('load')
->assertOk()
->assertSet('hostNotConfigured', true)
->assertSet('connected', false)
->assertSee(__('docker.host_unconfigured_title'));
}
public function test_shows_a_clean_not_installed_state_when_the_host_has_no_docker(): void
{
$this->actingAs($this->admin());
$this->hostCred();
$docker = Mockery::mock(DockerService::class);
// A missing docker binary surfaces as DockerNotInstalled from the single containers() call.
$docker->shouldReceive('containers')->once()->andThrow(new DockerNotInstalled('sh: 1: docker: not found'));
app()->instance(DockerService::class, $docker);
Livewire::test(Index::class)
->call('load')
->assertOk()
->assertSet('notInstalled', true)
->assertSet('error', null)
->assertSee(__('docker.not_installed_title'))
->assertDontSee('not found');
}
public function test_admin_can_action_a_host_container_and_it_is_audited(): void
{
$this->actingAs($this->admin());
$this->hostCred();
$docker = $this->stubDocker();
$docker->shouldReceive('containerAction')->once()
->with(Mockery::type(Server::class), 'clusev-redis-1', 'restart')
->andReturn(['ok' => true, 'output' => '']);
Livewire::test(Index::class)
->call('action', 'clusev-redis-1', 'restart')
->assertOk();
// Host actions carry a null server_id (transient host server); the target label names it.
$this->assertDatabaseHas('audit_events', ['action' => 'docker.action', 'server_id' => null]);
}
// ── ContainerLogs modal (shared by the host page + the server Docker tab) ──
public function test_logs_modal_loads_a_server_container_tail(): void
{
$this->actingAs($this->operator());
$server = $this->server();
$docker = Mockery::mock(DockerService::class);
$docker->shouldReceive('logs')->once()->with(Mockery::type(Server::class), 'web', 200)->andReturn('log line 1');
app()->instance(DockerService::class, $docker);
Livewire::test(ContainerLogs::class, ['serverId' => $server->id, 'ref' => 'web'])
->call('load')
->assertOk()
->assertSet('logs', 'log line 1');
}
public function test_logs_modal_loads_a_host_container_tail_for_admins(): void
{
$this->actingAs($this->admin());
$this->hostCred();
$docker = Mockery::mock(DockerService::class);
$docker->shouldReceive('logs')->once()->with(Mockery::type(Server::class), 'clusev-app-1', 200)->andReturn('host log line');
app()->instance(DockerService::class, $docker);
Livewire::test(ContainerLogs::class, ['serverId' => 0, 'ref' => 'clusev-app-1'])
->call('load')
->assertOk()
->assertSet('logs', 'host log line');
}
public function test_operator_cannot_open_host_logs(): void
{
// The host-logs sentinel (serverId 0) is admin-only; an operator is refused at the read.
$this->actingAs($this->operator());
$this->hostCred();
$docker = Mockery::mock(DockerService::class);
$docker->shouldReceive('logs')->never();
app()->instance(DockerService::class, $docker);
Livewire::test(ContainerLogs::class, ['serverId' => 0, 'ref' => 'clusev-app-1'])
->call('load')
->assertForbidden();
}
public function test_viewer_cannot_read_server_logs_in_the_modal(): void
{
$this->actingAs(User::factory()->viewer()->create(['must_change_password' => false]));
$server = $this->server();
$docker = Mockery::mock(DockerService::class);
$docker->shouldReceive('logs')->never(); // guard fires before the read
app()->instance(DockerService::class, $docker);
Livewire::test(ContainerLogs::class, ['serverId' => $server->id, 'ref' => 'web'])
->call('load')
->assertForbidden();
}
}