250 lines
9.3 KiB
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();
|
|
}
|
|
}
|