Add first-class hosting subdomain create/attach in Domains panel.
Deploy Ladill Hosting / deploy (push) Successful in 1m9s
Deploy Ladill Hosting / deploy (push) Successful in 1m9s
Customers can create label-based subdomains under primary/addon sites with a separate max_domains×5 quota, nginx/docroot/SSL provisioning, and managed DNS A records when Ladill controls the parent zone. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -15,12 +15,19 @@ use App\Services\Domain\DomainVerificationService;
|
||||
use App\Services\Hosting\Providers\SharedNodeProvider;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Tests\TestCase;
|
||||
|
||||
class HostingPanelDomainLinkingTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Bus::fake();
|
||||
}
|
||||
|
||||
public function test_add_domain_uses_automated_nameserver_onboarding_by_default(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
@@ -67,6 +74,7 @@ class HostingPanelDomainLinkingTest extends TestCase
|
||||
]);
|
||||
|
||||
$verification = \Mockery::mock(DomainVerificationService::class);
|
||||
$verification->shouldReceive('reprovision')->once();
|
||||
$verification->shouldNotReceive('prepareNameserverZone');
|
||||
$verification->shouldNotReceive('prepareManualDnsPack');
|
||||
|
||||
@@ -169,6 +177,7 @@ class HostingPanelDomainLinkingTest extends TestCase
|
||||
$this->assertStringContainsString('primary.example.com', $html);
|
||||
$this->assertStringContainsString('linked-example.com', $html);
|
||||
$this->assertStringContainsString('2 of 3 domain slots in use.', $html);
|
||||
$this->assertStringContainsString('0 of 15 subdomain slots in use.', $html);
|
||||
}
|
||||
|
||||
public function test_add_domain_does_not_restore_soft_deleted_site_from_another_account(): void
|
||||
@@ -260,4 +269,245 @@ class HostingPanelDomainLinkingTest extends TestCase
|
||||
'domain' => 'linked-example.com',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_add_subdomain_creates_hosted_site_under_parent_domain(): void
|
||||
{
|
||||
[$user, $account, $parent] = $this->makeAccountWithPrimary(maxDomains: 1, withManagedDomain: true);
|
||||
|
||||
$provider = \Mockery::mock(SharedNodeProvider::class);
|
||||
$provider->shouldReceive('addSite')
|
||||
->once()
|
||||
->withArgs(fn (HostedSite $site): bool => $site->domain === 'blog.primary.example.com' && $site->type === 'subdomain')
|
||||
->andReturn(['document_root' => '/home/acctuser/public_html/blog.primary.example.com', 'domain' => 'blog.primary.example.com']);
|
||||
|
||||
$pdns = \Mockery::mock(\App\Services\Dns\PowerDnsClient::class);
|
||||
$pdns->shouldReceive('upsertRecords')
|
||||
->once()
|
||||
->withArgs(function (string $zone, array $records) {
|
||||
return $zone === 'primary.example.com'
|
||||
&& ($records[0]['name'] ?? null) === 'blog.primary.example.com'
|
||||
&& ($records[0]['contents'][0] ?? null) === '127.0.0.1';
|
||||
})
|
||||
->andReturn(['status' => 'updated', 'count' => 1]);
|
||||
|
||||
$this->app->instance(SharedNodeProvider::class, $provider);
|
||||
$this->app->instance(\App\Services\Dns\PowerDnsClient::class, $pdns);
|
||||
|
||||
$session = $this->app['session.store'];
|
||||
$session->start();
|
||||
|
||||
$request = Request::create('http://localhost/hosting/panel/'.$account->id.'/domains/subdomains', 'POST', [
|
||||
'parent_site_id' => $parent->id,
|
||||
'subdomain' => 'blog',
|
||||
], [], [], [
|
||||
'HTTP_REFERER' => 'http://localhost/hosting/panel/'.$account->id.'/domains',
|
||||
]);
|
||||
$request->setUserResolver(fn (): User => $user);
|
||||
$request->setLaravelSession($session);
|
||||
|
||||
$response = $this->app->make(HostingPanelController::class)->addSubdomain(
|
||||
$request,
|
||||
$account,
|
||||
$this->app->make(\App\Services\Dns\PowerDnsClient::class),
|
||||
);
|
||||
|
||||
$this->assertSame(302, $response->getStatusCode());
|
||||
$this->assertDatabaseHas('hosted_sites', [
|
||||
'hosting_account_id' => $account->id,
|
||||
'domain' => 'blog.primary.example.com',
|
||||
'type' => 'subdomain',
|
||||
'domain_id' => $parent->domain_id,
|
||||
'document_root' => '/home/acctuser/public_html/blog.primary.example.com',
|
||||
]);
|
||||
$this->assertSame(1, $account->fresh()->subdomainSitesCount());
|
||||
$this->assertSame(5, $account->fresh()->maxSubdomainsLimit());
|
||||
}
|
||||
|
||||
public function test_subdomain_quota_is_max_domains_times_five(): void
|
||||
{
|
||||
[$user, $account, $parent] = $this->makeAccountWithPrimary(maxDomains: 1, withManagedDomain: false);
|
||||
|
||||
$provider = \Mockery::mock(SharedNodeProvider::class);
|
||||
$provider->shouldReceive('addSite')->times(5)->andReturnUsing(function (HostedSite $site) {
|
||||
return ['document_root' => $site->document_root, 'domain' => $site->domain];
|
||||
});
|
||||
$this->app->instance(SharedNodeProvider::class, $provider);
|
||||
$this->app->instance(\App\Services\Dns\PowerDnsClient::class, \Mockery::mock(\App\Services\Dns\PowerDnsClient::class));
|
||||
|
||||
$controller = $this->app->make(HostingPanelController::class);
|
||||
|
||||
for ($i = 1; $i <= 5; $i++) {
|
||||
$session = $this->app['session.store'];
|
||||
$session->start();
|
||||
$request = Request::create('/domains/subdomains', 'POST', [
|
||||
'parent_site_id' => $parent->id,
|
||||
'subdomain' => 'sub'.$i,
|
||||
]);
|
||||
$request->setUserResolver(fn (): User => $user);
|
||||
$request->setLaravelSession($session);
|
||||
|
||||
$response = $controller->addSubdomain($request, $account->fresh(), $this->app->make(\App\Services\Dns\PowerDnsClient::class));
|
||||
$this->assertSame(302, $response->getStatusCode());
|
||||
$this->assertNull($session->get('error'), 'sub'.$i.' should succeed');
|
||||
}
|
||||
|
||||
$this->assertSame(5, $account->fresh()->subdomainSitesCount());
|
||||
|
||||
$session = $this->app['session.store'];
|
||||
$session->start();
|
||||
$request = Request::create('/domains/subdomains', 'POST', [
|
||||
'parent_site_id' => $parent->id,
|
||||
'subdomain' => 'sub6',
|
||||
]);
|
||||
$request->setUserResolver(fn (): User => $user);
|
||||
$request->setLaravelSession($session);
|
||||
|
||||
$response = $controller->addSubdomain($request, $account->fresh(), $this->app->make(\App\Services\Dns\PowerDnsClient::class));
|
||||
$this->assertSame(302, $response->getStatusCode());
|
||||
$this->assertSame('You have reached the maximum number of subdomains (5).', $session->get('error'));
|
||||
$this->assertSame(5, $account->fresh()->subdomainSitesCount());
|
||||
}
|
||||
|
||||
public function test_addon_domain_cap_ignores_subdomains(): void
|
||||
{
|
||||
[$user, $account, $parent] = $this->makeAccountWithPrimary(maxDomains: 2, withManagedDomain: false);
|
||||
|
||||
HostedSite::create([
|
||||
'hosting_account_id' => $account->id,
|
||||
'domain' => 'blog.primary.example.com',
|
||||
'document_root' => '/home/acctuser/public_html/blog.primary.example.com',
|
||||
'type' => 'subdomain',
|
||||
'status' => 'active',
|
||||
'domain_id' => $parent->domain_id,
|
||||
]);
|
||||
|
||||
$provider = \Mockery::mock(SharedNodeProvider::class);
|
||||
$provider->shouldReceive('addSite')->once()->andReturn([
|
||||
'document_root' => '/home/acctuser/public_html/addon.example.com',
|
||||
'domain' => 'addon.example.com',
|
||||
]);
|
||||
$verification = \Mockery::mock(DomainVerificationService::class);
|
||||
$verification->shouldReceive('reprovision')->once();
|
||||
$this->app->instance(SharedNodeProvider::class, $provider);
|
||||
$this->app->instance(DomainVerificationService::class, $verification);
|
||||
|
||||
$session = $this->app['session.store'];
|
||||
$session->start();
|
||||
$request = Request::create('/domains', 'POST', ['domain' => 'addon.example.com']);
|
||||
$request->setUserResolver(fn (): User => $user);
|
||||
$request->setLaravelSession($session);
|
||||
|
||||
$response = $this->app->make(HostingPanelController::class)->addDomain(
|
||||
$request,
|
||||
$account->fresh(),
|
||||
$this->app->make(DomainDnsBlueprintService::class),
|
||||
$this->app->make(DomainVerificationService::class),
|
||||
);
|
||||
|
||||
$this->assertSame(302, $response->getStatusCode());
|
||||
$this->assertNull($session->get('error'));
|
||||
$this->assertDatabaseHas('hosted_sites', [
|
||||
'hosting_account_id' => $account->id,
|
||||
'domain' => 'addon.example.com',
|
||||
'type' => 'addon',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_add_subdomain_rejects_reserved_and_invalid_labels(): void
|
||||
{
|
||||
[$user, $account, $parent] = $this->makeAccountWithPrimary(maxDomains: 1, withManagedDomain: false);
|
||||
$this->app->instance(SharedNodeProvider::class, \Mockery::mock(SharedNodeProvider::class));
|
||||
$this->app->instance(\App\Services\Dns\PowerDnsClient::class, \Mockery::mock(\App\Services\Dns\PowerDnsClient::class));
|
||||
$controller = $this->app->make(HostingPanelController::class);
|
||||
|
||||
$session = $this->app['session.store'];
|
||||
$session->start();
|
||||
$request = Request::create('/domains/subdomains', 'POST', [
|
||||
'parent_site_id' => $parent->id,
|
||||
'subdomain' => 'www',
|
||||
]);
|
||||
$request->setUserResolver(fn (): User => $user);
|
||||
$request->setLaravelSession($session);
|
||||
$controller->addSubdomain($request, $account, $this->app->make(\App\Services\Dns\PowerDnsClient::class));
|
||||
$this->assertSame('The subdomain "www" is reserved.', $session->get('error'));
|
||||
|
||||
$this->expectException(\Illuminate\Validation\ValidationException::class);
|
||||
$session = $this->app['session.store'];
|
||||
$session->start();
|
||||
$request = Request::create('/domains/subdomains', 'POST', [
|
||||
'parent_site_id' => $parent->id,
|
||||
'subdomain' => 'bad.label',
|
||||
]);
|
||||
$request->setUserResolver(fn (): User => $user);
|
||||
$request->setLaravelSession($session);
|
||||
$controller->addSubdomain($request, $account, $this->app->make(\App\Services\Dns\PowerDnsClient::class));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: User, 1: HostingAccount, 2: HostedSite}
|
||||
*/
|
||||
private function makeAccountWithPrimary(int $maxDomains, bool $withManagedDomain): array
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$product = HostingProduct::create([
|
||||
'name' => 'Hosting Plan',
|
||||
'slug' => 'hosting-plan-'.uniqid(),
|
||||
'category' => 'shared',
|
||||
'type' => 'multi_domain',
|
||||
'price_monthly' => 10,
|
||||
'max_domains' => $maxDomains,
|
||||
]);
|
||||
|
||||
$node = HostingNode::create([
|
||||
'name' => 'Local Node',
|
||||
'hostname' => 'local-node-'.uniqid(),
|
||||
'ip_address' => '127.0.0.1',
|
||||
'type' => 'shared',
|
||||
'provider' => 'local',
|
||||
'status' => 'active',
|
||||
'ssh_port' => '22',
|
||||
]);
|
||||
|
||||
$account = HostingAccount::unguarded(function () use ($user, $node, $product, $maxDomains) {
|
||||
return HostingAccount::create([
|
||||
'user_id' => $user->id,
|
||||
'hosting_product_id' => $product->id,
|
||||
'hosting_node_id' => $node->id,
|
||||
'username' => 'acctuser',
|
||||
'primary_domain' => 'primary.example.com',
|
||||
'type' => 'shared',
|
||||
'status' => 'active',
|
||||
'php_version' => '8.2',
|
||||
'resource_limits' => ['max_domains' => $maxDomains],
|
||||
]);
|
||||
});
|
||||
|
||||
$domain = null;
|
||||
if ($withManagedDomain) {
|
||||
$domain = Domain::create([
|
||||
'user_id' => $user->id,
|
||||
'hosting_account_id' => $account->id,
|
||||
'host' => 'primary.example.com',
|
||||
'type' => 'custom',
|
||||
'source' => 'hosting',
|
||||
'onboarding_mode' => Domain::MODE_NS_AUTO,
|
||||
'status' => 'verified',
|
||||
'onboarding_state' => Domain::STATE_ACTIVE,
|
||||
'dns_mode' => 'managed',
|
||||
]);
|
||||
}
|
||||
|
||||
$parent = HostedSite::create([
|
||||
'hosting_account_id' => $account->id,
|
||||
'domain_id' => $domain?->id,
|
||||
'domain' => 'primary.example.com',
|
||||
'document_root' => '/home/acctuser/public_html',
|
||||
'type' => 'primary',
|
||||
'status' => 'active',
|
||||
'ssl_enabled' => true,
|
||||
]);
|
||||
|
||||
return [$user, $account->fresh(['sites', 'product', 'node']), $parent->fresh()];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user