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(); } }