create(); $developer = User::factory()->create(); $account = $this->createHostingAccount($owner); $this->actingAs($owner) ->get(route('user.team.index')) ->assertOk() ->assertSee('Add Developer') ->assertSee($account->primary_domain); $response = $this->actingAs($owner)->post(route('user.team.store'), [ 'email' => $developer->email, 'hosting_account_ids' => [$account->id], ]); $response->assertRedirect(); $response->assertSessionHas('success', 'Developer access granted and notification sent.'); $this->assertDatabaseHas('hosting_account_members', [ 'hosting_account_id' => $account->id, 'user_id' => $developer->id, 'role' => HostingAccountMember::ROLE_DEVELOPER, ]); Notification::assertSentTo($developer, HostingDeveloperAddedNotification::class); } public function test_owner_can_invite_new_developer_account_and_send_setup_notification(): void { Notification::fake(); $owner = User::factory()->create(); $account = $this->createHostingAccount($owner); $response = $this->actingAs($owner)->post(route('user.team.store'), [ 'name' => 'External Developer', 'email' => 'external-dev@example.com', 'hosting_account_ids' => [$account->id], ]); $response->assertRedirect(); $response->assertSessionHas('success', 'Developer added and invited by email.'); $developer = User::query()->where('email', 'external-dev@example.com')->first(); $this->assertNotNull($developer); $this->assertDatabaseHas('hosting_account_members', [ 'hosting_account_id' => $account->id, 'user_id' => $developer->id, ]); Notification::assertSentTo($developer, HostingDeveloperAddedNotification::class); } public function test_owner_can_invite_developer_with_ssh_key_and_install_shell_access(): void { Notification::fake(); $owner = User::factory()->create(); $developer = User::factory()->create(); $account = $this->createHostingAccount($owner); $sshKey = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBlY0P2hT9m5m3GAnExampleKeyData123456789 developer@example.com'; $provider = \Mockery::mock(SharedNodeProvider::class); $provider->shouldReceive('installTeamMemberSshKey') ->once() ->withArgs(function (HostingAccount $passedAccount, string $marker, string $publicKey) use ($account, $sshKey) { return $passedAccount->is($account) && str_starts_with($marker, 'ladill-team-member-') && $publicKey === $sshKey; }); $this->app->instance(SharedNodeProvider::class, $provider); $response = $this->actingAs($owner)->post(route('user.team.store'), [ 'email' => $developer->email, 'hosting_account_ids' => [$account->id], 'ssh_public_key' => $sshKey, ]); $response->assertRedirect(); $response->assertSessionHas('success'); $membership = HostingAccountMember::query() ->where('hosting_account_id', $account->id) ->where('user_id', $developer->id) ->first(); $this->assertNotNull($membership); $this->assertSame($sshKey, $membership->ssh_public_key); $this->assertNotNull($membership->ssh_key_installed_at); } public function test_developer_can_open_assigned_hosting_views_but_not_owner_only_actions(): void { $owner = User::factory()->create(); $developer = User::factory()->create(); $account = $this->createHostingAccount($owner); HostingAccountMember::create([ 'hosting_account_id' => $account->id, 'user_id' => $developer->id, 'invited_by_user_id' => $owner->id, 'role' => HostingAccountMember::ROLE_DEVELOPER, 'invited_at' => now(), ]); $this->actingAs($developer) ->get(route('hosting.single-domain')) ->assertOk() ->assertSee('Developer access') ->assertSee('Open Panel'); $this->actingAs($developer) ->get(route('hosting.panel.domains', $account)) ->assertOk() ->assertSee('Add Domain'); $this->actingAs($developer) ->get(route('hosting.panel.settings', $account)) ->assertOk() ->assertSee('SFTP Key Access') ->assertDontSee('SFTP Password'); $this->actingAs($developer) ->post(route('hosting.panel.settings.password', $account), [ 'password' => 'new-password-123', 'password_confirmation' => 'new-password-123', ]) ->assertForbidden(); $this->actingAs($developer) ->get(route('hosting.accounts.show', $account)) ->assertForbidden(); } public function test_developer_sees_assigned_hosting_domains_in_all_domains(): void { $owner = User::factory()->create(); $developer = User::factory()->create(); $account = $this->createHostingAccount($owner); HostingAccountMember::create([ 'hosting_account_id' => $account->id, 'user_id' => $developer->id, 'invited_by_user_id' => $owner->id, 'role' => HostingAccountMember::ROLE_DEVELOPER, 'invited_at' => now(), ]); $domain = Domain::create([ 'user_id' => $owner->id, 'hosting_account_id' => $account->id, 'host' => 'developer-visible.example.com', 'type' => 'custom', 'source' => 'hosting', 'status' => 'verified', 'onboarding_state' => Domain::STATE_ACTIVE, ]); $this->actingAs($developer) ->get(route('user.domains.index')) ->assertOk() ->assertSee($domain->host); $this->actingAs($developer) ->get(route('user.domains.show', $domain)) ->assertOk() ->assertSee($domain->host); } public function test_developer_dashboard_counts_assigned_hosting_domains(): void { $owner = User::factory()->create(); $developer = User::factory()->create(); $account = $this->createHostingAccount($owner); HostingAccountMember::create([ 'hosting_account_id' => $account->id, 'user_id' => $developer->id, 'invited_by_user_id' => $owner->id, 'role' => HostingAccountMember::ROLE_DEVELOPER, 'invited_at' => now(), ]); Domain::create([ 'user_id' => $owner->id, 'hosting_account_id' => $account->id, 'host' => 'developer-dashboard.example.com', 'type' => 'custom', 'source' => 'hosting', 'status' => 'verified', 'onboarding_state' => Domain::STATE_ACTIVE, ]); $this->actingAs($developer) ->get(route('dashboard')) ->assertOk() ->assertSeeTextInOrder(['Domains', '1', '0 pending', 'Hosting']); } public function test_developer_only_user_cannot_see_or_open_wallet_and_billing(): void { $owner = User::factory()->create(); $developer = User::factory()->create(); $account = $this->createHostingAccount($owner); HostingAccountMember::create([ 'hosting_account_id' => $account->id, 'user_id' => $developer->id, 'invited_by_user_id' => $owner->id, 'role' => HostingAccountMember::ROLE_DEVELOPER, 'invited_at' => now(), ]); $this->actingAs($developer) ->get(route('dashboard')) ->assertOk() ->assertDontSeeText('Wallet') ->assertDontSeeText('Billing'); $this->actingAs($developer) ->get(route('user.wallet.index')) ->assertForbidden(); $this->actingAs($developer) ->get(route('user.billing')) ->assertForbidden(); } public function test_developer_cannot_remove_hosting_panel_domain_but_owner_can(): void { $owner = User::factory()->create(); $developer = User::factory()->create(); $account = $this->createHostingAccount($owner); HostingAccountMember::create([ 'hosting_account_id' => $account->id, 'user_id' => $developer->id, 'invited_by_user_id' => $owner->id, 'role' => HostingAccountMember::ROLE_DEVELOPER, 'invited_at' => now(), ]); $site = HostedSite::create([ 'hosting_account_id' => $account->id, 'domain' => 'owner-removable.example.com', 'document_root' => '/home/'.$account->username.'/public_html/owner-removable.example.com', 'type' => 'addon', 'status' => 'active', 'ssl_enabled' => false, ]); $this->actingAs($developer) ->delete(route('hosting.panel.domains.remove', [$account, $site])) ->assertForbidden(); $this->assertDatabaseHas('hosted_sites', [ 'id' => $site->id, 'deleted_at' => null, ]); $runtime = \Mockery::mock(PanelRuntimeInterface::class); $runtime->shouldReceive('removeSite') ->once() ->withArgs(fn (HostedSite $passedSite): bool => $passedSite->is($site)) ->andReturn(true); $resolver = \Mockery::mock(PanelRuntimeResolver::class); $resolver->shouldReceive('forSubject') ->once() ->withArgs(fn (HostingAccount $passedAccount): bool => $passedAccount->is($account)) ->andReturn($runtime); $session = $this->app['session.store']; $session->start(); $request = \Illuminate\Http\Request::create(route('hosting.panel.domains.remove', [$account, $site]), 'DELETE', [], [], [], [ 'HTTP_REFERER' => route('hosting.panel.domains', $account), ]); $request->setUserResolver(fn (): User => $owner); $request->setLaravelSession($session); $response = (new \App\Http\Controllers\Hosting\HostingPanelController($resolver)) ->removeDomain($request, $account, $site); $this->assertSame(302, $response->getStatusCode()); $this->assertSame('Domain removed successfully.', $session->get('success')); $this->assertSoftDeleted('hosted_sites', [ 'id' => $site->id, ]); } public function test_owner_can_revoke_developer_and_access_is_removed_immediately(): void { $owner = User::factory()->create(); $developer = User::factory()->create(); $account = $this->createHostingAccount($owner); $membership = HostingAccountMember::create([ 'hosting_account_id' => $account->id, 'user_id' => $developer->id, 'invited_by_user_id' => $owner->id, 'role' => HostingAccountMember::ROLE_DEVELOPER, 'invited_at' => now(), 'ssh_public_key' => 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBlY0P2hT9m5m3GAnExampleKeyData123456789 developer@example.com', 'ssh_key_installed_at' => now(), ]); $provider = \Mockery::mock(SharedNodeProvider::class); $provider->shouldReceive('removeTeamMemberSshKey') ->once() ->withArgs(fn (HostingAccount $passedAccount, string $marker) => (int) $passedAccount->id === (int) $account->id && $marker === $membership->sshKeyMarker()); $this->app->instance(SharedNodeProvider::class, $provider); $this->actingAs($owner) ->delete(route('user.team.destroy', $developer)) ->assertRedirect() ->assertSessionHas('success', 'Developer access removed.'); $this->assertDatabaseMissing('hosting_account_members', [ 'hosting_account_id' => $account->id, 'user_id' => $developer->id, ]); $this->actingAs($developer) ->get(route('hosting.panel.domains', $account)) ->assertForbidden(); } public function test_developer_can_save_and_remove_their_own_ssh_key(): void { $owner = User::factory()->create(); $developer = User::factory()->create(); $account = $this->createHostingAccount($owner); $membership = HostingAccountMember::create([ 'hosting_account_id' => $account->id, 'user_id' => $developer->id, 'invited_by_user_id' => $owner->id, 'role' => HostingAccountMember::ROLE_DEVELOPER, 'invited_at' => now(), ]); $sshKey = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBlY0P2hT9m5m3GAnExampleKeyData123456789 developer@example.com'; $provider = \Mockery::mock(SharedNodeProvider::class); $provider->shouldReceive('installTeamMemberSshKey') ->once() ->withArgs(fn (HostingAccount $passedAccount, string $marker, string $publicKey) => (int) $passedAccount->id === (int) $account->id && $marker === $membership->sshKeyMarker() && $publicKey === $sshKey); $provider->shouldReceive('removeTeamMemberSshKey') ->once() ->withArgs(fn (HostingAccount $passedAccount, string $marker) => (int) $passedAccount->id === (int) $account->id && $marker === $membership->sshKeyMarker()); $this->app->instance(SharedNodeProvider::class, $provider); $this->actingAs($developer) ->post(route('hosting.panel.settings.ssh-key', $account), [ 'ssh_public_key' => $sshKey, ]) ->assertRedirect() ->assertSessionHas('success', 'SSH key saved. You can now connect over SFTP.'); $membership->refresh(); $this->assertSame($sshKey, $membership->ssh_public_key); $this->assertNotNull($membership->ssh_key_installed_at); $this->actingAs($developer) ->delete(route('hosting.panel.settings.ssh-key.destroy', $account)) ->assertRedirect() ->assertSessionHas('success', 'SSH key removed. SFTP access through your team membership has been revoked.'); $membership->refresh(); $this->assertNull($membership->ssh_public_key); $this->assertNull($membership->ssh_key_installed_at); } private function createHostingAccount(User $owner): HostingAccount { $product = HostingProduct::create([ 'name' => 'Single Domain Hosting', 'slug' => 'single-domain-team-test-'.$owner->id, 'category' => 'shared', 'type' => HostingProduct::TYPE_SINGLE_DOMAIN, 'price_monthly' => 10, 'max_domains' => 1, 'is_active' => true, 'is_visible' => true, ]); $node = HostingNode::create([ 'name' => 'Local Node', 'hostname' => 'local-node-'.$owner->id, 'ip_address' => '127.0.0.1', 'type' => 'shared', 'provider' => 'local', 'status' => 'active', 'ssh_port' => '22', ]); return HostingAccount::unguarded(function () use ($owner, $product, $node) { return HostingAccount::create([ 'user_id' => $owner->id, 'hosting_product_id' => $product->id, 'hosting_node_id' => $node->id, 'username' => 'acct'.$owner->id, 'primary_domain' => 'team-test-'.$owner->id.'.example.com', 'type' => 'shared', 'status' => 'active', 'php_version' => '8.2', ]); }); } }