233 lines
8.7 KiB
PHP
233 lines
8.7 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Livewire\Modals\Fail2banBans;
|
|
use App\Livewire\Modals\FirewallRule;
|
|
use App\Livewire\System\Index as SystemIndex;
|
|
use App\Livewire\Versions\Index as VersionsIndex;
|
|
use App\Livewire\Wireguard\Index as WireguardIndex;
|
|
use App\Models\Server;
|
|
use App\Models\User;
|
|
use App\Services\DeploymentService;
|
|
use App\Services\FirewallService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Livewire\Livewire;
|
|
use Mockery;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* RBAC guards on the DANGEROUS network + panel actions. The RBAC foundation (Role enum,
|
|
* User::isAdmin(), Gates manage-panel/manage-network) is committed separately; this proves the
|
|
* abort_unless(...) guards are wired at every dangerous entry point:
|
|
* - manage-panel gates the self-update (Versions\Index::requestUpdate) + stack restart
|
|
* (System\Index::restartNow);
|
|
* - manage-network gates the whole WireGuard page + its mutations and the network modals.
|
|
* Admin (factory default) may act; operator + viewer are refused with 403. Every host/SSH service
|
|
* is mocked so no real network is touched.
|
|
*/
|
|
class RbacNetworkGateTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
// Neutral self-hosted host so the update check is hermetic (matches VersionUpdateCheckTest).
|
|
private const REPO = 'https://selfhosted.test/o/clusev';
|
|
|
|
private const TAGS_URL = 'selfhosted.test/api/v1/repos/o/clusev/tags*';
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
Cache::flush();
|
|
config()->set('clusev.repository', self::REPO);
|
|
app(DeploymentService::class)->clearUpdateRequest(); // never inherit a stray sentinel
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
app(DeploymentService::class)->clearUpdateRequest();
|
|
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]);
|
|
}
|
|
|
|
// ── manage-panel: Versions\Index::requestUpdate ─────────────────────────────
|
|
|
|
public function test_admin_can_request_update(): void
|
|
{
|
|
config()->set('clusev.version', '0.9.4');
|
|
Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]);
|
|
|
|
// Mock the sentinel write so the admin path performs no real side effect.
|
|
$deployment = Mockery::mock(DeploymentService::class)->makePartial();
|
|
$deployment->shouldReceive('requestUpdate')->andReturn(true);
|
|
$this->app->instance(DeploymentService::class, $deployment);
|
|
|
|
Livewire::actingAs($this->admin())
|
|
->test(VersionsIndex::class)
|
|
->call('requestUpdate')
|
|
->assertSet('updateRequested', true); // no 403 — the admin was allowed through
|
|
}
|
|
|
|
public function test_operator_cannot_request_update(): void
|
|
{
|
|
Livewire::actingAs($this->operator())
|
|
->test(VersionsIndex::class)
|
|
->call('requestUpdate')
|
|
->assertForbidden();
|
|
}
|
|
|
|
public function test_viewer_cannot_request_update(): void
|
|
{
|
|
Livewire::actingAs($this->viewer())
|
|
->test(VersionsIndex::class)
|
|
->call('requestUpdate')
|
|
->assertForbidden();
|
|
}
|
|
|
|
// ── manage-panel: System\Index::restartNow ──────────────────────────────────
|
|
|
|
public function test_admin_can_restart_stack(): void
|
|
{
|
|
$deployment = Mockery::mock(DeploymentService::class)->makePartial();
|
|
$deployment->shouldReceive('requestRestart')->andReturn(true);
|
|
$this->app->instance(DeploymentService::class, $deployment);
|
|
|
|
Livewire::actingAs($this->admin())
|
|
->test(SystemIndex::class)
|
|
->call('restartNow')
|
|
->assertSet('restartRequested', true); // allowed through the guard
|
|
}
|
|
|
|
public function test_operator_cannot_restart_stack(): void
|
|
{
|
|
Livewire::actingAs($this->operator())
|
|
->test(SystemIndex::class)
|
|
->call('restartNow')
|
|
->assertForbidden();
|
|
}
|
|
|
|
public function test_viewer_cannot_restart_stack(): void
|
|
{
|
|
Livewire::actingAs($this->viewer())
|
|
->test(SystemIndex::class)
|
|
->call('restartNow')
|
|
->assertForbidden();
|
|
}
|
|
|
|
// ── manage-network: WireGuard page mount + mutations ────────────────────────
|
|
|
|
public function test_admin_can_mount_wireguard(): void
|
|
{
|
|
Livewire::actingAs($this->admin())
|
|
->test(WireguardIndex::class)
|
|
->assertOk();
|
|
}
|
|
|
|
public function test_operator_cannot_mount_wireguard(): void
|
|
{
|
|
Livewire::actingAs($this->operator())
|
|
->test(WireguardIndex::class)
|
|
->assertForbidden();
|
|
}
|
|
|
|
public function test_viewer_cannot_mount_wireguard(): void
|
|
{
|
|
Livewire::actingAs($this->viewer())
|
|
->test(WireguardIndex::class)
|
|
->assertForbidden();
|
|
}
|
|
|
|
public function test_operator_cannot_add_wireguard_peer(): void
|
|
{
|
|
// Prove addPeer()'s OWN method-level guard refuses an operator (not just mount's). An admin
|
|
// boots the component so the snapshot is valid, then we swap the acting user to an operator
|
|
// and call the mutation — the addPeer() abort_unless must still fire 403. This models a
|
|
// demotion mid-session or a hand-crafted /livewire/update, both re-gated by the method guard.
|
|
$component = Livewire::actingAs($this->admin())->test(WireguardIndex::class)->assertOk();
|
|
|
|
$this->actingAs($this->operator());
|
|
|
|
$component->call('addPeer')->assertForbidden();
|
|
}
|
|
|
|
// ── manage-network: FirewallRule modal (mount + save) ───────────────────────
|
|
|
|
public function test_admin_can_mount_and_save_firewall_rule(): void
|
|
{
|
|
$server = Server::create(['name' => 's', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']);
|
|
|
|
$firewall = Mockery::mock(FirewallService::class);
|
|
$firewall->shouldReceive('addRule')->once()->andReturn(['ok' => true, 'output' => '']);
|
|
$this->app->instance(FirewallService::class, $firewall);
|
|
|
|
Livewire::actingAs($this->admin())
|
|
->test(FirewallRule::class, ['serverId' => $server->id, 'tool' => 'ufw'])
|
|
->set('action', 'allow')
|
|
->set('proto', 'tcp')
|
|
->set('port', '2222')
|
|
->call('save')
|
|
->assertHasNoErrors(); // allowed through, SSH mocked away
|
|
}
|
|
|
|
public function test_operator_cannot_mount_firewall_rule(): void
|
|
{
|
|
$server = Server::create(['name' => 's', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']);
|
|
|
|
// No real firewall service should ever be reached — the mount guard fires first.
|
|
$firewall = Mockery::mock(FirewallService::class);
|
|
$firewall->shouldReceive('addRule')->never();
|
|
$this->app->instance(FirewallService::class, $firewall);
|
|
|
|
Livewire::actingAs($this->operator())
|
|
->test(FirewallRule::class, ['serverId' => $server->id, 'tool' => 'ufw'])
|
|
->assertForbidden();
|
|
}
|
|
|
|
public function test_viewer_cannot_mount_firewall_rule(): void
|
|
{
|
|
$server = Server::create(['name' => 's', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']);
|
|
|
|
Livewire::actingAs($this->viewer())
|
|
->test(FirewallRule::class, ['serverId' => $server->id, 'tool' => 'ufw'])
|
|
->assertForbidden();
|
|
}
|
|
|
|
// ── manage-network: Fail2banBans modal (unban is the same privileged SSH call as
|
|
// Servers\Show::unbanIp — guarded here too so the modal isn't a bypass) ──────
|
|
|
|
public function test_operator_cannot_mount_fail2ban_bans_modal(): void
|
|
{
|
|
$server = Server::create(['name' => 's', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']);
|
|
|
|
Livewire::actingAs($this->operator())
|
|
->test(Fail2banBans::class, ['serverId' => $server->id])
|
|
->assertForbidden();
|
|
}
|
|
|
|
public function test_viewer_cannot_mount_fail2ban_bans_modal(): void
|
|
{
|
|
$server = Server::create(['name' => 's', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']);
|
|
|
|
Livewire::actingAs($this->viewer())
|
|
->test(Fail2banBans::class, ['serverId' => $server->id])
|
|
->assertForbidden();
|
|
}
|
|
}
|