Files
ladill-servers/tests/Feature/HostingTeamAccessTest.php
isaaccladandCursor b6c8ac343f Extract Ladill Servers as standalone app at servers.ladill.com.
VPS and dedicated server ordering, managed panels, SSO, billing, and launcher integration — forked from hosting infrastructure with server-focused routes and dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 19:18:30 +00:00

439 lines
16 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Domain;
use App\Models\HostedSite;
use App\Models\HostingAccount;
use App\Models\HostingAccountMember;
use App\Models\HostingNode;
use App\Models\HostingProduct;
use App\Models\User;
use App\Notifications\HostingDeveloperAddedNotification;
use App\Services\Hosting\Contracts\PanelRuntimeInterface;
use App\Services\Hosting\PanelRuntimeResolver;
use App\Services\Hosting\Providers\SharedNodeProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
class HostingTeamAccessTest extends TestCase
{
use RefreshDatabase;
public function test_owner_can_invite_existing_developer_and_send_notification(): void
{
Notification::fake();
$owner = User::factory()->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',
]);
});
}
}