Deploy Ladill Hosting / deploy (push) Failing after 17s
Shared web hosting extracted from the platform monolith, with CI deploy to /var/www/ladill-hosting matching Bird/Domains/Email. Co-authored-by: Cursor <cursoragent@cursor.com>
439 lines
16 KiB
PHP
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',
|
|
]);
|
|
});
|
|
}
|
|
}
|