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]); } /** An online server with a credential, selected as the active fleet server. */ private function activeServer(): 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']); session(['active_server_id' => $server->id]); return $server; } /** A confirmed, single-use token for the given apply handler + server. */ private function confirmed(string $event, array $params, int $serverId): string { $token = ConfirmToken::issue($event, $params, '', null, $serverId); ConfirmToken::confirm($token); // the modal confirmation step return $token; } // ── operate: Services\Index::applyService (systemctl restart) ──────────────── public function test_viewer_cannot_apply_a_service_action(): void { $this->actingAs($this->viewer()); $server = $this->activeServer(); // The privileged SSH call must never be reached — the guard fires first. $fleet = Mockery::mock(FleetService::class); $fleet->shouldReceive('serviceAction')->never(); app()->instance(FleetService::class, $fleet); $token = $this->confirmed('serviceConfirmed', ['op' => 'restart', 'name' => 'nginx.service'], $server->id); Livewire::test(Services::class) ->call('applyService', $token) ->assertForbidden(); } public function test_operator_can_apply_a_service_action(): void { $this->actingAs($this->operator()); $server = $this->activeServer(); $fleet = Mockery::mock(FleetService::class); $fleet->shouldReceive('serviceAction')->once() ->with(Mockery::type(Server::class), 'restart', 'nginx.service') ->andReturn(['ok' => true, 'output' => '']); $fleet->shouldReceive('systemd')->andReturn(['services' => []]); // reload after success app()->instance(FleetService::class, $fleet); $token = $this->confirmed('serviceConfirmed', ['op' => 'restart', 'name' => 'nginx.service'], $server->id); Livewire::test(Services::class) ->call('applyService', $token) ->assertOk(); // allowed through the guard, SSH mocked away } public function test_admin_can_apply_a_service_action(): void { $this->actingAs($this->admin()); $server = $this->activeServer(); $fleet = Mockery::mock(FleetService::class); $fleet->shouldReceive('serviceAction')->once() ->with(Mockery::type(Server::class), 'restart', 'nginx.service') ->andReturn(['ok' => true, 'output' => '']); $fleet->shouldReceive('systemd')->andReturn(['services' => []]); app()->instance(FleetService::class, $fleet); $token = $this->confirmed('serviceConfirmed', ['op' => 'restart', 'name' => 'nginx.service'], $server->id); Livewire::test(Services::class) ->call('applyService', $token) ->assertOk(); } // ── operate: Files\Index::deleteEntry (SFTP unlink) ───────────────────────── public function test_viewer_cannot_delete_a_file(): void { $this->actingAs($this->viewer()); $server = $this->activeServer(); $fleet = Mockery::mock(FleetService::class); $fleet->shouldReceive('files')->andReturn([]); // any dir listing during mount/load $fleet->shouldReceive('deleteFile')->never(); // guard fires before the SFTP unlink app()->instance(FleetService::class, $fleet); $token = $this->confirmed('fileConfirmed', ['path' => '/etc/passwd'], $server->id); Livewire::test(Files::class) ->call('deleteEntry', $token) ->assertForbidden(); } public function test_operator_can_delete_a_file(): void { $this->actingAs($this->operator()); $server = $this->activeServer(); $fleet = Mockery::mock(FleetService::class); $fleet->shouldReceive('files')->andReturn([]); $fleet->shouldReceive('deleteFile')->once() ->with(Mockery::type(Server::class), '/etc/passwd'); app()->instance(FleetService::class, $fleet); $token = $this->confirmed('fileConfirmed', ['path' => '/etc/passwd'], $server->id); Livewire::test(Files::class) ->call('deleteEntry', $token) ->assertOk(); // allowed through the guard, SFTP mocked away } public function test_admin_can_delete_a_file(): void { $this->actingAs($this->admin()); $server = $this->activeServer(); $fleet = Mockery::mock(FleetService::class); $fleet->shouldReceive('files')->andReturn([]); $fleet->shouldReceive('deleteFile')->once() ->with(Mockery::type(Server::class), '/etc/passwd'); app()->instance(FleetService::class, $fleet); $token = $this->confirmed('fileConfirmed', ['path' => '/etc/passwd'], $server->id); Livewire::test(Files::class) ->call('deleteEntry', $token) ->assertOk(); } // ── operate: Modals\FileEditor::save (SFTP write) ─────────────────────────── public function test_viewer_cannot_save_a_file_edit(): void { $this->actingAs($this->viewer()); $server = Server::create(['name' => 's', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']); $fleet = Mockery::mock(FleetService::class); $fleet->shouldReceive('writeFile')->never(); // guard fires before the remote write $this->app->instance(FleetService::class, $fleet); $this->app->instance(Sftp::class, Mockery::mock(Sftp::class)); // resolvable, unused for text Livewire::test(FileEditor::class, ['serverId' => $server->id, 'path' => '/etc/x.conf', 'name' => 'x.conf']) ->set('content', 'edited = wert') ->call('save') ->assertForbidden(); } public function test_operator_can_save_a_file_edit(): void { $this->actingAs($this->operator()); $server = Server::create(['name' => 's', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']); $fleet = Mockery::mock(FleetService::class); $fleet->shouldReceive('writeFile')->once() ->with(Mockery::type(Server::class), '/etc/x.conf', 'edited = wert'); $this->app->instance(FleetService::class, $fleet); $this->app->instance(Sftp::class, Mockery::mock(Sftp::class)); Livewire::test(FileEditor::class, ['serverId' => $server->id, 'path' => '/etc/x.conf', 'name' => 'x.conf']) ->set('content', 'edited = wert') ->call('save') ->assertOk(); // allowed through the guard, SFTP mocked away } public function test_admin_can_save_a_file_edit(): void { $this->actingAs($this->admin()); $server = Server::create(['name' => 's', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']); $fleet = Mockery::mock(FleetService::class); $fleet->shouldReceive('writeFile')->once() ->with(Mockery::type(Server::class), '/etc/x.conf', 'edited = wert'); $this->app->instance(FleetService::class, $fleet); $this->app->instance(Sftp::class, Mockery::mock(Sftp::class)); Livewire::test(FileEditor::class, ['serverId' => $server->id, 'path' => '/etc/x.conf', 'name' => 'x.conf']) ->set('content', 'edited = wert') ->call('save') ->assertOk(); } // ── operate: file CONTENT reads (download + editor view) ──────────────────── // Browsing the LISTING (names/perms/size) stays open to a viewer — see the browse // test below. But pulling a file's BYTES (SFTP get) discloses secrets the server's // credential can read (/etc/shadow, keys, .env) when the credential is root, which is // beyond "read-only". So the content reads — Files::download, Files::edit (opens the // editor) and FileEditor::load (the actual read) — require operate, like the writes. public function test_viewer_cannot_download_a_file(): void { $this->actingAs($this->viewer()); $this->activeServer(); $fleet = Mockery::mock(FleetService::class); $fleet->shouldReceive('files')->andReturn([]); $fleet->shouldReceive('getFile')->never(); // guard fires before the SFTP get app()->instance(FleetService::class, $fleet); Livewire::test(Files::class) ->set('entries', [['name' => 'shadow', 'type' => 'file', 'size' => 1, 'perms' => '-rw-------', 'owner' => 'root', 'modified' => 'now']]) ->call('download', 0) ->assertForbidden(); } public function test_operator_can_download_a_file(): void { $this->actingAs($this->operator()); $this->activeServer(); $fleet = Mockery::mock(FleetService::class); $fleet->shouldReceive('files')->andReturn([]); $fleet->shouldReceive('getFile')->once() ->with(Mockery::type(Server::class), '/shadow') ->andReturn('root:x:...'); app()->instance(FleetService::class, $fleet); Livewire::test(Files::class) ->set('entries', [['name' => 'shadow', 'type' => 'file', 'size' => 1, 'perms' => '-rw-------', 'owner' => 'root', 'modified' => 'now']]) ->call('download', 0) ->assertOk(); // allowed through the guard, SFTP mocked away } public function test_viewer_cannot_open_the_file_editor(): void { $this->actingAs($this->viewer()); $this->activeServer(); $fleet = Mockery::mock(FleetService::class); $fleet->shouldReceive('files')->andReturn([]); app()->instance(FleetService::class, $fleet); Livewire::test(Files::class) ->set('entries', [['name' => 'x.conf', 'type' => 'file', 'size' => 1, 'perms' => '-rw-r--r--', 'owner' => 'root', 'modified' => 'now']]) ->call('edit', 0) ->assertForbidden(); } public function test_viewer_cannot_read_file_content_in_the_editor(): void { $this->actingAs($this->viewer()); $server = Server::create(['name' => 's', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']); $fleet = Mockery::mock(FleetService::class); $fleet->shouldReceive('readFile')->never(); // guard fires before the remote read $this->app->instance(FleetService::class, $fleet); $this->app->instance(Sftp::class, Mockery::mock(Sftp::class)); Livewire::test(FileEditor::class, ['serverId' => $server->id, 'path' => '/etc/x.conf', 'name' => 'x.conf']) ->call('load') ->assertForbidden(); } public function test_operator_can_read_file_content_in_the_editor(): void { $this->actingAs($this->operator()); $server = Server::create(['name' => 's', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']); $fleet = Mockery::mock(FleetService::class); $fleet->shouldReceive('readFile')->once() ->with(Mockery::type(Server::class), '/etc/x.conf') ->andReturn(['content' => 'edited = wert', 'binary' => false, 'tooBig' => false]); $this->app->instance(FleetService::class, $fleet); $this->app->instance(Sftp::class, Mockery::mock(Sftp::class)); Livewire::test(FileEditor::class, ['serverId' => $server->id, 'path' => '/etc/x.conf', 'name' => 'x.conf']) ->call('load') ->assertOk() // allowed through the guard, SFTP mocked away ->assertSet('content', 'edited = wert'); } // ── read-only: a viewer keeps READ access (proves read-only, not locked-out) ─ public function test_viewer_can_load_the_services_list(): void { $this->actingAs($this->viewer()); $server = $this->activeServer(); $fleet = Mockery::mock(FleetService::class); $fleet->shouldReceive('systemd')->andReturn([ 'services' => [['name' => 'nginx.service', 'status' => 'online', 'sub' => 'running', 'enabled' => true, 'desc' => 'web']], 'journal' => [], 'cursor' => null, ]); app()->instance(FleetService::class, $fleet); Livewire::test(Services::class) ->assertOk() // page mounts for a viewer ->call('load') // the lazy read runs ->assertOk() ->assertSet('connected', true) ->assertSet('services.0.name', 'nginx.service'); // the list is visible } public function test_viewer_can_browse_the_file_manager(): void { $this->actingAs($this->viewer()); $this->activeServer(); $fleet = Mockery::mock(FleetService::class); $fleet->shouldReceive('files')->andReturn([ ['name' => 'etc', 'type' => 'dir', 'size' => null, 'perms' => 'drwxr-xr-x', 'owner' => 'root', 'modified' => 'now'], ]); app()->instance(FleetService::class, $fleet); Livewire::test(Files::class) ->assertOk() // page mounts for a viewer ->call('load') // the lazy directory read runs ->assertOk() ->assertSet('connected', true) ->assertSet('entries.0.name', 'etc'); // the listing is visible } }