clusev/tests/Feature/RbacFleetGateTest.php

250 lines
9.3 KiB
PHP

<?php
namespace Tests\Feature;
use App\Livewire\Modals\CreateServer;
use App\Livewire\Servers\ServerTerminal;
use App\Livewire\Servers\Show;
use App\Livewire\System\Index as SystemIndex;
use App\Livewire\Terminal\Index as TerminalIndex;
use App\Models\HostCredential;
use App\Models\Server;
use App\Models\TerminalSession;
use App\Models\User;
use App\Services\DeploymentService;
use App\Services\FleetService;
use App\Support\Confirm\ConfirmToken;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery;
use Tests\TestCase;
/**
* RBAC guards on the DANGEROUS fleet + host-terminal + domain/TLS actions. The RBAC foundation
* (Role enum, Gates manage-fleet/manage-panel + operate) is committed separately; this proves the
* abort_unless(...) guards fire at every dangerous entry point:
* - manage-fleet gates server registration + credential surface (CreateServer, credential
* delete/toggle, SSH-key install, host-terminal login) and the HOST shell target;
* - operate gates a SERVER terminal session (day-to-day), not the host shell;
* - manage-panel gates the domain/TLS control-plane mutations (System\Index).
* Admin (factory default) may act; operator/viewer are refused with 403 where they lack the
* ability. Every host/SSH service is mocked so no real network is touched (hermetic RefreshDatabase).
*/
class RbacFleetGateTest 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 viewer(): User
{
return User::factory()->viewer()->create(['must_change_password' => false]);
}
private function server(string $name = 's', string $ip = '10.0.0.9'): Server
{
return Server::create(['name' => $name, 'ip' => $ip, 'ssh_port' => 22, 'status' => 'online']);
}
/** A modal-confirmed, server-scoped token (mirrors the real confirm step). */
private function confirmed(string $event, array $params, ?int $serverId = null): string
{
$token = ConfirmToken::issue($event, $params, '', null, $serverId);
ConfirmToken::confirm($token);
return $token;
}
// ── manage-fleet: CreateServer modal (mount + save) ─────────────────────────
public function test_admin_can_open_and_save_create_server(): void
{
// SSH probe is mocked so the admin path performs no real login.
$this->mock(FleetService::class, fn ($m) => $m->shouldReceive('testConnection')->andReturn(['ok' => true, 'error' => null]));
Livewire::actingAs($this->admin())
->test(CreateServer::class)
->set('name', 'web-1')->set('ip', '10.0.0.5')->set('sshPort', 22)
->set('username', 'root')->set('authType', 'password')->set('secret', 'pw')
->call('save')
->assertHasNoErrors(); // allowed through, SSH mocked away
$this->assertSame(1, Server::count());
}
public function test_operator_cannot_open_create_server(): void
{
// No FleetService probe should ever be reached — the mount guard fires first.
$this->mock(FleetService::class, fn ($m) => $m->shouldReceive('testConnection')->never());
Livewire::actingAs($this->operator())
->test(CreateServer::class)
->assertForbidden();
$this->assertSame(0, Server::count());
}
public function test_viewer_cannot_open_create_server(): void
{
Livewire::actingAs($this->viewer())
->test(CreateServer::class)
->assertForbidden();
}
// ── manage-fleet: Servers\Show::deleteCredential ────────────────────────────
public function test_admin_can_delete_a_credential(): void
{
// ConfirmToken binds to the issuing uid — authenticate BEFORE minting it.
$this->actingAs($this->admin());
$server = $this->server();
$server->credential()->create(['username' => 'root', 'auth_type' => 'password', 'secret' => 'x']);
$token = $this->confirmed('credentialDeleted', [], $server->id);
Livewire::test(Show::class, ['server' => $server])
->call('deleteCredential', $token);
$this->assertFalse($server->credential()->exists()); // allowed → credential gone
}
public function test_operator_cannot_delete_a_credential(): void
{
$server = $this->server();
$server->credential()->create(['username' => 'root', 'auth_type' => 'password', 'secret' => 'x']);
// The method guard fires before the token is even consumed — no token needed.
Livewire::actingAs($this->operator())
->test(Show::class, ['server' => $server])
->call('deleteCredential', 'irrelevant')
->assertForbidden();
$this->assertTrue($server->credential()->exists(), 'operator must not be able to delete the credential');
}
public function test_viewer_cannot_delete_a_credential(): void
{
$server = $this->server();
$server->credential()->create(['username' => 'root', 'auth_type' => 'password', 'secret' => 'x']);
Livewire::actingAs($this->viewer())
->test(Show::class, ['server' => $server])
->call('deleteCredential', 'irrelevant')
->assertForbidden();
$this->assertTrue($server->credential()->exists());
}
// ── manage-fleet (host shell) vs operate (server shell): the split terminals ──
public function test_admin_can_open_host_and_server_shells(): void
{
HostCredential::create([
'host' => 'host.docker.internal', 'port' => 22, 'username' => 'root',
'auth_type' => 'password', 'secret' => 'r00t-pw',
]);
$server = $this->server();
// Host shell = the admin-only sidebar Terminal page.
Livewire::actingAs($this->admin())->test(TerminalIndex::class)->call('open');
// Server shell = the operate-gated Terminal tab on the server-details page.
Livewire::actingAs($this->admin())->test(ServerTerminal::class, ['server' => $server])->call('open');
$this->assertSame(2, TerminalSession::count()); // both targets minted a session
}
public function test_operator_is_refused_the_host_terminal(): void
{
// The host terminal page (manage-fleet) is out of reach for an operator — route + component
// mount both gate it, so they can never mint a host session or open the host-login setup.
$this->actingAs($this->operator());
$this->get(route('terminal'))->assertForbidden();
$this->assertSame(0, TerminalSession::count());
}
public function test_operator_can_open_a_server_shell(): void
{
$server = $this->server();
Livewire::actingAs($this->operator())
->test(ServerTerminal::class, ['server' => $server])
->call('open')
->assertOk(); // server shell = operate → allowed
$this->assertSame(1, TerminalSession::count());
}
public function test_viewer_cannot_open_host_or_server_shell(): void
{
$server = $this->server();
$this->actingAs($this->viewer());
$this->get(route('terminal'))->assertForbidden(); // host page = manage-fleet
Livewire::actingAs($this->viewer())
->test(ServerTerminal::class, ['server' => $server])
->call('open')
->assertForbidden(); // server shell = operate
$this->assertSame(0, TerminalSession::count());
}
// ── manage-panel: System\Index domain/TLS mutations ─────────────────────────
public function test_admin_can_apply_a_domain_change(): void
{
// ConfirmToken binds to the issuing uid — authenticate BEFORE minting it.
$this->actingAs($this->admin());
$token = $this->confirmed('domainChanged', ['domain' => 'panel.example.test']);
Livewire::test(SystemIndex::class)
->call('applyDomain', $token)
->assertSet('domainInput', 'panel.example.test'); // allowed → domain persisted
$this->assertSame('panel.example.test', app(DeploymentService::class)->configuredDomain());
}
public function test_operator_cannot_apply_a_domain_change(): void
{
Livewire::actingAs($this->operator())
->test(SystemIndex::class)
->call('applyDomain', 'irrelevant')
->assertForbidden();
$this->assertNull(app(DeploymentService::class)->configuredDomain());
}
public function test_operator_cannot_confirm_a_domain_change(): void
{
Livewire::actingAs($this->operator())
->test(SystemIndex::class)
->set('domainInput', 'panel.example.test')
->call('confirmDomain')
->assertForbidden();
}
public function test_viewer_cannot_apply_a_domain_change(): void
{
Livewire::actingAs($this->viewer())
->test(SystemIndex::class)
->call('applyDomain', 'irrelevant')
->assertForbidden();
}
}