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>
This commit is contained in:
isaacclad
2026-06-06 19:18:30 +00:00
co-authored by Cursor
commit b6c8ac343f
382 changed files with 67315 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace Tests\Feature;
use App\Models\EmailTeamMember;
use App\Models\User;
use App\Services\Billing\BillingClient;
use App\Services\Payment\WalletPaymentService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Mockery;
use Tests\TestCase;
class AccountPagesTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
private function user(): User
{
return User::create(['public_id' => (string) Str::uuid(), 'name' => 'A', 'email' => 'a+'.uniqid().'@x.com']);
}
public function test_settings_page_renders(): void
{
$this->actingAs($this->user())->get(route('account.settings'))->assertOk()->assertSee('Hosting notifications');
}
public function test_team_page_renders_with_owner(): void
{
$u = $this->user();
$this->actingAs($u)->get(route('account.team'))->assertOk()->assertSee('Owner')->assertSee($u->email);
}
public function test_team_invite_creates_member(): void
{
$u = $this->user();
$this->actingAs($u)->post(route('account.team.store'), ['email' => 'mate@x.com', 'role' => 'member'])
->assertRedirect();
$this->assertDatabaseHas('email_team_members', ['account_id' => $u->id, 'email' => 'mate@x.com', 'status' => 'invited']);
}
public function test_developers_page_and_token_lifecycle(): void
{
$u = $this->user();
$this->actingAs($u)->get(route('account.developers'))->assertOk()->assertSee('Create a token');
$this->actingAs($u)->post(route('account.developers.store'), ['name' => 'CI'])
->assertRedirect()->assertSessionHas('new_token');
$this->assertSame(1, $u->tokens()->count());
}
public function test_billing_page_renders(): void
{
$w = Mockery::mock(WalletPaymentService::class);
$w->shouldReceive('balanceMinor')->andReturn(5000);
$this->app->instance(WalletPaymentService::class, $w);
$b = Mockery::mock(BillingClient::class);
$b->shouldReceive('serviceLedger')->andReturn(['spent_minor' => 1000, 'credited_minor' => 0]);
$b->shouldReceive('balanceMinor')->andReturn(5000);
$this->app->instance(BillingClient::class, $b);
$this->actingAs($this->user())->get(route('account.billing'))
->assertOk()->assertSee('Spent on hosting');
}
public function test_switch_account_rejects_inaccessible(): void
{
$this->actingAs($this->user())->post(route('account.switch'), ['account' => 999999])->assertForbidden();
}
}
@@ -0,0 +1,273 @@
<?php
namespace Tests\Feature;
use App\Models\HostingAccount;
use App\Models\HostingBillingInvoice;
use App\Models\HostingProduct;
use App\Models\User;
use App\Services\Billing\PaystackService;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AdminHostingAccountDurationTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
public function test_admin_can_assign_hosting_package_with_duration(): void
{
Carbon::setTestNow('2026-04-05 10:00:00');
$admin = User::factory()->create(['is_admin' => true]);
$user = User::factory()->create();
$product = $this->createHostingProduct();
$response = $this->actingAs($admin)->post(route('admin.users.assign-hosting-package', $user), [
'hosting_product_id' => $product->id,
'duration_months' => 6,
'primary_domain' => 'example.com',
'username' => 'exampleuser',
]);
$response->assertRedirect();
$account = HostingAccount::query()->where('user_id', $user->id)->firstOrFail();
$this->assertSame('exampleuser', $account->username);
$this->assertSame('example.com', $account->primary_domain);
$this->assertSame($product->id, $account->hosting_product_id);
$this->assertSame('active', $account->status);
$this->assertNotNull($account->expires_at);
$this->assertTrue($account->expires_at->equalTo(Carbon::parse('2026-10-05 10:00:00')));
$this->assertSame(6, data_get($account->metadata, 'assigned_duration_months'));
}
public function test_admin_can_assign_same_hosting_product_multiple_times_to_one_user(): void
{
Carbon::setTestNow('2026-04-05 10:00:00');
$admin = User::factory()->create(['is_admin' => true]);
$user = User::factory()->create();
$product = $this->createHostingProduct();
$first = $this->actingAs($admin)->post(route('admin.users.assign-hosting-package', $user), [
'hosting_product_id' => $product->id,
'duration_months' => 6,
'primary_domain' => 'first-example.com',
'username' => 'firstacct',
]);
$second = $this->actingAs($admin)->post(route('admin.users.assign-hosting-package', $user), [
'hosting_product_id' => $product->id,
'duration_months' => 12,
'primary_domain' => 'second-example.com',
'username' => 'secondacct',
]);
$first->assertRedirect();
$second->assertRedirect();
$accounts = HostingAccount::query()
->where('user_id', $user->id)
->where('hosting_product_id', $product->id)
->orderBy('username')
->get();
$this->assertCount(2, $accounts);
$this->assertSame(['firstacct', 'secondacct'], $accounts->pluck('username')->all());
$this->assertSame(['first-example.com', 'second-example.com'], $accounts->pluck('primary_domain')->all());
}
public function test_admin_can_update_duration_for_existing_hosting_account(): void
{
Carbon::setTestNow('2026-04-05 10:00:00');
$admin = User::factory()->create(['is_admin' => true]);
$user = User::factory()->create();
$product = $this->createHostingProduct();
$account = HostingAccount::query()->create([
'user_id' => $user->id,
'hosting_product_id' => $product->id,
'username' => 'existinguser',
'type' => 'shared',
'status' => 'active',
'expires_at' => Carbon::parse('2026-05-05 10:00:00'),
'metadata' => [
'assigned_duration_months' => 1,
],
]);
$response = $this->actingAs($admin)->patch(route('admin.users.hosting-accounts.update-duration', [$user, $account]), [
'duration_months' => 12,
]);
$response->assertRedirect();
$account->refresh();
$this->assertTrue($account->expires_at->equalTo(Carbon::parse('2027-04-05 10:00:00')));
$this->assertSame(12, data_get($account->metadata, 'assigned_duration_months'));
$this->assertSame($admin->id, data_get($account->metadata, 'duration_updated_by_admin'));
$this->assertNotNull(data_get($account->metadata, 'duration_updated_at'));
}
public function test_admin_can_renew_existing_hosting_account_using_assigned_duration(): void
{
Carbon::setTestNow('2026-04-05 10:00:00');
$admin = User::factory()->create(['is_admin' => true]);
$user = User::factory()->create();
$product = $this->createHostingProduct();
$account = HostingAccount::query()->create([
'user_id' => $user->id,
'hosting_product_id' => $product->id,
'username' => 'renewadmin',
'type' => 'shared',
'status' => 'active',
'expires_at' => Carbon::parse('2026-10-05 10:00:00'),
'metadata' => [
'assigned_duration_months' => 6,
],
]);
$response = $this->actingAs($admin)->post(route('admin.users.hosting-accounts.renew', [$user, $account]));
$response->assertRedirect();
$account->refresh();
$this->assertTrue($account->expires_at->equalTo(Carbon::parse('2027-04-05 10:00:00')));
$this->assertSame($admin->id, data_get($account->metadata, 'renewed_by_admin'));
$this->assertNotNull(data_get($account->metadata, 'renewed_at'));
}
public function test_customer_renewal_starts_payment_checkout_instead_of_renewing_immediately(): void
{
Carbon::setTestNow('2026-04-05 10:00:00');
$user = User::factory()->create();
$product = $this->createHostingProduct();
$account = HostingAccount::query()->create([
'user_id' => $user->id,
'hosting_product_id' => $product->id,
'username' => 'renewcustomer',
'type' => 'shared',
'status' => 'active',
'expires_at' => Carbon::parse('2026-05-05 10:00:00'),
'metadata' => [
'assigned_duration_months' => 3,
],
]);
$paystack = \Mockery::mock(PaystackService::class);
$paystack->shouldReceive('initializeTransaction')
->once()
->andReturn([
'authorization_url' => 'https://paystack.test/renew/authorize',
]);
$this->app->instance(PaystackService::class, $paystack);
$response = $this->actingAs($user)->post(route('hosting.accounts.renew', $account));
$response->assertRedirect('https://paystack.test/renew/authorize');
$account->refresh();
$this->assertTrue($account->expires_at->equalTo(Carbon::parse('2026-05-05 10:00:00')));
$this->assertNull(data_get($account->metadata, 'renewed_by_user'));
$this->assertDatabaseHas('hosting_billing_invoices', [
'hosting_account_id' => $account->id,
'invoice_type' => HostingBillingInvoice::TYPE_RENEWAL,
'status' => HostingBillingInvoice::STATUS_PENDING,
'total_amount' => 30,
]);
}
public function test_customer_renewal_callback_applies_extension_after_successful_payment(): void
{
Carbon::setTestNow('2026-04-05 10:00:00');
$user = User::factory()->create();
$product = $this->createHostingProduct();
$account = HostingAccount::query()->create([
'user_id' => $user->id,
'hosting_product_id' => $product->id,
'username' => 'renewcallback',
'type' => 'shared',
'status' => 'active',
'expires_at' => Carbon::parse('2026-05-05 10:00:00'),
'metadata' => [
'assigned_duration_months' => 3,
],
]);
$invoice = HostingBillingInvoice::query()->create([
'user_id' => $user->id,
'hosting_account_id' => $account->id,
'invoice_type' => HostingBillingInvoice::TYPE_RENEWAL,
'status' => HostingBillingInvoice::STATUS_PENDING,
'currency' => 'GHS',
'subtotal_amount' => 30,
'credit_amount' => 0,
'total_amount' => 30,
'provider_reference' => 'HOST-REN-CALLBACK',
'metadata' => [
'months' => 3,
'billing_cycle' => 'quarterly',
],
]);
$paystack = \Mockery::mock(PaystackService::class);
$paystack->shouldReceive('verifyTransaction')
->once()
->with('HOST-REN-CALLBACK')
->andReturn([
'reference' => 'HOST-REN-CALLBACK',
'status' => 'success',
]);
$this->app->instance(PaystackService::class, $paystack);
$response = $this->actingAs($user)->get(route('hosting.accounts.renew.callback', $account) . '?reference=HOST-REN-CALLBACK');
$response->assertRedirect(route('hosting.accounts.show', $account));
$account->refresh();
$invoice->refresh();
$this->assertTrue($account->expires_at->equalTo(Carbon::parse('2026-08-05 10:00:00')));
$this->assertSame($user->id, data_get($account->metadata, 'renewed_by_user'));
$this->assertNotNull($invoice->paid_at);
$this->assertSame(HostingBillingInvoice::STATUS_PAID, $invoice->status);
}
private function createHostingProduct(): HostingProduct
{
return HostingProduct::query()->create([
'name' => 'Admin Assigned Shared Hosting',
'slug' => 'admin-assigned-shared-hosting',
'category' => 'shared',
'type' => 'single_domain',
'price_monthly' => 10,
'price_quarterly' => 30,
'price_yearly' => 120,
'price_biennial' => 240,
'currency' => 'GHS',
'max_domains' => 1,
'is_active' => true,
'is_visible' => true,
'sort_order' => 1,
]);
}
}
@@ -0,0 +1,142 @@
<?php
namespace Tests\Feature;
use App\Models\CustomerHostingOrder;
use App\Models\HostingAccount;
use App\Models\HostingProduct;
use App\Models\User;
use App\Services\Hosting\Providers\SharedNodeProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AdminHostingAccountUnsuspendTest extends TestCase
{
use RefreshDatabase;
public function test_admin_can_unsuspend_hosting_account_from_user_page(): void
{
$admin = User::factory()->create(['is_admin' => true]);
$user = User::factory()->create();
$product = $this->createHostingProduct();
$account = HostingAccount::create([
'user_id' => $user->id,
'hosting_product_id' => $product->id,
'username' => 'unsuspendacct',
'type' => HostingAccount::TYPE_SHARED,
'status' => HostingAccount::STATUS_SUSPENDED,
'resource_status' => HostingAccount::RESOURCE_STATUS_SUSPENDED,
'suspension_reason' => 'Manual suspension.',
'suspended_at' => now()->subHour(),
'metadata' => [
'suspension_origin' => 'manual',
],
]);
$order = CustomerHostingOrder::create([
'user_id' => $user->id,
'hosting_product_id' => $product->id,
'hosting_account_id' => $account->id,
'domain_name' => 'example.test',
'billing_cycle' => CustomerHostingOrder::CYCLE_MONTHLY,
'amount_paid' => 10,
'currency' => 'GHS',
'status' => CustomerHostingOrder::STATUS_SUSPENDED,
'suspended_at' => now()->subHour(),
]);
$provider = \Mockery::mock(SharedNodeProvider::class);
$provider->shouldReceive('unsuspendAccount')->once()->withArgs(
fn (HostingAccount $candidate): bool => $candidate->id === $account->id
)->andReturn(true);
$provider->shouldReceive('applyAccountResourceProfile')->once()->withArgs(
fn (HostingAccount $candidate, bool $throttled): bool => $candidate->id === $account->id && $throttled === false
)->andReturn(true);
$this->app->instance(SharedNodeProvider::class, $provider);
$response = $this->actingAs($admin)->post(route('admin.users.hosting-accounts.unsuspend', [$user, $account]));
$response->assertRedirect();
$response->assertSessionHas('success', 'Hosting account unsuspended.');
$account->refresh();
$order->refresh();
$this->assertSame(HostingAccount::STATUS_ACTIVE, $account->status);
$this->assertSame(HostingAccount::RESOURCE_STATUS_ACTIVE, $account->resource_status);
$this->assertNull($account->suspended_at);
$this->assertNull($account->suspension_reason);
$this->assertSame(CustomerHostingOrder::STATUS_ACTIVE, $order->status);
$this->assertNull($order->suspended_at);
}
public function test_admin_order_unsuspend_works_when_account_is_suspended_but_order_status_drifted(): void
{
$admin = User::factory()->create(['is_admin' => true]);
$user = User::factory()->create();
$product = $this->createHostingProduct();
$account = HostingAccount::create([
'user_id' => $user->id,
'hosting_product_id' => $product->id,
'username' => 'driftacct',
'type' => HostingAccount::TYPE_SHARED,
'status' => HostingAccount::STATUS_SUSPENDED,
'resource_status' => HostingAccount::RESOURCE_STATUS_SUSPENDED,
'suspension_reason' => 'CPU overage.',
'suspended_at' => now()->subMinutes(30),
'metadata' => [
'suspension_origin' => 'automatic',
],
]);
$order = CustomerHostingOrder::create([
'user_id' => $user->id,
'hosting_product_id' => $product->id,
'hosting_account_id' => $account->id,
'domain_name' => 'drift.example.test',
'billing_cycle' => CustomerHostingOrder::CYCLE_MONTHLY,
'amount_paid' => 10,
'currency' => 'GHS',
'status' => CustomerHostingOrder::STATUS_ACTIVE,
]);
$provider = \Mockery::mock(SharedNodeProvider::class);
$provider->shouldReceive('unsuspendAccount')->once()->withArgs(
fn (HostingAccount $candidate): bool => $candidate->id === $account->id
)->andReturn(true);
$provider->shouldReceive('applyAccountResourceProfile')->once()->withArgs(
fn (HostingAccount $candidate, bool $throttled): bool => $candidate->id === $account->id && $throttled === false
)->andReturn(true);
$this->app->instance(SharedNodeProvider::class, $provider);
$response = $this->actingAs($admin)->post(route('admin.hosting.orders.unsuspend', $order));
$response->assertRedirect();
$response->assertSessionHas('success', 'Order unsuspended.');
$account->refresh();
$order->refresh();
$this->assertSame(HostingAccount::STATUS_ACTIVE, $account->status);
$this->assertSame(HostingAccount::RESOURCE_STATUS_ACTIVE, $account->resource_status);
$this->assertSame(CustomerHostingOrder::STATUS_ACTIVE, $order->status);
}
private function createHostingProduct(): HostingProduct
{
return HostingProduct::create([
'name' => 'Admin Unsuspend Test',
'slug' => 'admin-unsuspend-test-' . str()->random(6),
'category' => HostingProduct::CATEGORY_SHARED,
'type' => HostingProduct::TYPE_SINGLE_DOMAIN,
'price_monthly' => 10,
'currency' => 'GHS',
'disk_gb' => 10,
'max_domains' => 1,
'is_active' => true,
'is_visible' => true,
]);
}
}
@@ -0,0 +1,85 @@
<?php
namespace Tests\Feature;
use App\Models\HostingAccount;
use App\Models\HostingProduct;
use App\Models\User;
use App\Services\Hosting\Providers\SharedNodeProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class HardenHostingAccountIsolationCommandTest extends TestCase
{
use RefreshDatabase;
public function test_it_hardens_a_single_shared_account(): void
{
$user = User::factory()->create();
$product = HostingProduct::create([
'name' => 'Isolation Test Product',
'slug' => 'isolation-test-' . str()->random(6),
'category' => HostingProduct::CATEGORY_SHARED,
'type' => HostingProduct::TYPE_SINGLE_DOMAIN,
'price_monthly' => 10,
'currency' => 'GHS',
'disk_gb' => 10,
'max_domains' => 1,
'is_active' => true,
'is_visible' => true,
]);
$account = HostingAccount::create([
'user_id' => $user->id,
'hosting_product_id' => $product->id,
'username' => 'isolateacct',
'primary_domain' => 'example.test',
'type' => HostingAccount::TYPE_SHARED,
'status' => HostingAccount::STATUS_ACTIVE,
]);
$provider = \Mockery::mock(SharedNodeProvider::class);
$provider->shouldReceive('hardenAccountFilesystem')->once()->withArgs(
fn (HostingAccount $candidate): bool => $candidate->id === $account->id
);
$this->app->instance(SharedNodeProvider::class, $provider);
$this->artisan('hosting:harden-account-isolation', [
'--account' => $account->id,
])->assertExitCode(0);
}
public function test_it_supports_dry_run_without_touching_accounts(): void
{
$user = User::factory()->create();
$product = HostingProduct::create([
'name' => 'Isolation Test Product',
'slug' => 'isolation-dry-run-' . str()->random(6),
'category' => HostingProduct::CATEGORY_SHARED,
'type' => HostingProduct::TYPE_SINGLE_DOMAIN,
'price_monthly' => 10,
'currency' => 'GHS',
'disk_gb' => 10,
'max_domains' => 1,
'is_active' => true,
'is_visible' => true,
]);
HostingAccount::create([
'user_id' => $user->id,
'hosting_product_id' => $product->id,
'username' => 'dryrunacct',
'primary_domain' => 'dry-run.test',
'type' => HostingAccount::TYPE_SHARED,
'status' => HostingAccount::STATUS_ACTIVE,
]);
$provider = \Mockery::mock(SharedNodeProvider::class);
$provider->shouldNotReceive('hardenAccountFilesystem');
$this->app->instance(SharedNodeProvider::class, $provider);
$this->artisan('hosting:harden-account-isolation', [
'--dry-run' => true,
])->assertExitCode(0);
}
}
@@ -0,0 +1,107 @@
<?php
namespace Tests\Feature;
use App\Exceptions\ContaboApiException;
use App\Jobs\ProvisionHostingOrderJob;
use App\Models\CustomerHostingOrder;
use App\Models\HostingProduct;
use App\Models\User;
use App\Services\Hosting\HostingOrderFulfillmentService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Tests\TestCase;
class HostingOrderFulfillmentServiceTest extends TestCase
{
use RefreshDatabase;
public function test_paid_order_is_always_queued_for_provisioning(): void
{
Bus::fake();
$user = User::factory()->create();
$product = HostingProduct::create([
'name' => 'Cloud VPS',
'slug' => 'vps-fulfillment',
'category' => HostingProduct::CATEGORY_VPS,
'type' => HostingProduct::TYPE_VPS,
'contabo_product_id' => 'V91',
'price_monthly' => 50,
'currency' => 'GHS',
'disk_gb' => 75,
'is_active' => true,
'is_visible' => true,
]);
$order = CustomerHostingOrder::create([
'user_id' => $user->id,
'hosting_product_id' => $product->id,
'domain_name' => 'vps-server',
'billing_cycle' => CustomerHostingOrder::CYCLE_MONTHLY,
'amount_paid' => 50,
'currency' => 'GHS',
'status' => CustomerHostingOrder::STATUS_PENDING_APPROVAL,
]);
$result = app(HostingOrderFulfillmentService::class)->attemptAutoFulfillment($order);
$this->assertTrue($result['fulfilled']);
$order->refresh();
$this->assertSame(CustomerHostingOrder::STATUS_APPROVED, $order->status);
Bus::assertDispatched(ProvisionHostingOrderJob::class);
}
public function test_customer_facing_status_hides_approval_wording(): void
{
$this->assertSame('Pending', CustomerHostingOrder::customerFacingStatusLabelFor(
CustomerHostingOrder::STATUS_PENDING_APPROVAL
));
$this->assertSame('Pending', CustomerHostingOrder::customerFacingStatusLabelFor(
CustomerHostingOrder::STATUS_APPROVED
));
}
public function test_contabo_402_is_treated_as_payment_required(): void
{
$exception = ContaboApiException::fromResponse(402, json_encode([
'message' => 'Request refused as it requires additional payed service.',
], JSON_THROW_ON_ERROR));
$this->assertTrue($exception->isPaymentRequired());
}
public function test_order_returns_to_pending_approval_on_contabo_payment_failure(): void
{
$user = User::factory()->create();
$product = HostingProduct::create([
'name' => 'Cloud VPS',
'slug' => 'vps-hold',
'category' => HostingProduct::CATEGORY_VPS,
'type' => HostingProduct::TYPE_VPS,
'contabo_product_id' => 'V91',
'price_monthly' => 50,
'currency' => 'GHS',
'disk_gb' => 75,
'is_active' => true,
'is_visible' => true,
]);
$order = CustomerHostingOrder::create([
'user_id' => $user->id,
'hosting_product_id' => $product->id,
'domain_name' => 'vps-hold',
'billing_cycle' => CustomerHostingOrder::CYCLE_MONTHLY,
'amount_paid' => 50,
'currency' => 'GHS',
'status' => CustomerHostingOrder::STATUS_PROVISIONING,
]);
$order->holdForAdminProvisioning('Request refused as it requires additional payed service.', 402);
$order->refresh();
$this->assertSame(CustomerHostingOrder::STATUS_PENDING_APPROVAL, $order->status);
$this->assertSame(402, $order->meta['provisioning_hold']['http_status'] ?? null);
$this->assertSame('Pending', $order->customerFacingStatusLabel());
}
}
@@ -0,0 +1,306 @@
<?php
namespace Tests\Feature;
use App\Http\Controllers\Hosting\HostingPanelController;
use App\Models\HostedSite;
use App\Models\HostingAccount;
use App\Models\HostingNode;
use App\Models\User;
use App\Services\Hosting\Providers\SharedNodeProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;
use Tests\TestCase;
class HostingPanelAppInstallTest extends TestCase
{
use RefreshDatabase;
public function test_wordpress_install_failure_surfaces_failed_step_when_command_output_is_empty(): void
{
$user = User::factory()->create();
$node = HostingNode::create([
'name' => 'Local Node',
'hostname' => 'local-node',
'ip_address' => '127.0.0.1',
'type' => 'shared',
'provider' => 'local',
'status' => 'active',
'ssh_port' => '22',
]);
$account = HostingAccount::unguarded(function () use ($user, $node) {
return HostingAccount::create([
'user_id' => $user->id,
'hosting_node_id' => $node->id,
'username' => 'acctuser',
'primary_domain' => 'example.com',
'type' => 'shared',
'status' => 'active',
'php_version' => '8.2',
]);
});
$site = HostedSite::create([
'hosting_account_id' => $account->id,
'domain' => 'example.com',
'document_root' => '/home/acctuser/public_html',
'type' => 'primary',
'status' => 'active',
]);
$provider = \Mockery::mock(SharedNodeProvider::class);
$provider->shouldReceive('createDatabase')
->once()
->withArgs(fn ($database, string $password): bool => $database->hosting_account_id === $account->id && $database->hosted_site_id === $site->id && $database->name === $database->username && strlen($password) === 16)
->andReturn(['name' => 'acctuser_abcd12', 'username' => 'acctuser_abcd12', 'host' => 'localhost']);
$provider->shouldReceive('deleteDatabase')
->once()
->andReturn(true);
$provider->shouldReceive('prepareDirectoryForUser')
->once()
->withArgs(fn (HostingAccount $passedAccount, string $path): bool => $passedAccount->is($account) && $path === '/home/acctuser/public_html');
$provider->shouldReceive('executeCommand')
->once()
->withArgs(fn (HostingAccount $passedAccount, string $command): bool => $passedAccount->is($account) && str_starts_with($command, 'test -w '))
->andReturn(['output' => '', 'exit_code' => 0]);
$provider->shouldReceive('executeCommand')
->once()
->withArgs(fn (HostingAccount $passedAccount, string $command): bool => $passedAccount->is($account) && str_contains($command, 'curl -fLsS'))
->andReturn(['output' => '', 'exit_code' => 6]);
$this->app->instance(SharedNodeProvider::class, $provider);
$session = $this->app['session.store'];
$session->start();
$request = Request::create('http://localhost/hosting/panel/' . $account->id . '/apps/install', 'POST', [
'app' => 'wordpress',
'site_id' => $site->id,
'directory' => '',
], [], [], [
'HTTP_REFERER' => 'http://localhost/hosting/panel/' . $account->id . '/apps',
]);
$request->setUserResolver(fn (): User => $user);
$request->setLaravelSession($session);
$response = $this->app->make(HostingPanelController::class)->installApp($request, $account);
$this->assertSame(302, $response->getStatusCode());
$this->assertSame(
'Failed to install application: WordPress installation failed. Download WordPress package failed with exit code 6.',
$session->get('error')
);
$this->assertNull($site->fresh()->installed_app);
$this->assertSame(0, $account->fresh()->databases()->count());
}
public function test_wordpress_install_provisions_database_and_returns_admin_credentials(): void
{
$user = User::factory()->create(['email' => 'owner@example.com']);
$node = HostingNode::create([
'name' => 'Local Node',
'hostname' => 'local-node',
'ip_address' => '127.0.0.1',
'type' => 'shared',
'provider' => 'local',
'status' => 'active',
'ssh_port' => '22',
]);
$account = HostingAccount::unguarded(function () use ($user, $node) {
return HostingAccount::create([
'user_id' => $user->id,
'hosting_node_id' => $node->id,
'username' => 'acctuser',
'primary_domain' => 'example.com',
'type' => 'shared',
'status' => 'active',
'php_version' => '8.2',
]);
});
$site = HostedSite::create([
'hosting_account_id' => $account->id,
'domain' => 'example.com',
'document_root' => '/home/acctuser/public_html',
'type' => 'primary',
'status' => 'active',
'ssl_enabled' => false,
]);
$provider = \Mockery::mock(SharedNodeProvider::class);
$provider->shouldReceive('createDatabase')
->once()
->withArgs(fn ($database, string $password): bool => $database->hosting_account_id === $account->id && $database->hosted_site_id === $site->id && $database->name === $database->username && strlen($password) === 16)
->andReturnUsing(fn ($database) => ['name' => $database->name, 'username' => $database->username, 'host' => 'localhost']);
$provider->shouldReceive('prepareDirectoryForUser')
->once()
->withArgs(fn (HostingAccount $passedAccount, string $path): bool => $passedAccount->is($account) && $path === '/home/acctuser/public_html');
$provider->shouldReceive('executeCommand')
->times(10)
->andReturnUsing(function (HostingAccount $passedAccount, string $command) use ($account) {
$this->assertTrue($passedAccount->is($account));
return match (true) {
str_starts_with($command, 'test -w ') => ['output' => '', 'exit_code' => 0],
str_contains($command, 'https://wordpress.org/latest.tar.gz') => ['output' => '', 'exit_code' => 0],
str_contains($command, 'tar -xzf latest.tar.gz') => ['output' => '', 'exit_code' => 0],
str_contains($command, 'rm -f latest.tar.gz') => ['output' => '', 'exit_code' => 0],
str_contains($command, 'https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar') => ['output' => '', 'exit_code' => 0],
str_contains($command, 'php wp-cli.phar config create') => ['output' => 'Success: Generated wp-config.php file.', 'exit_code' => 0],
str_contains($command, 'php wp-cli.phar core install') => ['output' => 'Success: WordPress installed successfully.', 'exit_code' => 0],
str_contains($command, "php wp-cli.phar rewrite structure '/%postname%/'") => ['output' => 'Success: Rewrite structure set.', 'exit_code' => 0],
str_contains($command, 'rm -f wp-cli.phar') => ['output' => '', 'exit_code' => 0],
str_contains($command, 'wp-load.php') && str_contains($command, 'wp-config.php') => ['output' => '', 'exit_code' => 0],
default => throw new \RuntimeException("Unexpected command: {$command}"),
};
});
$provider->shouldNotReceive('deleteDatabase');
$this->app->instance(SharedNodeProvider::class, $provider);
$session = $this->app['session.store'];
$session->start();
$request = Request::create('http://localhost/hosting/panel/' . $account->id . '/apps/install', 'POST', [
'app' => 'wordpress',
'site_id' => $site->id,
'directory' => '',
], [], [], [
'HTTP_REFERER' => 'http://localhost/hosting/panel/' . $account->id . '/apps',
]);
$request->setUserResolver(fn (): User => $user);
$request->setLaravelSession($session);
$response = $this->app->make(HostingPanelController::class)->installApp($request, $account);
$this->assertSame(302, $response->getStatusCode());
$this->assertStringContainsString('Wordpress installed successfully in example.com/', $session->get('success'));
$this->assertStringContainsString('Admin URL: http://example.com/wp-admin', $session->get('success'));
$this->assertStringContainsString('Database:', $session->get('success'));
$site = $site->fresh();
$this->assertSame('wordpress', $site->installed_app);
$this->assertSame('http://example.com/wp-admin', $site->app_config['admin_url'] ?? null);
$this->assertNotNull($site->app_config['database_name'] ?? null);
$this->assertNotNull($site->app_config['database_username'] ?? null);
$this->assertSame(1, $account->fresh()->databases()->count());
}
public function test_database_panel_shows_configured_phpmyadmin_link(): void
{
Config::set('hosting.shared.phpmyadmin_url', 'https://db.example.com/?account={account}&domain={primary_domain}');
$user = User::factory()->create();
$node = HostingNode::create([
'name' => 'Local Node',
'hostname' => 'local-node',
'ip_address' => '127.0.0.1',
'type' => 'shared',
'provider' => 'local',
'status' => 'active',
'ssh_port' => '22',
]);
$account = HostingAccount::unguarded(function () use ($user, $node) {
return HostingAccount::create([
'user_id' => $user->id,
'hosting_node_id' => $node->id,
'username' => 'acctuser',
'primary_domain' => 'example.com',
'type' => 'shared',
'status' => 'active',
'php_version' => '8.2',
]);
});
$request = Request::create('http://localhost/hosting/panel/' . $account->id . '/databases', 'GET');
$request->setUserResolver(fn (): User => $user);
$response = $this->app->make(HostingPanelController::class)->databases($request, $account);
$this->assertSame('https://db.example.com/?account=acctuser&domain=example.com', $response->getData()['phpMyAdminUrl']);
}
public function test_extract_file_uses_quiet_archive_commands_for_large_zip_uploads(): void
{
$user = User::factory()->create();
$node = HostingNode::create([
'name' => 'Local Node',
'hostname' => 'local-node',
'ip_address' => '127.0.0.1',
'type' => 'shared',
'provider' => 'local',
'status' => 'active',
'ssh_port' => '22',
]);
$account = HostingAccount::unguarded(function () use ($user, $node) {
return HostingAccount::create([
'user_id' => $user->id,
'hosting_node_id' => $node->id,
'username' => 'acctuser',
'primary_domain' => 'example.com',
'type' => 'shared',
'status' => 'active',
'php_version' => '8.2',
]);
});
$archivePath = '/public_html/archive.zip';
$fullArchivePath = '/home/acctuser/public_html/archive.zip';
$archiveDir = '/home/acctuser/public_html';
$provider = \Mockery::mock(SharedNodeProvider::class);
$provider->shouldReceive('executeCommand')
->once()
->ordered()
->withArgs(fn (HostingAccount $passedAccount, string $command, int $timeout): bool => $passedAccount->is($account)
&& $command === "unzip -tqq '{$fullArchivePath}' 2>&1"
&& $timeout === 1800)
->andReturn(['output' => '', 'exit_code' => 0]);
$provider->shouldReceive('executeCommand')
->once()
->ordered()
->withArgs(fn (HostingAccount $passedAccount, string $command, int $timeout): bool => $passedAccount->is($account)
&& $command === "unzip -Z1 '{$fullArchivePath}' 2>/dev/null | wc -l"
&& $timeout === 1800)
->andReturn(['output' => "423\n", 'exit_code' => 0]);
$provider->shouldReceive('executeCommand')
->once()
->ordered()
->withArgs(fn (HostingAccount $passedAccount, string $command, int $timeout): bool => $passedAccount->is($account)
&& $command === "cd '{$archiveDir}' && unzip -oq '{$fullArchivePath}' 2>&1"
&& $timeout === 1800)
->andReturn(['output' => '', 'exit_code' => 0]);
$provider->shouldReceive('executeCommand')
->once()
->ordered()
->withArgs(fn (HostingAccount $passedAccount, string $command, int $timeout): bool => $passedAccount->is($account)
&& $command === "find '{$archiveDir}' -type f -exec chmod 644 {} + 2>/dev/null; find '{$archiveDir}' -type d -exec chmod 755 {} + 2>/dev/null"
&& $timeout === 1800)
->andReturn(['output' => '', 'exit_code' => 0]);
$this->app->instance(SharedNodeProvider::class, $provider);
$request = Request::create('http://localhost/hosting/panel/' . $account->id . '/files/extract', 'POST', [
'path' => $archivePath,
]);
$request->setUserResolver(fn (): User => $user);
$response = $this->app->make(HostingPanelController::class)->extractFile($request, $account);
$this->assertSame(200, $response->getStatusCode());
$this->assertSame([
'success' => true,
'message' => 'Archive extracted successfully',
'total_files' => 423,
'extracted_files' => 423,
], $response->getData(true));
}
}
@@ -0,0 +1,263 @@
<?php
namespace Tests\Feature;
use App\Http\Controllers\Hosting\HostingPanelController;
use App\Http\Controllers\Hosting\HostingProductController;
use App\Models\Domain;
use App\Models\HostedSite;
use App\Models\HostingAccount;
use App\Models\HostingNode;
use App\Models\HostingProduct;
use App\Models\User;
use App\Services\Domain\DomainDnsBlueprintService;
use App\Services\Domain\DomainVerificationService;
use App\Services\Hosting\Providers\SharedNodeProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Tests\TestCase;
class HostingPanelDomainLinkingTest extends TestCase
{
use RefreshDatabase;
public function test_add_domain_uses_automated_nameserver_onboarding_by_default(): void
{
$user = User::factory()->create();
$product = HostingProduct::create([
'name' => 'Multi Domain Hosting',
'slug' => 'multi-domain-hosting-test',
'category' => 'shared',
'type' => 'multi_domain',
'price_monthly' => 10,
'max_domains' => 5,
]);
$node = HostingNode::create([
'name' => 'Local Node',
'hostname' => 'local-node',
'ip_address' => '127.0.0.1',
'type' => 'shared',
'provider' => 'local',
'status' => 'active',
'ssh_port' => '22',
]);
$account = HostingAccount::unguarded(function () use ($user, $node, $product) {
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',
]);
});
$provider = \Mockery::mock(SharedNodeProvider::class);
$provider->shouldReceive('addSite')
->once()
->withArgs(fn (HostedSite $site): bool => $site->hosting_account_id === $account->id && $site->domain === 'linked-example.com')
->andReturn([
'document_root' => '/home/acctuser/public_html/linked-example.com',
'domain' => 'linked-example.com',
]);
$verification = \Mockery::mock(DomainVerificationService::class);
$verification->shouldNotReceive('prepareNameserverZone');
$verification->shouldNotReceive('prepareManualDnsPack');
$this->app->instance(SharedNodeProvider::class, $provider);
$this->app->instance(DomainVerificationService::class, $verification);
$session = $this->app['session.store'];
$session->start();
$request = Request::create('http://localhost/hosting/panel/' . $account->id . '/domains', 'POST', [
'domain' => 'linked-example.com',
], [], [], [
'HTTP_REFERER' => 'http://localhost/hosting/accounts/' . $account->id,
]);
$request->setUserResolver(fn (): User => $user);
$request->setLaravelSession($session);
$response = $this->app->make(HostingPanelController::class)->addDomain(
$request,
$account,
$this->app->make(DomainDnsBlueprintService::class),
$this->app->make(DomainVerificationService::class),
);
$this->assertSame(302, $response->getStatusCode());
$domain = Domain::query()->where('host', 'linked-example.com')->firstOrFail();
$this->assertSame($account->id, $domain->hosting_account_id);
$this->assertSame(Domain::MODE_NS_AUTO, $domain->onboarding_mode);
$this->assertSame('managed', $domain->dns_mode);
$this->assertDatabaseHas('hosted_sites', [
'hosting_account_id' => $account->id,
'domain' => 'linked-example.com',
'domain_id' => $domain->id,
]);
}
public function test_hosting_account_page_renders_linked_domains(): void
{
$user = User::factory()->create();
$product = HostingProduct::create([
'name' => 'Multi Domain Hosting',
'slug' => 'multi-domain-account-view-test',
'category' => 'shared',
'type' => 'multi_domain',
'price_monthly' => 10,
'max_domains' => 3,
]);
$node = HostingNode::create([
'name' => 'Local Node',
'hostname' => 'local-node',
'ip_address' => '127.0.0.1',
'type' => 'shared',
'provider' => 'local',
'status' => 'active',
'ssh_port' => '22',
]);
$account = HostingAccount::unguarded(function () use ($user, $node, $product) {
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',
]);
});
HostedSite::create([
'hosting_account_id' => $account->id,
'domain' => 'primary.example.com',
'document_root' => '/home/acctuser/public_html',
'type' => 'primary',
'status' => 'active',
'ssl_enabled' => true,
]);
HostedSite::create([
'hosting_account_id' => $account->id,
'domain' => 'linked-example.com',
'document_root' => '/home/acctuser/public_html/linked-example.com',
'type' => 'addon',
'status' => 'active',
'ssl_enabled' => false,
]);
$request = Request::create('http://localhost/hosting/accounts/' . $account->id, 'GET');
$request->setUserResolver(fn (): User => $user);
$view = $this->app->make(HostingProductController::class)->showAccount($request, $account);
$html = $view->render();
$this->assertStringContainsString('primary.example.com', $html);
$this->assertStringContainsString('linked-example.com', $html);
$this->assertStringContainsString('2 of 3 domain slots in use.', $html);
}
public function test_add_domain_does_not_restore_soft_deleted_site_from_another_account(): void
{
$user = User::factory()->create();
$otherUser = User::factory()->create();
$product = HostingProduct::create([
'name' => 'Multi Domain Hosting',
'slug' => 'multi-domain-restore-guard-test',
'category' => 'shared',
'type' => 'multi_domain',
'price_monthly' => 10,
'max_domains' => 5,
]);
$node = HostingNode::create([
'name' => 'Local Node',
'hostname' => 'local-node',
'ip_address' => '127.0.0.1',
'type' => 'shared',
'provider' => 'local',
'status' => 'active',
'ssh_port' => '22',
]);
$account = HostingAccount::unguarded(function () use ($user, $node, $product) {
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',
]);
});
$otherAccount = HostingAccount::unguarded(function () use ($otherUser, $node, $product) {
return HostingAccount::create([
'user_id' => $otherUser->id,
'hosting_product_id' => $product->id,
'hosting_node_id' => $node->id,
'username' => 'otheracct',
'primary_domain' => 'other.example.com',
'type' => 'shared',
'status' => 'active',
'php_version' => '8.2',
]);
});
$site = HostedSite::create([
'hosting_account_id' => $otherAccount->id,
'domain' => 'linked-example.com',
'document_root' => '/home/otheracct/public_html/linked-example.com',
'type' => 'addon',
'status' => 'active',
]);
$site->delete();
$provider = \Mockery::mock(SharedNodeProvider::class);
$provider->shouldNotReceive('addSite');
$this->app->instance(SharedNodeProvider::class, $provider);
$session = $this->app['session.store'];
$session->start();
$request = Request::create('http://localhost/hosting/panel/' . $account->id . '/domains', 'POST', [
'domain' => 'linked-example.com',
], [], [], [
'HTTP_REFERER' => 'http://localhost/hosting/accounts/' . $account->id,
]);
$request->setUserResolver(fn (): User => $user);
$request->setLaravelSession($session);
$response = $this->app->make(HostingPanelController::class)->addDomain(
$request,
$account,
$this->app->make(DomainDnsBlueprintService::class),
$this->app->make(DomainVerificationService::class),
);
$this->assertSame(302, $response->getStatusCode());
$this->assertSame('This domain is already in use on another hosting account.', $session->get('error'));
$this->assertSoftDeleted('hosted_sites', [
'id' => $site->id,
'hosting_account_id' => $otherAccount->id,
'domain' => 'linked-example.com',
]);
}
}
+150
View File
@@ -0,0 +1,150 @@
<?php
namespace Tests\Feature;
use App\Http\Controllers\Hosting\HostingPanelController;
use App\Models\HostedSite;
use App\Models\HostingAccount;
use App\Models\HostingNode;
use App\Models\User;
use App\Services\Hosting\Providers\SharedNodeProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Tests\TestCase;
class HostingPanelSslTest extends TestCase
{
use RefreshDatabase;
public function test_ssl_request_marks_site_as_enabled_when_certificate_is_issued(): void
{
$user = User::factory()->create();
$node = HostingNode::create([
'name' => 'Local Node',
'hostname' => 'local-node',
'ip_address' => '127.0.0.1',
'type' => 'shared',
'provider' => 'local',
'status' => 'active',
'ssh_port' => '22',
]);
$account = HostingAccount::unguarded(function () use ($user, $node) {
return HostingAccount::create([
'user_id' => $user->id,
'hosting_node_id' => $node->id,
'username' => 'acctuser',
'primary_domain' => 'example.com',
'type' => 'shared',
'status' => 'active',
'php_version' => '8.2',
]);
});
$site = HostedSite::create([
'hosting_account_id' => $account->id,
'domain' => 'example.com',
'document_root' => '/home/acctuser/public_html/example.com',
'type' => 'primary',
'status' => 'active',
'ssl_enabled' => false,
]);
$provider = \Mockery::mock(SharedNodeProvider::class);
$provider->shouldReceive('requestLetsEncryptCertificate')
->once()
->withArgs(fn (HostedSite $passedSite): bool => $passedSite->is($site))
->andReturn(true);
$this->app->instance(SharedNodeProvider::class, $provider);
$session = $this->app['session.store'];
$session->start();
$request = Request::create(
'http://localhost/hosting/panel/' . $account->id . '/ssl/' . $site->id,
'POST',
[],
[],
[],
['HTTP_REFERER' => 'http://localhost/hosting/panel/' . $account->id . '/ssl']
);
$request->setUserResolver(fn (): User => $user);
$request->setLaravelSession($session);
$response = $this->app->make(HostingPanelController::class)->requestSsl($request, $account, $site);
$this->assertSame(302, $response->getStatusCode());
$this->assertSame(
'SSL certificate issued for example.com successfully.',
$session->get('success')
);
$this->assertTrue((bool) $site->fresh()->ssl_enabled);
}
public function test_ssl_request_surfaces_actionable_provider_failures(): void
{
$user = User::factory()->create();
$node = HostingNode::create([
'name' => 'Local Node',
'hostname' => 'local-node',
'ip_address' => '127.0.0.1',
'type' => 'shared',
'provider' => 'local',
'status' => 'active',
'ssh_port' => '22',
]);
$account = HostingAccount::unguarded(function () use ($user, $node) {
return HostingAccount::create([
'user_id' => $user->id,
'hosting_node_id' => $node->id,
'username' => 'acctuser',
'primary_domain' => 'example.com',
'type' => 'shared',
'status' => 'active',
'php_version' => '8.2',
]);
});
$site = HostedSite::create([
'hosting_account_id' => $account->id,
'domain' => 'example.com',
'document_root' => '/home/acctuser/public_html/example.com',
'type' => 'primary',
'status' => 'active',
'ssl_enabled' => false,
]);
$provider = \Mockery::mock(SharedNodeProvider::class);
$provider->shouldReceive('requestLetsEncryptCertificate')
->once()
->withArgs(fn (HostedSite $passedSite): bool => $passedSite->is($site))
->andThrow(new \RuntimeException('Local hosting administration requires passwordless sudo or a valid root SSH key on the hosting node.'));
$this->app->instance(SharedNodeProvider::class, $provider);
$session = $this->app['session.store'];
$session->start();
$request = Request::create(
'http://localhost/hosting/panel/' . $account->id . '/ssl/' . $site->id,
'POST',
[],
[],
[],
['HTTP_REFERER' => 'http://localhost/hosting/panel/' . $account->id . '/ssl']
);
$request->setUserResolver(fn (): User => $user);
$request->setLaravelSession($session);
$response = $this->app->make(HostingPanelController::class)->requestSsl($request, $account, $site);
$this->assertSame(302, $response->getStatusCode());
$this->assertSame(
'Failed to issue SSL certificate: Local hosting administration requires passwordless sudo or a valid root SSH key on the hosting node.',
$session->get('error')
);
$this->assertFalse((bool) $site->fresh()->ssl_enabled);
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
namespace Tests\Feature;
use App\Models\HostingAccount;
use App\Models\HostingAccountMember;
use App\Models\HostingNode;
use App\Models\HostingProduct;
use App\Models\User;
use App\Services\Hosting\BrowserTerminalSessionManager;
use App\Services\Hosting\Providers\SharedNodeProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class HostingPanelTerminalTest extends TestCase
{
use RefreshDatabase;
public function test_owner_can_open_terminal_and_run_an_allowed_command(): void
{
$owner = User::factory()->create();
$account = $this->createHostingAccount($owner);
$provider = \Mockery::mock(SharedNodeProvider::class);
$provider->shouldReceive('executeTerminalCommand')
->once()
->withArgs(fn (HostingAccount $passedAccount, string $command, string $workingDirectory) => $passedAccount->is($account)
&& $command === 'php artisan about'
&& $workingDirectory === '/home/acctuser/public_html')
->andReturn([
'output' => 'Laravel Framework 12.x',
'exit_code' => 0,
]);
$this->app->instance(SharedNodeProvider::class, $provider);
$this->actingAs($owner)
->get(route('hosting.panel.terminal', $account))
->assertOk()
->assertSee('Terminal')
->assertSee($account->username.'@ladill');
$this->actingAs($owner)
->postJson(route('hosting.panel.terminal.run', $account), [
'command' => 'php artisan about',
'path' => '/public_html',
])
->assertOk()
->assertJson([
'success' => true,
'exit_code' => 0,
'output' => 'Laravel Framework 12.x',
'cwd' => '/public_html',
]);
}
public function test_terminal_rejects_disallowed_commands_before_execution(): void
{
$owner = User::factory()->create();
$account = $this->createHostingAccount($owner);
$provider = \Mockery::mock(SharedNodeProvider::class);
$provider->shouldNotReceive('executeTerminalCommand');
$this->app->instance(SharedNodeProvider::class, $provider);
$this->actingAs($owner)
->postJson(route('hosting.panel.terminal.run', $account), [
'command' => 'curl https://example.com',
'path' => '/public_html',
])
->assertStatus(422)
->assertJsonPath('error', '`curl` is not available in the panel terminal.');
}
public function test_developer_can_view_terminal(): 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.panel.terminal', $account))
->assertOk()
->assertSee('Terminal');
}
public function test_terminal_input_endpoint_accepts_plain_text_payloads(): void
{
$owner = User::factory()->create();
$account = $this->createHostingAccount($owner);
$sessionManager = \Mockery::mock(BrowserTerminalSessionManager::class);
$sessionManager->shouldReceive('appendInput')
->once()
->withArgs(fn (HostingAccount $passedAccount, int $userId, string $sessionId, string $input) => $passedAccount->is($account)
&& $userId === (int) $owner->id
&& $sessionId === 'session-123'
&& $input === "ls\r");
$this->app->instance(BrowserTerminalSessionManager::class, $sessionManager);
$this->actingAs($owner)
->call(
'POST',
route('hosting.panel.terminal.sessions.input', [$account, 'session-123']),
server: [
'CONTENT_TYPE' => 'text/plain',
'HTTP_ACCEPT' => 'application/json',
],
content: "ls\r",
)
->assertOk()
->assertJson(['ok' => true]);
}
private function createHostingAccount(User $owner): HostingAccount
{
$product = HostingProduct::create([
'name' => 'Single Domain Hosting',
'slug' => 'terminal-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-terminal-'.$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' => 'acctuser',
'primary_domain' => 'example.com',
'type' => 'shared',
'status' => 'active',
'php_version' => '8.2',
]);
});
}
}
+374
View File
@@ -0,0 +1,374 @@
<?php
namespace Tests\Feature;
use App\Models\CustomerHostingOrder;
use App\Models\Domain;
use App\Models\HostingAccount;
use App\Models\HostingBillingInvoice;
use App\Models\HostingPlanChange;
use App\Models\HostingProduct;
use App\Models\Mailbox;
use App\Models\User;
use App\Services\Hosting\Providers\SharedNodeProvider;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class HostingPhaseThreeApiTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
public function test_plans_endpoint_returns_visible_shared_hosting_products(): void
{
$user = User::factory()->create();
$visible = $this->makeProduct([
'name' => 'Starter Shared',
'slug' => 'starter-shared-phase3',
'category' => HostingProduct::CATEGORY_SHARED,
'type' => HostingProduct::TYPE_SINGLE_DOMAIN,
]);
$hidden = $this->makeProduct([
'name' => 'Hidden Shared',
'slug' => 'hidden-shared-phase3',
'category' => HostingProduct::CATEGORY_SHARED,
'type' => HostingProduct::TYPE_MULTI_DOMAIN,
'is_visible' => false,
]);
$this->actingAs($user)
->getJson('/api/plans')
->assertOk()
->assertJsonPath('data.0.id', $visible->id)
->assertJsonMissing(['id' => $hidden->id]);
}
public function test_upgrade_endpoint_applies_prorated_plan_change_and_updates_quotas(): void
{
Carbon::setTestNow(Carbon::create(2026, 4, 6, 10, 0, 0));
$user = User::factory()->create();
$currentProduct = $this->makeProduct([
'name' => 'Starter',
'slug' => 'starter-phase3-upgrade',
'price_monthly' => 10,
'disk_gb' => 10,
'max_domains' => 1,
'max_databases' => 2,
'max_email_accounts' => 5,
]);
$targetProduct = $this->makeProduct([
'name' => 'Growth',
'slug' => 'growth-phase3-upgrade',
'price_monthly' => 25,
'disk_gb' => 25,
'max_domains' => 5,
'max_databases' => 10,
'max_email_accounts' => 15,
]);
$account = $this->makeAccount($user, $currentProduct, [
'allocated_disk_gb' => 10,
'expires_at' => now()->addMonth(),
'resource_limits' => [
'disk_gb' => 10,
'max_domains' => 1,
'max_databases' => 2,
'max_email_accounts' => 5,
],
]);
CustomerHostingOrder::create([
'user_id' => $user->id,
'hosting_product_id' => $currentProduct->id,
'hosting_account_id' => $account->id,
'domain_name' => $account->primary_domain,
'billing_cycle' => CustomerHostingOrder::CYCLE_MONTHLY,
'amount_paid' => 10,
'currency' => 'GHS',
'status' => CustomerHostingOrder::STATUS_ACTIVE,
'expires_at' => now()->addMonth(),
]);
$provider = \Mockery::mock(SharedNodeProvider::class);
$provider->shouldReceive('syncAccountPackage')->once()->andReturn(true);
$this->app->instance(SharedNodeProvider::class, $provider);
$this->actingAs($user)
->postJson('/api/hosting/upgrade', [
'hosting_account_id' => $account->id,
'target_product_id' => $targetProduct->id,
])
->assertOk()
->assertJsonPath('data.quote.direction', HostingPlanChange::DIRECTION_UPGRADE)
->assertJsonPath('data.quote.charge_amount', 15)
->assertJsonPath('data.invoice.total_amount', '15.00');
$account->refresh();
$this->assertSame($targetProduct->id, $account->hosting_product_id);
$this->assertSame(25, $account->allocated_disk_gb);
$this->assertSame(5, data_get($account->resource_limits, 'max_domains'));
$this->assertSame(15, data_get($account->resource_limits, 'max_email_accounts'));
$this->assertDatabaseHas('hosting_plan_changes', [
'hosting_account_id' => $account->id,
'direction' => HostingPlanChange::DIRECTION_UPGRADE,
'charge_amount' => 15,
'credit_amount' => 0,
]);
$this->assertDatabaseHas('hosting_billing_invoices', [
'hosting_account_id' => $account->id,
'invoice_type' => HostingBillingInvoice::TYPE_PLAN_CHANGE,
'status' => HostingBillingInvoice::STATUS_PENDING,
'total_amount' => 15,
]);
}
public function test_downgrade_endpoint_applies_credit_and_reduces_quota_immediately(): void
{
Carbon::setTestNow(Carbon::create(2026, 4, 6, 10, 0, 0));
$user = User::factory()->create();
$currentProduct = $this->makeProduct([
'name' => 'Growth',
'slug' => 'growth-phase3-downgrade',
'price_monthly' => 25,
'disk_gb' => 25,
'max_domains' => 5,
'max_databases' => 10,
'max_email_accounts' => 15,
]);
$targetProduct = $this->makeProduct([
'name' => 'Starter',
'slug' => 'starter-phase3-downgrade',
'price_monthly' => 10,
'disk_gb' => 10,
'max_domains' => 1,
'max_databases' => 2,
'max_email_accounts' => 5,
]);
$account = $this->makeAccount($user, $currentProduct, [
'allocated_disk_gb' => 25,
'expires_at' => now()->addMonth(),
'resource_limits' => [
'disk_gb' => 25,
'max_domains' => 5,
'max_databases' => 10,
'max_email_accounts' => 15,
],
]);
CustomerHostingOrder::create([
'user_id' => $user->id,
'hosting_product_id' => $currentProduct->id,
'hosting_account_id' => $account->id,
'domain_name' => $account->primary_domain,
'billing_cycle' => CustomerHostingOrder::CYCLE_MONTHLY,
'amount_paid' => 25,
'currency' => 'GHS',
'status' => CustomerHostingOrder::STATUS_ACTIVE,
'expires_at' => now()->addMonth(),
]);
$provider = \Mockery::mock(SharedNodeProvider::class);
$provider->shouldReceive('syncAccountPackage')->once()->andReturn(true);
$this->app->instance(SharedNodeProvider::class, $provider);
$this->actingAs($user)
->postJson('/api/hosting/downgrade', [
'hosting_account_id' => $account->id,
'target_product_id' => $targetProduct->id,
])
->assertOk()
->assertJsonPath('data.quote.direction', HostingPlanChange::DIRECTION_DOWNGRADE)
->assertJsonPath('data.quote.credit_amount', 15)
->assertJsonPath('data.invoice.status', HostingBillingInvoice::STATUS_CREDITED);
$account->refresh();
$this->assertSame($targetProduct->id, $account->hosting_product_id);
$this->assertSame(10, $account->allocated_disk_gb);
$this->assertDatabaseHas('hosting_billing_invoices', [
'hosting_account_id' => $account->id,
'invoice_type' => HostingBillingInvoice::TYPE_PLAN_CHANGE,
'status' => HostingBillingInvoice::STATUS_CREDITED,
'credit_amount' => 15,
'total_amount' => 0,
]);
}
public function test_paid_hosting_mailbox_creation_creates_addon_invoice(): void
{
$user = User::factory()->create();
$product = $this->makeProduct([
'name' => 'Starter Mail',
'slug' => 'starter-mail-phase3',
'max_email_accounts' => 1,
]);
$account = $this->makeAccount($user, $product);
$domain = $this->makeDomain($user, $account, 'phase3-mail.test');
Mailbox::create([
'user_id' => $user->id,
'hosting_account_id' => $account->id,
'domain_id' => $domain->id,
'display_name' => 'Free Mailbox',
'local_part' => 'info',
'address' => 'info@phase3-mail.test',
'password_hash' => Hash::make('password123'),
'quota_mb' => 2048,
'status' => 'active',
'maildir_path' => 'hosting/1/phase3-mail.test/info',
'credentials_issued_at' => now(),
'incoming_token' => 'token-free-1',
'inbound_enabled' => true,
'outbound_enabled' => true,
'outbound_provider' => 'smtp',
'is_paid' => false,
]);
$this->actingAs($user)
->postJson('/api/email/create', [
'hosting_account_id' => $account->id,
'domain_id' => $domain->id,
'local_part' => 'sales',
'display_name' => 'Sales',
'password' => 'password123',
])
->assertCreated()
->assertJsonPath('data.mailbox.is_paid', true)
->assertJsonPath('data.usage.extra_count', 1)
->assertJsonPath('data.invoice.total_amount', '5.00');
$this->assertDatabaseHas('mailboxes', [
'hosting_account_id' => $account->id,
'address' => 'sales@phase3-mail.test',
'is_paid' => true,
]);
$this->assertDatabaseHas('hosting_billing_invoices', [
'hosting_account_id' => $account->id,
'invoice_type' => HostingBillingInvoice::TYPE_EMAIL_ADDON,
'status' => HostingBillingInvoice::STATUS_PENDING,
'total_amount' => 5,
]);
}
public function test_mailbox_delete_requires_account_ownership(): void
{
$owner = User::factory()->create();
$otherUser = User::factory()->create();
$product = $this->makeProduct([
'name' => 'Starter Delete',
'slug' => 'starter-delete-phase3',
'max_email_accounts' => 1,
]);
$account = $this->makeAccount($owner, $product);
$domain = $this->makeDomain($owner, $account, 'delete-mail.test');
$mailbox = Mailbox::create([
'user_id' => $owner->id,
'hosting_account_id' => $account->id,
'domain_id' => $domain->id,
'display_name' => 'Owner Mailbox',
'local_part' => 'admin',
'address' => 'admin@delete-mail.test',
'password_hash' => Hash::make('password123'),
'quota_mb' => 2048,
'status' => 'active',
'maildir_path' => 'hosting/1/delete-mail.test/admin',
'credentials_issued_at' => now(),
'incoming_token' => 'token-delete-1',
'inbound_enabled' => true,
'outbound_enabled' => true,
'outbound_provider' => 'smtp',
'is_paid' => false,
]);
$this->actingAs($otherUser)
->deleteJson('/api/email/delete', [
'mailbox_id' => $mailbox->id,
])
->assertForbidden();
}
private function makeProduct(array $overrides = []): HostingProduct
{
return HostingProduct::create(array_merge([
'name' => 'Shared Phase 3',
'slug' => 'shared-phase-3-' . str()->random(8),
'category' => HostingProduct::CATEGORY_SHARED,
'type' => HostingProduct::TYPE_SINGLE_DOMAIN,
'price_monthly' => 10,
'price_quarterly' => 30,
'price_yearly' => 120,
'price_biennial' => 240,
'currency' => 'GHS',
'disk_gb' => 10,
'max_domains' => 1,
'max_databases' => 2,
'max_email_accounts' => 5,
'is_active' => true,
'is_visible' => true,
], $overrides));
}
private function makeAccount(User $user, HostingProduct $product, array $overrides = []): HostingAccount
{
return HostingAccount::unguarded(function () use ($user, $product, $overrides) {
return HostingAccount::create(array_merge([
'user_id' => $user->id,
'hosting_product_id' => $product->id,
'username' => 'acct' . random_int(1000, 9999),
'primary_domain' => 'phase3.example.test',
'type' => HostingAccount::TYPE_SHARED,
'status' => HostingAccount::STATUS_ACTIVE,
'allocated_disk_gb' => (int) ($product->disk_gb ?? 0),
'php_version' => '8.4',
'cpu_limit_percent' => 50,
'memory_limit_mb' => 512,
'process_limit' => 10,
'io_limit_mb' => 5,
'inode_limit' => 50000,
'resource_status' => HostingAccount::RESOURCE_STATUS_ACTIVE,
'resource_limits' => [
'disk_gb' => (int) ($product->disk_gb ?? 0),
'max_domains' => $product->max_domains,
'max_databases' => $product->max_databases,
'max_email_accounts' => $product->max_email_accounts,
],
], $overrides));
});
}
private function makeDomain(User $user, HostingAccount $account, string $host): Domain
{
return Domain::create([
'user_id' => $user->id,
'website_id' => null,
'hosting_account_id' => $account->id,
'host' => $host,
'type' => 'custom',
'source' => 'hosting',
'status' => 'verified',
'onboarding_mode' => Domain::MODE_NS_AUTO,
'onboarding_state' => Domain::STATE_ACTIVE,
'dns_mode' => 'managed',
'verified_at' => now(),
'active_at' => now(),
]);
}
}
+779
View File
@@ -0,0 +1,779 @@
<?php
namespace Tests\Feature;
use App\Http\Controllers\Hosting\HostingProductController;
use App\Jobs\ProvisionHostingOrderJob;
use App\Models\CustomerHostingOrder;
use App\Models\HostingProduct;
use App\Models\ProvisioningQueueItem;
use App\Models\User;
use App\Services\ExchangeRateService;
use App\Services\Hosting\HostingResourcePolicyService;
use App\Services\Hosting\NodeCapacityService;
use App\Services\Hosting\Providers\ContaboProvider;
use App\Services\Hosting\Providers\SharedNodeProvider;
use App\Services\Hosting\ServerAgentBootstrapService;
use App\Services\Hosting\ServerAgentInstallerService;
use App\Services\Hosting\ServerOrderService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Tests\TestCase;
class HostingPricingDisplayTest extends TestCase
{
use RefreshDatabase;
private function mockContaboImages(): void
{
$provider = \Mockery::mock(ContaboProvider::class);
$provider->shouldReceive('getAvailableImages')->andReturn([
[
'id' => 'ubuntu-24-image',
'name' => 'Ubuntu 24.04',
'description' => 'Ubuntu',
'os_type' => 'linux',
'standard_image' => true,
],
[
'id' => 'ubuntu-image',
'name' => 'Ubuntu 22.04',
'description' => 'Ubuntu',
'os_type' => 'linux',
'standard_image' => true,
],
[
'id' => 'win-image',
'name' => 'Windows Server 2022',
'description' => 'Windows',
'os_type' => 'windows',
'standard_image' => true,
],
]);
$provider->shouldReceive('getAvailableProducts')->andReturn([
['id' => 'V91', 'name' => 'Cloud VPS 10 NVMe', 'cpu' => 3, 'ram_gb' => 8, 'disk_gb' => 75, 'price_monthly' => 4.99],
['id' => 'V45', 'name' => 'VPS S SSD', 'cpu' => 4, 'ram_gb' => 8, 'disk_gb' => 200, 'price_monthly' => 6.99],
]);
$this->app->instance(ContaboProvider::class, $provider);
}
public function test_vps_page_renders_dynamic_ghs_price_instead_of_stored_zero_value(): void
{
$user = User::factory()->create();
$this->mockContaboImages();
$this->mock(ExchangeRateService::class, function ($mock): void {
$mock->shouldReceive('getUsdToGhs')->andReturn(15.50);
});
HostingProduct::create([
'name' => 'VPS S',
'slug' => 'vps-s-test',
'description' => 'Dynamic VPS pricing test',
'category' => 'vps',
'type' => 'vps',
'price_monthly' => 0,
'price_quarterly' => 0,
'price_yearly' => 0,
'currency' => 'USD',
'cpu_cores' => 4,
'ram_mb' => 8192,
'disk_gb' => 200,
'contabo_product_id' => 'V45',
'is_active' => true,
'is_visible' => true,
]);
$request = Request::create('http://localhost/hosting/vps', 'GET');
$request->setUserResolver(fn (): User => $user);
$view = $this->app->make(HostingProductController::class)->vps($request);
$html = $view->render();
$this->assertStringContainsString('GHS 140.00', $html);
$this->assertStringNotContainsString('$0.00', $html);
}
public function test_vps_page_lists_install_later_and_multiple_live_images(): void
{
$user = User::factory()->create();
$this->mockContaboImages();
$this->mock(ExchangeRateService::class, function ($mock): void {
$mock->shouldReceive('getUsdToGhs')->andReturn(15.50);
});
HostingProduct::create([
'name' => 'VPS S',
'slug' => 'vps-s-install-later-test',
'description' => 'Dynamic VPS pricing test',
'category' => 'vps',
'type' => 'vps',
'price_monthly' => 0,
'price_quarterly' => 0,
'price_yearly' => 0,
'currency' => 'USD',
'cpu_cores' => 4,
'ram_mb' => 8192,
'disk_gb' => 200,
'contabo_product_id' => 'V45',
'is_active' => true,
'is_visible' => true,
]);
$request = Request::create('http://localhost/hosting/vps', 'GET');
$request->setUserResolver(fn (): User => $user);
$view = $this->app->make(HostingProductController::class)->vps($request);
$html = $view->render();
$this->assertStringContainsString('Install Later', $html);
$this->assertStringContainsString('Ubuntu 24.04', $html);
$this->assertStringContainsString('Ubuntu 22.04', $html);
}
public function test_server_orders_use_display_currency_for_dynamic_products(): void
{
$user = User::factory()->create();
$this->mockContaboImages();
$this->mock(ExchangeRateService::class, function ($mock): void {
$mock->shouldReceive('getUsdToGhs')->andReturn(15.50);
});
$product = HostingProduct::create([
'name' => 'VPS S',
'slug' => 'vps-s-order-test',
'description' => 'Dynamic VPS order test',
'category' => 'vps',
'type' => 'vps',
'price_monthly' => 0,
'price_quarterly' => 0,
'price_yearly' => 0,
'currency' => 'USD',
'cpu_cores' => 4,
'ram_mb' => 8192,
'disk_gb' => 200,
'contabo_product_id' => 'V45',
'is_active' => true,
'is_visible' => true,
]);
$response = $this->actingAs($user)->postJson(route('hosting.servers.store'), [
'product_id' => $product->id,
'server_name' => 'pricing-test-server',
'billing_cycle' => 'monthly',
'region' => 'EU',
'image' => 'ubuntu-image',
'license' => 'none',
'application' => 'none',
'default_user' => 'root',
'additional_ip' => 'none',
'private_networking' => 'disabled',
'storage_type' => 'included',
'object_storage' => 'none',
'server_password' => 'Secure123!!',
'server_password_confirmation' => 'Secure123!!',
]);
$response->assertOk();
$order = CustomerHostingOrder::query()->firstOrFail();
$this->assertSame('GHS', $order->currency);
$this->assertSame('140.00', number_format((float) $order->amount_paid, 2, '.', ''));
}
public function test_server_orders_persist_selected_options_password_and_semiannual_term_pricing(): void
{
$user = User::factory()->create();
$this->mockContaboImages();
$this->mock(ExchangeRateService::class, function ($mock): void {
$mock->shouldReceive('getUsdToGhs')->andReturn(15.50);
});
$product = HostingProduct::create([
'name' => 'VPS S',
'slug' => 'vps-s-semiannual-test',
'description' => 'Dynamic VPS order test',
'category' => 'vps',
'type' => 'vps',
'price_monthly' => 0,
'price_quarterly' => 0,
'price_yearly' => 0,
'currency' => 'USD',
'cpu_cores' => 4,
'ram_mb' => 8192,
'disk_gb' => 200,
'contabo_product_id' => 'V45',
'is_active' => true,
'is_visible' => true,
]);
$response = $this->actingAs($user)->postJson(route('hosting.servers.store'), [
'product_id' => $product->id,
'server_name' => 'semiannual-configured-server',
'billing_cycle' => 'semiannual',
'region' => 'EU',
'image' => 'win-image',
'license' => 'plesk_windows',
'application' => 'none',
'default_user' => 'administrator',
'additional_ip' => 'none',
'private_networking' => 'disabled',
'storage_type' => 'included',
'object_storage' => 'eu_250',
'server_password' => 'Abc123!!',
'server_password_confirmation' => 'Abc123!!',
]);
$response->assertOk();
$order = CustomerHostingOrder::query()->firstOrFail();
$this->assertSame('semiannual', $order->billing_cycle);
$this->assertSame('4985.36', number_format((float) $order->amount_paid, 2, '.', ''));
$this->assertSame('win-image', data_get($order->meta, 'server_order.selection.image'));
$this->assertSame('administrator', data_get($order->meta, 'server_order.selection.default_user'));
$this->assertSame(
['250 GB Object Storage (EU)'],
data_get($order->meta, 'server_order.quote.manual_follow_up')
);
$this->assertSame('Abc123!!', decrypt((string) data_get($order->meta, 'server_order.server_password_encrypted')));
$this->assertSame('Windows Server 2022', data_get($order->meta, 'server_order.selection_summary.0.value'));
}
public function test_server_orders_allow_install_later_with_remote_login_only(): void
{
$user = User::factory()->create();
$this->mockContaboImages();
$this->mock(ExchangeRateService::class, function ($mock): void {
$mock->shouldReceive('getUsdToGhs')->andReturn(15.50);
});
$product = HostingProduct::create([
'name' => 'VPS S',
'slug' => 'vps-s-install-later-order-test',
'description' => 'Dynamic VPS order test',
'category' => 'vps',
'type' => 'vps',
'price_monthly' => 0,
'price_quarterly' => 0,
'price_yearly' => 0,
'currency' => 'USD',
'cpu_cores' => 4,
'ram_mb' => 8192,
'disk_gb' => 200,
'contabo_product_id' => 'V45',
'is_active' => true,
'is_visible' => true,
]);
$response = $this->actingAs($user)->postJson(route('hosting.servers.store'), [
'product_id' => $product->id,
'server_name' => 'install-later-server',
'billing_cycle' => 'monthly',
'region' => 'EU',
'image' => '__install_later__',
'license' => 'none',
'application' => 'none',
'default_user' => 'root',
'additional_ip' => 'none',
'private_networking' => 'disabled',
'storage_type' => 'included',
'object_storage' => 'none',
'server_password' => 'Secure123!!',
'server_password_confirmation' => 'Secure123!!',
]);
$response->assertOk();
$order = CustomerHostingOrder::query()->firstOrFail();
$this->assertSame('__install_later__', data_get($order->meta, 'server_order.selection.image'));
$this->assertSame('none', data_get($order->meta, 'server_order.selection.license'));
$this->assertSame('none', data_get($order->meta, 'server_order.selection.application'));
$this->assertSame('140.00', number_format((float) $order->amount_paid, 2, '.', ''));
}
public function test_vps_10_monthly_orders_include_one_time_setup_fee_but_yearly_orders_do_not(): void
{
$user = User::factory()->create();
$this->mockContaboImages();
$this->mock(ExchangeRateService::class, function ($mock): void {
$mock->shouldReceive('getUsdToGhs')->andReturn(15.50);
});
$product = HostingProduct::create([
'name' => 'VPS 10',
'slug' => 'vps-10-setup-fee-test',
'description' => 'Dynamic VPS setup fee test',
'category' => 'vps',
'type' => 'vps',
'price_monthly' => 0,
'price_quarterly' => 0,
'price_yearly' => 0,
'currency' => 'GHS',
'cpu_cores' => 3,
'ram_mb' => 8192,
'disk_gb' => 75,
'contabo_product_id' => 'V91',
'is_active' => true,
'is_visible' => true,
]);
$this->actingAs($user)->postJson(route('hosting.servers.store'), [
'product_id' => $product->id,
'server_name' => 'monthly-setup-fee-server',
'billing_cycle' => 'monthly',
'region' => 'EU',
'image' => '__install_later__',
'license' => 'none',
'application' => 'none',
'default_user' => 'root',
'additional_ip' => 'none',
'private_networking' => 'disabled',
'storage_type' => 'included',
'object_storage' => 'none',
'server_password' => 'Secure123!!',
'server_password_confirmation' => 'Secure123!!',
])->assertOk();
$monthlyOrder = CustomerHostingOrder::query()->latest('id')->firstOrFail();
$this->assertSame('200.00', number_format((float) $monthlyOrder->amount_paid, 2, '.', ''));
$this->assertSame('100.00', number_format((float) collect(data_get($monthlyOrder->meta, 'server_order.quote.line_items', []))->firstWhere('code', 'setup_fee')['amount'], 2, '.', ''));
$this->actingAs($user)->postJson(route('hosting.servers.store'), [
'product_id' => $product->id,
'server_name' => 'yearly-no-setup-fee-server',
'billing_cycle' => 'yearly',
'region' => 'EU',
'image' => '__install_later__',
'license' => 'none',
'application' => 'none',
'default_user' => 'root',
'additional_ip' => 'none',
'private_networking' => 'disabled',
'storage_type' => 'included',
'object_storage' => 'none',
'server_password' => 'Secure123!!',
'server_password_confirmation' => 'Secure123!!',
])->assertOk();
$yearlyOrder = CustomerHostingOrder::query()->latest('id')->firstOrFail();
$this->assertSame('960.00', number_format((float) $yearlyOrder->amount_paid, 2, '.', ''));
$this->assertNull(collect(data_get($yearlyOrder->meta, 'server_order.quote.line_items', []))->firstWhere('code', 'setup_fee'));
}
public function test_provisioning_job_uses_selected_server_options_for_contabo(): void
{
$user = User::factory()->create();
$this->mock(ExchangeRateService::class, function ($mock): void {
$mock->shouldReceive('getUsdToGhs')->andReturn(15.50);
});
$provider = \Mockery::mock(ContaboProvider::class);
$provider->shouldReceive('getAvailableImages')->once()->andReturn([
[
'id' => 'win-image',
'name' => 'Windows Server 2022',
'description' => 'Windows',
'os_type' => 'windows',
'standard_image' => true,
],
]);
$provider->shouldReceive('createInstance')
->once()
->withArgs(function (array $config): bool {
$this->assertSame('V45', $config['product_id']);
$this->assertSame('win-image', $config['image_id']);
$this->assertSame('US-west', $config['region']);
$this->assertSame('administrator', $config['default_user']);
$this->assertSame('PleskHost', $config['license']);
$this->assertSame(6, $config['period']);
$this->assertSame('Abc123!!', $config['root_password']);
$this->assertArrayHasKey('additionalIps', $config['add_ons']);
$this->assertArrayHasKey('privateNetworking', $config['add_ons']);
return true;
})
->andReturn([
'instance_id' => 'instance-123',
'ip_address' => '203.0.113.10',
]);
$this->app->instance(ContaboProvider::class, $provider);
$product = HostingProduct::create([
'name' => 'VPS S',
'slug' => 'vps-s-provisioning-test',
'description' => 'Dynamic VPS provisioning test',
'category' => 'vps',
'type' => 'vps',
'price_monthly' => 0,
'price_quarterly' => 0,
'price_yearly' => 0,
'currency' => 'USD',
'cpu_cores' => 4,
'ram_mb' => 8192,
'disk_gb' => 200,
'contabo_product_id' => 'V45',
'contabo_region' => 'EU',
'is_active' => true,
'is_visible' => true,
]);
$order = CustomerHostingOrder::create([
'user_id' => $user->id,
'hosting_product_id' => $product->id,
'domain_name' => 'job-provisioned-server',
'billing_cycle' => 'semiannual',
'amount_paid' => 0,
'currency' => 'GHS',
'status' => CustomerHostingOrder::STATUS_APPROVED,
'meta' => [
'region' => 'US-west',
'server_order' => [
'selection' => [
'image' => 'win-image',
'region' => 'US-west',
'license' => 'plesk_host',
'application' => 'none',
'default_user' => 'administrator',
'additional_ip' => 'one_extra',
'private_networking' => 'enabled',
'storage_type' => 'included',
'object_storage' => 'none',
],
'server_password_encrypted' => encrypt('Abc123!!'),
],
],
]);
$capacityService = \Mockery::mock(NodeCapacityService::class);
$sharedProvider = \Mockery::mock(SharedNodeProvider::class);
$serverOrders = $this->app->make(ServerOrderService::class);
$bootstrap = \Mockery::mock(ServerAgentBootstrapService::class);
$bootstrap->shouldReceive('ensureBootstrap')->andReturn(['token' => 'bootstrap-token']);
$installer = \Mockery::mock(ServerAgentInstallerService::class);
$installer->shouldReceive('augmentProvisioningConfig')->andReturnUsing(
fn ($freshOrder, array $config, array $bootstrapConfig): array => $config
);
(new ProvisionHostingOrderJob($order))->handle(
$capacityService,
app(HostingResourcePolicyService::class),
$sharedProvider,
$provider,
$serverOrders,
$bootstrap,
$installer,
);
$order->refresh();
$this->assertSame(CustomerHostingOrder::STATUS_ACTIVE, $order->status);
$this->assertSame('203.0.113.10', $order->server_ip);
$this->assertSame('administrator', $order->server_username);
$this->assertSame('instance-123', data_get($order->meta, 'contabo_instance_id'));
$this->assertDatabaseHas('provisioning_queue', [
'customer_hosting_order_id' => $order->id,
'status' => ProvisioningQueueItem::STATUS_COMPLETED,
]);
}
public function test_ladill_control_panel_selection_sets_internal_panel_url_after_provisioning(): void
{
$user = User::factory()->create();
$this->mock(ExchangeRateService::class, function ($mock): void {
$mock->shouldReceive('getUsdToGhs')->andReturn(15.50);
});
$provider = \Mockery::mock(ContaboProvider::class);
$provider->shouldReceive('getAvailableImages')->once()->andReturn([
[
'id' => 'ubuntu-image',
'name' => 'Ubuntu 22.04',
'description' => 'Ubuntu',
'os_type' => 'linux',
'standard_image' => true,
],
]);
$provider->shouldReceive('createInstance')
->once()
->withArgs(function (array $config): bool {
$this->assertNull($config['license']);
$this->assertSame('ladill', $config['panel']);
return true;
})
->andReturn([
'instance_id' => 'instance-panel-123',
'ip_address' => '203.0.113.20',
'status' => 'running',
'region' => 'EU',
'datacenter' => 'MUC1',
'image' => 'ubuntu-image',
]);
$this->app->instance(ContaboProvider::class, $provider);
$product = HostingProduct::create([
'name' => 'VPS S',
'slug' => 'vps-s-ladill-panel-test',
'description' => 'Dynamic VPS provisioning test',
'category' => 'vps',
'type' => 'vps',
'price_monthly' => 0,
'price_quarterly' => 0,
'price_yearly' => 0,
'currency' => 'USD',
'cpu_cores' => 4,
'ram_mb' => 8192,
'disk_gb' => 200,
'contabo_product_id' => 'V45',
'contabo_region' => 'EU',
'is_active' => true,
'is_visible' => true,
]);
$order = CustomerHostingOrder::create([
'user_id' => $user->id,
'hosting_product_id' => $product->id,
'domain_name' => 'ladill-panel-server',
'billing_cycle' => 'monthly',
'amount_paid' => 0,
'currency' => 'GHS',
'status' => CustomerHostingOrder::STATUS_APPROVED,
'meta' => [
'region' => 'EU',
'server_order' => [
'selection' => [
'image' => 'ubuntu-image',
'region' => 'EU',
'license' => 'ladill_panel',
'application' => 'none',
'default_user' => 'root',
'additional_ip' => 'none',
'private_networking' => 'disabled',
'storage_type' => 'included',
'object_storage' => 'none',
],
'selection_summary' => [
['label' => 'License', 'value' => 'Ladill Control Panel'],
],
'server_password_encrypted' => encrypt('Root123!!'),
],
],
]);
$capacityService = \Mockery::mock(NodeCapacityService::class);
$sharedProvider = \Mockery::mock(SharedNodeProvider::class);
$serverOrders = $this->app->make(ServerOrderService::class);
$bootstrap = \Mockery::mock(ServerAgentBootstrapService::class);
$bootstrap->shouldReceive('ensureBootstrap')->andReturn(['token' => 'bootstrap-token']);
$installer = \Mockery::mock(ServerAgentInstallerService::class);
$installer->shouldReceive('augmentProvisioningConfig')->andReturnUsing(
fn ($freshOrder, array $config, array $bootstrapConfig): array => $config
);
(new ProvisionHostingOrderJob($order))->handle(
$capacityService,
app(HostingResourcePolicyService::class),
$sharedProvider,
$provider,
$serverOrders,
$bootstrap,
$installer,
);
$order->refresh();
$this->assertSame(route('hosting.server-panel.show', $order), $order->control_panel_url);
$this->assertSame('ladill', data_get($order->meta, 'server_panel.panel'));
$this->assertSame('instance-panel-123', data_get($order->meta, 'contabo_instance_id'));
}
public function test_ladill_server_panel_page_and_power_action_work_for_selected_orders(): void
{
$user = User::factory()->create();
$product = HostingProduct::create([
'name' => 'VPS S',
'slug' => 'vps-s-panel-route-test',
'description' => 'Dynamic VPS provisioning test',
'category' => 'vps',
'type' => 'vps',
'price_monthly' => 0,
'price_quarterly' => 0,
'price_yearly' => 0,
'currency' => 'USD',
'cpu_cores' => 4,
'ram_mb' => 8192,
'disk_gb' => 200,
'contabo_product_id' => 'V45',
'is_active' => true,
'is_visible' => true,
]);
$order = CustomerHostingOrder::create([
'user_id' => $user->id,
'hosting_product_id' => $product->id,
'domain_name' => 'panel-route-server',
'billing_cycle' => 'monthly',
'amount_paid' => 0,
'currency' => 'GHS',
'status' => CustomerHostingOrder::STATUS_ACTIVE,
'server_ip' => '203.0.113.50',
'server_username' => 'root',
'control_panel_url' => 'http://localhost/hosting/orders/1/panel',
'meta' => [
'contabo_instance_id' => 'instance-route-123',
'server_order' => [
'selection' => [
'license' => 'ladill_panel',
'application' => 'none',
],
'selection_summary' => [
['label' => 'License', 'value' => 'Ladill Control Panel'],
],
],
'server_panel' => [
'panel' => 'ladill',
'power_status' => 'on',
'instance_status' => 'running',
],
],
]);
$provider = \Mockery::mock(ContaboProvider::class);
$provider->shouldReceive('startInstance')->once()->with('instance-route-123')->andReturn(true);
$this->app->instance(ContaboProvider::class, $provider);
$this->actingAs($user)
->get(route('hosting.server-panel.show', $order))
->assertOk()
->assertSee('Ladill Control Panel')
->assertSee('panel-route-server');
$this->actingAs($user)
->post(route('hosting.server-panel.power', [$order, 'start']))
->assertRedirect();
}
public function test_provisioning_uses_cloud_init_for_webmin_profile(): void
{
$user = User::factory()->create();
$this->mock(ExchangeRateService::class, function ($mock): void {
$mock->shouldReceive('getUsdToGhs')->andReturn(15.50);
});
$provider = \Mockery::mock(ContaboProvider::class);
$provider->shouldReceive('getAvailableImages')->once()->andReturn([
[
'id' => 'ubuntu-image',
'name' => 'Ubuntu 22.04',
'description' => 'Ubuntu',
'os_type' => 'linux',
'standard_image' => true,
],
]);
$provider->shouldReceive('generateCloudInit')->once()->andReturn('#cloud-config webmin');
$provider->shouldReceive('createInstance')
->once()
->withArgs(function (array $config): bool {
$this->assertSame('ubuntu-image', $config['image_id']);
$this->assertSame('#cloud-config webmin', $config['user_data']);
$this->assertNull($config['license']);
$this->assertNull($config['application_id']);
return true;
})
->andReturn([
'instance_id' => 'instance-webmin-123',
'ip_address' => '203.0.113.30',
]);
$this->app->instance(ContaboProvider::class, $provider);
$product = HostingProduct::create([
'name' => 'VPS S',
'slug' => 'vps-s-webmin-test',
'description' => 'Dynamic VPS provisioning test',
'category' => 'vps',
'type' => 'vps',
'price_monthly' => 0,
'price_quarterly' => 0,
'price_yearly' => 0,
'currency' => 'USD',
'cpu_cores' => 4,
'ram_mb' => 8192,
'disk_gb' => 200,
'contabo_product_id' => 'V45',
'contabo_region' => 'EU',
'is_active' => true,
'is_visible' => true,
]);
$order = CustomerHostingOrder::create([
'user_id' => $user->id,
'hosting_product_id' => $product->id,
'domain_name' => 'webmin-server',
'billing_cycle' => 'monthly',
'amount_paid' => 0,
'currency' => 'GHS',
'status' => CustomerHostingOrder::STATUS_APPROVED,
'meta' => [
'region' => 'EU',
'server_order' => [
'selection' => [
'image' => 'ubuntu-image',
'region' => 'EU',
'license' => 'none',
'application' => 'webmin',
'default_user' => 'root',
'additional_ip' => 'none',
'private_networking' => 'disabled',
'storage_type' => 'included',
'object_storage' => 'none',
],
'server_password_encrypted' => encrypt('Root123!!'),
],
],
]);
$capacityService = \Mockery::mock(NodeCapacityService::class);
$sharedProvider = \Mockery::mock(SharedNodeProvider::class);
$serverOrders = $this->app->make(ServerOrderService::class);
$bootstrap = \Mockery::mock(ServerAgentBootstrapService::class);
$bootstrap->shouldReceive('ensureBootstrap')->andReturn(['token' => 'bootstrap-token']);
$installer = \Mockery::mock(ServerAgentInstallerService::class);
$installer->shouldReceive('augmentProvisioningConfig')->andReturnUsing(
fn ($freshOrder, array $config, array $bootstrapConfig): array => $config
);
(new ProvisionHostingOrderJob($order))->handle(
$capacityService,
app(HostingResourcePolicyService::class),
$sharedProvider,
$provider,
$serverOrders,
$bootstrap,
$installer,
);
$this->assertDatabaseHas('provisioning_queue', [
'customer_hosting_order_id' => $order->id,
'status' => ProvisioningQueueItem::STATUS_COMPLETED,
]);
}
}
@@ -0,0 +1,343 @@
<?php
namespace Tests\Feature;
use App\Models\CustomerHostingOrder;
use App\Models\HostingAccount;
use App\Models\HostingAccountAlert;
use App\Models\HostingProduct;
use App\Models\User;
use App\Notifications\HostingResourceWarningNotification;
use App\Services\Hosting\HostingResourcePolicyService;
use App\Services\Hosting\Providers\SharedNodeProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
class HostingResourcePolicyServiceTest extends TestCase
{
use RefreshDatabase;
public function test_it_warns_when_disk_usage_is_near_the_account_limit(): void
{
Notification::fake();
$account = $this->makeAccount([
'allocated_disk_gb' => 10,
'disk_used_bytes' => (int) (9.1 * 1073741824),
'inode_limit' => 50000,
'inode_count' => 1000,
'cpu_limit_percent' => 50,
'cpu_usage_percent' => 10,
'memory_limit_mb' => 512,
'memory_used_mb' => 128,
'process_limit' => 10,
'process_count' => 2,
]);
$provider = \Mockery::mock(SharedNodeProvider::class);
$provider->shouldReceive('applyAccountResourceProfile')->never();
$provider->shouldReceive('suspendAccount')->never();
$this->app->instance(SharedNodeProvider::class, $provider);
app(HostingResourcePolicyService::class)->process($account);
$account->refresh();
$this->assertTrue($account->is_flagged);
$this->assertSame(HostingAccount::RESOURCE_STATUS_ACTIVE, $account->resource_status);
$this->assertDatabaseHas('hosting_account_alerts', [
'hosting_account_id' => $account->id,
'alert_type' => HostingAccountAlert::TYPE_WARNING,
'is_resolved' => false,
]);
Notification::assertSentTo($account->user, HostingResourceWarningNotification::class);
}
public function test_it_throttles_an_account_when_cpu_limit_is_exceeded(): void
{
Notification::fake();
$account = $this->makeAccount([
'cpu_limit_percent' => 50,
'cpu_usage_percent' => 90,
'memory_limit_mb' => 512,
'memory_used_mb' => 128,
'process_limit' => 10,
'process_count' => 3,
'inode_limit' => 50000,
'inode_count' => 1000,
'throttled_at' => now()->subHours(25),
'cpu_breach_streak' => 11,
]);
$provider = \Mockery::mock(SharedNodeProvider::class);
$provider->shouldReceive('applyAccountResourceProfile')->once()->withArgs(
fn (HostingAccount $candidate, bool $throttled): bool => $candidate->id === $account->id && $throttled === true
)->andReturn(true);
$provider->shouldReceive('suspendAccount')->never();
$this->app->instance(SharedNodeProvider::class, $provider);
app(HostingResourcePolicyService::class)->process($account);
$account->refresh();
$this->assertSame(HostingAccount::STATUS_ACTIVE, $account->status);
$this->assertSame(HostingAccount::RESOURCE_STATUS_THROTTLED, $account->resource_status);
$this->assertNotNull($account->throttled_at);
$this->assertDatabaseHas('hosting_account_alerts', [
'hosting_account_id' => $account->id,
'alert_type' => HostingAccountAlert::TYPE_THROTTLED,
'is_resolved' => false,
]);
}
public function test_it_suspends_a_throttled_account_when_abuse_continues(): void
{
Notification::fake();
$account = $this->makeAccount([
'status' => HostingAccount::STATUS_ACTIVE,
'resource_status' => HostingAccount::RESOURCE_STATUS_THROTTLED,
'cpu_limit_percent' => 50,
'cpu_usage_percent' => 10,
'memory_limit_mb' => 512,
'memory_used_mb' => 1024,
'process_limit' => 10,
'process_count' => 3,
'inode_limit' => 50000,
'inode_count' => 1000,
'throttled_at' => now()->subHours(25),
'memory_breach_streak' => 11,
]);
$order = CustomerHostingOrder::create([
'user_id' => $account->user_id,
'hosting_product_id' => $account->hosting_product_id,
'hosting_account_id' => $account->id,
'domain_name' => $account->primary_domain ?: 'example.test',
'billing_cycle' => CustomerHostingOrder::CYCLE_MONTHLY,
'amount_paid' => 10,
'currency' => 'GHS',
'status' => CustomerHostingOrder::STATUS_ACTIVE,
]);
$provider = \Mockery::mock(SharedNodeProvider::class);
$provider->shouldReceive('applyAccountResourceProfile')->never();
$provider->shouldReceive('suspendAccount')->once()->withArgs(
fn (HostingAccount $candidate, string $reason): bool => $candidate->id === $account->id && str_contains($reason, 'Memory usage')
)->andReturn(true);
$this->app->instance(SharedNodeProvider::class, $provider);
app(HostingResourcePolicyService::class)->process($account);
$account->refresh();
$order->refresh();
$this->assertSame(HostingAccount::STATUS_SUSPENDED, $account->status);
$this->assertSame(HostingAccount::RESOURCE_STATUS_SUSPENDED, $account->resource_status);
$this->assertSame(CustomerHostingOrder::STATUS_SUSPENDED, $order->status);
$this->assertDatabaseHas('hosting_account_alerts', [
'hosting_account_id' => $account->id,
'alert_type' => HostingAccountAlert::TYPE_SUSPENDED,
'is_resolved' => false,
]);
Notification::assertNothingSent();
}
public function test_it_unsuspends_an_automatically_suspended_account_when_usage_returns_to_normal(): void
{
Notification::fake();
$account = $this->makeAccount([
'status' => HostingAccount::STATUS_SUSPENDED,
'resource_status' => HostingAccount::RESOURCE_STATUS_SUSPENDED,
'cpu_limit_percent' => 50,
'cpu_usage_percent' => 0,
'memory_limit_mb' => 512,
'memory_used_mb' => 128,
'process_limit' => 10,
'process_count' => 2,
'inode_limit' => 50000,
'inode_count' => 1000,
'suspension_reason' => 'CPU usage reached 110.6% against a 50% limit.',
'suspended_at' => now()->subMinutes(10),
'metadata' => [
'suspension_origin' => 'automatic',
],
]);
$provider = \Mockery::mock(SharedNodeProvider::class);
$provider->shouldReceive('unsuspendAccount')->once()->withArgs(
fn (HostingAccount $candidate): bool => $candidate->id === $account->id
)->andReturn(true);
$provider->shouldReceive('applyAccountResourceProfile')->once()->withArgs(
fn (HostingAccount $candidate, bool $throttled): bool => $candidate->id === $account->id && $throttled === false
)->andReturn(true);
$provider->shouldReceive('suspendAccount')->never();
$this->app->instance(SharedNodeProvider::class, $provider);
app(HostingResourcePolicyService::class)->process($account);
$account->refresh();
$this->assertSame(HostingAccount::STATUS_ACTIVE, $account->status);
$this->assertSame(HostingAccount::RESOURCE_STATUS_ACTIVE, $account->resource_status);
$this->assertNull($account->suspended_at);
$this->assertNull($account->suspension_reason);
$this->assertFalse($account->uploads_restricted);
}
public function test_it_does_not_unsuspend_a_manually_suspended_account_when_usage_is_normal(): void
{
Notification::fake();
$account = $this->makeAccount([
'status' => HostingAccount::STATUS_SUSPENDED,
'resource_status' => HostingAccount::RESOURCE_STATUS_SUSPENDED,
'cpu_limit_percent' => 50,
'cpu_usage_percent' => 0,
'memory_limit_mb' => 512,
'memory_used_mb' => 128,
'process_limit' => 10,
'process_count' => 2,
'inode_limit' => 50000,
'inode_count' => 1000,
'suspension_reason' => 'Manual suspension.',
'suspended_at' => now()->subMinutes(10),
'metadata' => [
'suspension_origin' => 'manual',
],
]);
$provider = \Mockery::mock(SharedNodeProvider::class);
$provider->shouldReceive('applyAccountResourceProfile')->never();
$provider->shouldReceive('unsuspendAccount')->never();
$provider->shouldReceive('suspendAccount')->never();
$this->app->instance(SharedNodeProvider::class, $provider);
app(HostingResourcePolicyService::class)->process($account);
$account->refresh();
$this->assertSame(HostingAccount::STATUS_SUSPENDED, $account->status);
$this->assertSame(HostingAccount::RESOURCE_STATUS_SUSPENDED, $account->resource_status);
$this->assertNotNull($account->suspended_at);
$this->assertSame('Manual suspension.', $account->suspension_reason);
}
public function test_it_applies_slug_specific_inode_limits_for_single_domain_products(): void
{
$starter = HostingProduct::query()->updateOrCreate([
'slug' => 'starter-plan',
], [
'name' => 'Starter Plan',
'category' => HostingProduct::CATEGORY_SHARED,
'type' => HostingProduct::TYPE_SINGLE_DOMAIN,
'price_monthly' => 1,
'currency' => 'GHS',
'disk_gb' => 5,
'max_domains' => 1,
'is_active' => true,
'is_visible' => true,
]);
$basic = HostingProduct::query()->updateOrCreate([
'slug' => 'basic-plan',
], [
'name' => 'Basic Plan',
'category' => HostingProduct::CATEGORY_SHARED,
'type' => HostingProduct::TYPE_SINGLE_DOMAIN,
'price_monthly' => 25,
'currency' => 'GHS',
'disk_gb' => 10,
'max_domains' => 1,
'is_active' => true,
'is_visible' => true,
]);
$plus = HostingProduct::query()->updateOrCreate([
'slug' => 'plus-plan',
], [
'name' => 'Plus Plan',
'category' => HostingProduct::CATEGORY_SHARED,
'type' => HostingProduct::TYPE_SINGLE_DOMAIN,
'price_monthly' => 35,
'currency' => 'GHS',
'disk_gb' => 20,
'max_domains' => 1,
'is_active' => true,
'is_visible' => true,
]);
$growth = HostingProduct::query()->updateOrCreate([
'slug' => 'growth-plan',
], [
'name' => 'Growth Plan',
'category' => HostingProduct::CATEGORY_SHARED,
'type' => HostingProduct::TYPE_MULTI_DOMAIN,
'price_monthly' => 55,
'currency' => 'GHS',
'disk_gb' => 30,
'max_domains' => 5,
'is_active' => true,
'is_visible' => true,
]);
$pro = HostingProduct::query()->updateOrCreate([
'slug' => 'pro-plan',
], [
'name' => 'Pro Plan',
'category' => HostingProduct::CATEGORY_SHARED,
'type' => HostingProduct::TYPE_MULTI_DOMAIN,
'price_monthly' => 175,
'currency' => 'GHS',
'disk_gb' => 150,
'max_domains' => -1,
'is_active' => true,
'is_visible' => true,
]);
$service = app(HostingResourcePolicyService::class);
$this->assertSame(225000, $service->defaultLimitsForProduct($starter)['inode_limit']);
$this->assertSame(450000, $service->defaultLimitsForProduct($basic)['inode_limit']);
$this->assertSame(450000, $service->defaultLimitsForProduct($plus)['inode_limit']);
$this->assertSame(750000, $service->defaultLimitsForProduct($growth)['inode_limit']);
$this->assertSame(1200000, $service->defaultLimitsForProduct($pro)['inode_limit']);
}
private function makeAccount(array $overrides = []): HostingAccount
{
$user = User::factory()->create();
$product = HostingProduct::create([
'name' => 'Resource Test Hosting',
'slug' => 'resource-test-hosting-' . str()->random(6),
'category' => HostingProduct::CATEGORY_SHARED,
'type' => HostingProduct::TYPE_SINGLE_DOMAIN,
'price_monthly' => 10,
'currency' => 'GHS',
'disk_gb' => 10,
'max_domains' => 1,
'is_active' => true,
'is_visible' => true,
]);
return HostingAccount::create(array_merge([
'user_id' => $user->id,
'hosting_product_id' => $product->id,
'username' => 'acct' . random_int(1000, 9999),
'primary_domain' => 'resource-test.example',
'type' => HostingAccount::TYPE_SHARED,
'status' => HostingAccount::STATUS_ACTIVE,
'allocated_disk_gb' => 10,
'cpu_limit_percent' => 50,
'memory_limit_mb' => 512,
'process_limit' => 10,
'io_limit_mb' => 5,
'inode_limit' => 50000,
'resource_status' => HostingAccount::RESOURCE_STATUS_ACTIVE,
], $overrides));
}
}
+438
View File
@@ -0,0 +1,438 @@
<?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',
]);
});
}
}
@@ -0,0 +1,283 @@
<?php
namespace Tests\Feature;
use App\Models\CustomerHostingOrder;
use App\Models\Domain;
use App\Models\HostingAccount;
use App\Models\HostingProduct;
use App\Models\Mailbox;
use App\Models\User;
use App\Services\Hosting\Providers\SharedNodeProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class HostingUpsellRecommendationTest extends TestCase
{
use RefreshDatabase;
public function test_api_returns_plan_upgrade_recommendation_when_storage_usage_is_high(): void
{
$user = User::factory()->create();
$starter = $this->makeProduct([
'name' => 'Starter Plan',
'slug' => 'starter-upsell-api',
'type' => HostingProduct::TYPE_SINGLE_DOMAIN,
'price_monthly' => 1,
'disk_gb' => 5,
'max_domains' => 1,
'max_email_accounts' => 5,
'sort_order' => 1,
]);
$basic = $this->makeProduct([
'name' => 'Basic Plan',
'slug' => 'basic-upsell-api',
'type' => HostingProduct::TYPE_SINGLE_DOMAIN,
'price_monthly' => 15,
'disk_gb' => 25,
'max_domains' => 1,
'max_email_accounts' => 10,
'sort_order' => 2,
]);
$account = $this->makeAccount($user, $starter, [
'primary_domain' => 'starter-upsell.test',
'disk_used_bytes' => (int) round(5 * 0.8 * 1073741824),
]);
$this->actingAs($user)
->getJson('/api/upsells/recommendations')
->assertOk()
->assertJsonPath('data.0.type', 'plan_upgrade')
->assertJsonPath('data.0.account_id', $account->id)
->assertJsonPath('data.0.target_product_id', $basic->id)
->assertJsonPath('data.0.target_product_name', 'Basic Plan');
}
public function test_api_returns_email_addon_recommendation_when_mailbox_usage_exceeds_allowance(): void
{
$user = User::factory()->create();
$product = $this->makeProduct([
'name' => 'Growth Plan',
'slug' => 'growth-upsell-email',
'max_email_accounts' => 1,
]);
$account = $this->makeAccount($user, $product, [
'primary_domain' => 'mailbox-upsell.test',
]);
$domain = $this->makeDomain($user, $account, 'mailbox-upsell.test');
Mailbox::create([
'user_id' => $user->id,
'hosting_account_id' => $account->id,
'domain_id' => $domain->id,
'display_name' => 'Info',
'local_part' => 'info',
'address' => 'info@mailbox-upsell.test',
'password_hash' => Hash::make('password123'),
'quota_mb' => 2048,
'status' => 'active',
'maildir_path' => 'hosting/1/mailbox-upsell.test/info',
'credentials_issued_at' => now(),
'incoming_token' => 'upsell-token-1',
'inbound_enabled' => true,
'outbound_enabled' => true,
'outbound_provider' => 'smtp',
'is_paid' => false,
]);
Mailbox::create([
'user_id' => $user->id,
'hosting_account_id' => $account->id,
'domain_id' => $domain->id,
'display_name' => 'Sales',
'local_part' => 'sales',
'address' => 'sales@mailbox-upsell.test',
'password_hash' => Hash::make('password123'),
'quota_mb' => 2048,
'status' => 'active',
'maildir_path' => 'hosting/1/mailbox-upsell.test/sales',
'credentials_issued_at' => now(),
'incoming_token' => 'upsell-token-2',
'inbound_enabled' => true,
'outbound_enabled' => true,
'outbound_provider' => 'smtp',
'is_paid' => true,
]);
$this->actingAs($user)
->getJson('/api/upsells/recommendations')
->assertOk()
->assertJsonFragment([
'type' => 'email_addon',
'account_id' => $account->id,
'suggested_quantity' => 10,
])
->assertJsonFragment([
'estimated_amount' => 50.0,
]);
}
public function test_account_page_renders_recommendation_and_web_plan_change_route_applies_upgrade(): void
{
$user = User::factory()->create();
$starter = $this->makeProduct([
'name' => 'Starter Plan',
'slug' => 'starter-upsell-web',
'type' => HostingProduct::TYPE_SINGLE_DOMAIN,
'price_monthly' => 1,
'disk_gb' => 5,
'sort_order' => 1,
]);
$basic = $this->makeProduct([
'name' => 'Basic Plan',
'slug' => 'basic-upsell-web',
'type' => HostingProduct::TYPE_SINGLE_DOMAIN,
'price_monthly' => 15,
'disk_gb' => 25,
'max_email_accounts' => 10,
'sort_order' => 2,
]);
$account = $this->makeAccount($user, $starter, [
'primary_domain' => 'upgrade-web.test',
'disk_used_bytes' => (int) round(5 * 0.82 * 1073741824),
'expires_at' => now()->addMonth(),
]);
CustomerHostingOrder::create([
'user_id' => $user->id,
'hosting_product_id' => $starter->id,
'hosting_account_id' => $account->id,
'domain_name' => $account->primary_domain,
'billing_cycle' => CustomerHostingOrder::CYCLE_MONTHLY,
'amount_paid' => 1,
'currency' => 'GHS',
'status' => CustomerHostingOrder::STATUS_ACTIVE,
'expires_at' => now()->addMonth(),
]);
$provider = \Mockery::mock(SharedNodeProvider::class);
$provider->shouldReceive('syncAccountPackage')->once()->andReturn(true);
$this->app->instance(SharedNodeProvider::class, $provider);
$this->actingAs($user)
->get(route('hosting.accounts.show', $account))
->assertOk()
->assertSeeText('Recommended Next Steps')
->assertSeeText('Upgrade to Basic Plan');
$this->actingAs($user)
->post(route('hosting.accounts.plan-change', $account), [
'target_product_id' => $basic->id,
])
->assertRedirect(route('hosting.accounts.show', $account));
$account->refresh();
$this->assertSame($basic->id, $account->hosting_product_id);
$this->assertSame(25, $account->allocated_disk_gb);
}
public function test_dashboard_renders_hosting_recommendations(): void
{
$user = User::factory()->create();
$starter = $this->makeProduct([
'name' => 'Starter Plan',
'slug' => 'starter-upsell-dashboard',
'type' => HostingProduct::TYPE_SINGLE_DOMAIN,
'disk_gb' => 5,
'sort_order' => 1,
]);
$this->makeProduct([
'name' => 'Basic Plan',
'slug' => 'basic-upsell-dashboard',
'type' => HostingProduct::TYPE_SINGLE_DOMAIN,
'disk_gb' => 25,
'sort_order' => 2,
]);
$this->makeAccount($user, $starter, [
'primary_domain' => 'dashboard-upsell.test',
'disk_used_bytes' => (int) round(5 * 0.75 * 1073741824),
]);
$this->actingAs($user)
->get(route('dashboard'))
->assertOk()
->assertSeeText('Recommended Next Steps')
->assertSeeText('Storage usage is high on dashboard-upsell.test');
}
private function makeProduct(array $overrides = []): HostingProduct
{
return HostingProduct::create(array_merge([
'name' => 'Shared Upsell',
'slug' => 'shared-upsell-' . str()->random(8),
'category' => HostingProduct::CATEGORY_SHARED,
'type' => HostingProduct::TYPE_SINGLE_DOMAIN,
'price_monthly' => 10,
'price_quarterly' => 30,
'price_yearly' => 120,
'price_biennial' => 240,
'currency' => 'GHS',
'disk_gb' => 10,
'max_domains' => 1,
'max_databases' => 2,
'max_email_accounts' => 5,
'bandwidth_gb' => 100,
'is_active' => true,
'is_visible' => true,
], $overrides));
}
private function makeAccount(User $user, HostingProduct $product, array $overrides = []): HostingAccount
{
return HostingAccount::unguarded(function () use ($user, $product, $overrides) {
return HostingAccount::create(array_merge([
'user_id' => $user->id,
'hosting_product_id' => $product->id,
'username' => 'acct' . random_int(1000, 9999),
'primary_domain' => 'upsell.example.test',
'type' => HostingAccount::TYPE_SHARED,
'status' => HostingAccount::STATUS_ACTIVE,
'allocated_disk_gb' => (int) ($product->disk_gb ?? 0),
'disk_used_bytes' => 0,
'bandwidth_used_bytes' => 0,
'php_version' => '8.4',
'cpu_limit_percent' => 50,
'memory_limit_mb' => 512,
'process_limit' => 10,
'io_limit_mb' => 5,
'inode_limit' => 50000,
'resource_status' => HostingAccount::RESOURCE_STATUS_ACTIVE,
'resource_limits' => [
'disk_gb' => (int) ($product->disk_gb ?? 0),
'bandwidth_gb' => $product->bandwidth_gb,
'max_domains' => $product->max_domains,
'max_databases' => $product->max_databases,
'max_email_accounts' => $product->max_email_accounts,
],
], $overrides));
});
}
private function makeDomain(User $user, HostingAccount $account, string $host): Domain
{
return Domain::create([
'user_id' => $user->id,
'website_id' => null,
'hosting_account_id' => $account->id,
'host' => $host,
'type' => 'custom',
'source' => 'hosting',
'status' => 'verified',
'onboarding_mode' => Domain::MODE_NS_AUTO,
'onboarding_state' => Domain::STATE_ACTIVE,
'dns_mode' => 'managed',
'verified_at' => now(),
'active_at' => now(),
]);
}
}
+172
View File
@@ -0,0 +1,172 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\EmailDomain\EmailDomainClient;
use App\Services\Mailbox\MailboxClient;
use App\Services\Payment\WalletPaymentService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Mockery;
use Tests\TestCase;
class MailboxesTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
private function user(): User
{
return User::create(['public_id' => (string) Str::uuid(), 'name' => 'A', 'email' => 'a+'.uniqid().'@x.com']);
}
private function mailboxClient(): \Mockery\MockInterface
{
$m = Mockery::mock(MailboxClient::class);
$this->app->instance(MailboxClient::class, $m);
return $m;
}
private function wallet(): \Mockery\MockInterface
{
$w = Mockery::mock(WalletPaymentService::class);
$w->shouldReceive('balanceMinor')->andReturn(0)->byDefault();
$this->app->instance(WalletPaymentService::class, $w);
return $w;
}
public function test_root_redirects_guest_to_sso(): void
{
$this->get('/')->assertRedirect(route('sso.connect'));
}
public function test_mailboxes_require_login(): void
{
$this->get(route('email.mailboxes.index'))->assertRedirect(route('login'));
}
public function test_index_lists_from_api(): void
{
$this->mailboxClient()->shouldReceive('forUser')->andReturn([
['id' => 1, 'address' => 'me@acme.com', 'display_name' => 'Me', 'quota_mb' => 10240, 'status' => 'active'],
]);
$this->actingAs($this->user())->get(route('email.mailboxes.index'))->assertOk()->assertSee('me@acme.com');
}
public function test_create_form_shows_free_banner_within_allowance(): void
{
config(['email.free_mailboxes' => 1]);
$this->mailboxClient()->shouldReceive('forUser')->andReturn([]);
$this->wallet();
$dc = Mockery::mock(EmailDomainClient::class);
$dc->shouldReceive('verified')->andReturn([['id' => 7, 'domain' => 'acme.com']]);
$this->app->instance(EmailDomainClient::class, $dc);
$this->actingAs($this->user())->get(route('email.mailboxes.create'))
->assertOk()->assertSee('free, forever', false);
}
public function test_store_free_1gb_mailbox_creates_without_charge(): void
{
$mc = $this->mailboxClient();
$mc->shouldReceive('create')->once()
->with(Mockery::any(), 7, 'sales', 'Sales', 'secret123', 1024, false, null) // 1 GB = free
->andReturn(['id' => 9, 'address' => 'sales@acme.com']);
$w = $this->wallet();
$w->shouldNotReceive('charge');
$this->actingAs($this->user())->post(route('email.mailboxes.store'), [
'email_domain_id' => 7, 'local_part' => 'Sales', 'display_name' => 'Sales',
'password' => 'secret123', 'password_confirmation' => 'secret123', 'quota_mb' => 1024,
])->assertRedirect(route('email.mailboxes.show', 9));
}
public function test_store_paid_mailbox_charges_tier_price(): void
{
// 25 GB tier = GHS 30 = 3000 (doubled)
$mc = $this->mailboxClient();
$mc->shouldReceive('create')->once()
->withArgs(fn ($pid, $did, $lp, $dn, $pw, $q, $paid, $ref) => $did === 7 && $q === 25600 && $paid === true && is_string($ref))
->andReturn(['id' => 5, 'address' => 'sales@acme.com']);
$w = $this->wallet();
$w->shouldReceive('canAfford')->once()->with(Mockery::any(), 3000)->andReturn(true);
$w->shouldReceive('charge')->once()->withArgs(fn ($u, $amt, ...$r) => $amt === 3000)->andReturn(true);
$this->actingAs($this->user())->post(route('email.mailboxes.store'), [
'email_domain_id' => 7, 'local_part' => 'sales', 'display_name' => 'Sales',
'password' => 'secret123', 'password_confirmation' => 'secret123', 'quota_mb' => 25600,
])->assertRedirect(route('email.mailboxes.show', 5));
}
public function test_store_rejects_invalid_quota(): void
{
$mc = $this->mailboxClient();
$mc->shouldNotReceive('create');
$this->wallet();
$this->actingAs($this->user())->from(route('email.mailboxes.create'))->post(route('email.mailboxes.store'), [
'email_domain_id' => 7, 'local_part' => 'sales', 'display_name' => 'Sales',
'password' => 'secret123', 'password_confirmation' => 'secret123', 'quota_mb' => 999,
])->assertSessionHasErrors('quota_mb');
}
public function test_store_paid_blocks_when_wallet_insufficient(): void
{
$mc = $this->mailboxClient();
$mc->shouldNotReceive('create');
$w = $this->wallet();
$w->shouldReceive('canAfford')->once()->andReturn(false);
$this->actingAs($this->user())->from(route('email.mailboxes.create'))->post(route('email.mailboxes.store'), [
'email_domain_id' => 7, 'local_part' => 'sales', 'display_name' => 'Sales',
'password' => 'secret123', 'password_confirmation' => 'secret123', 'quota_mb' => 10240,
])->assertRedirect(route('email.mailboxes.create'))->assertSessionHas('error');
}
public function test_upgrade_charges_new_tier_and_updates_quota(): void
{
$mc = $this->mailboxClient();
$mc->shouldReceive('show')->andReturn(['id' => 3, 'address' => 'me@acme.com', 'quota_mb' => 1024]); // currently free 1 GB
$mc->shouldReceive('updateQuota')->once()
->withArgs(fn ($pid, $id, $mb, $paid, $ref) => $id === 3 && $mb === 10240 && $paid === true && is_string($ref))
->andReturn(['id' => 3, 'quota_mb' => 10240]);
$w = $this->wallet();
$w->shouldReceive('canAfford')->once()->with(Mockery::any(), 2000)->andReturn(true); // 10 GB = GHS 20
$w->shouldReceive('charge')->once()->withArgs(fn ($u, $amt, ...$r) => $amt === 2000)->andReturn(true);
$this->actingAs($this->user())->patch(route('email.mailboxes.upgrade.store', 3), ['quota_mb' => 10240])
->assertRedirect(route('email.mailboxes.show', 3));
}
public function test_upgrade_rejects_smaller_or_equal_plan(): void
{
$mc = $this->mailboxClient();
$mc->shouldReceive('show')->andReturn(['id' => 3, 'address' => 'me@acme.com', 'quota_mb' => 10240]);
$mc->shouldNotReceive('updateQuota');
$w = $this->wallet();
$w->shouldNotReceive('charge');
$this->actingAs($this->user())->from(route('email.mailboxes.show', 3))
->patch(route('email.mailboxes.upgrade.store', 3), ['quota_mb' => 5120])
->assertRedirect(route('email.mailboxes.show', 3))->assertSessionHas('error');
}
public function test_domains_index_lists_from_api(): void
{
$dc = Mockery::mock(EmailDomainClient::class);
$dc->shouldReceive('forUser')->andReturn([['id' => 1, 'domain' => 'acme.com', 'active' => false]]);
$this->app->instance(EmailDomainClient::class, $dc);
$this->actingAs($this->user())->get(route('email.domains.index'))
->assertOk()->assertSee('acme.com')->assertSee('Pending verification');
}
}
@@ -0,0 +1,30 @@
<?php
namespace Tests\Feature;
use Database\Seeders\HostingProductSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class MarketingHostingCatalogTest extends TestCase
{
use RefreshDatabase;
public function test_marketing_hosting_pages_render_updated_seeded_single_and_multi_domain_plans(): void
{
$this->seed(HostingProductSeeder::class);
$singleDomain = $this->get(route('products.hosting.single'));
$singleDomain->assertOk();
$singleDomain->assertSee('Starter Plan');
$singleDomain->assertSee('GHS 1.00');
$singleDomain->assertSee('8 GB · 1 site');
$multiDomain = $this->get(route('products.hosting.multi'));
$multiDomain->assertOk();
$multiDomain->assertSee('Pro Plan');
$multiDomain->assertSee('GHS 175.00');
$multiDomain->assertSee('225 GB · Unlimited sites');
$multiDomain->assertDontSee('225 GB · -1 sites');
}
}
@@ -0,0 +1,132 @@
<?php
namespace Tests\Feature;
use App\Jobs\ProvisionHostingOrderJob;
use App\Models\CustomerHostingOrder;
use App\Models\HostedSite;
use App\Models\HostingAccount;
use App\Models\HostingNode;
use App\Models\HostingProduct;
use App\Models\ProvisioningQueueItem;
use App\Models\User;
use App\Services\Hosting\HostingResourcePolicyService;
use App\Services\Hosting\NodeCapacityService;
use App\Services\Hosting\Providers\ContaboProvider;
use App\Services\Hosting\Providers\SharedNodeProvider;
use App\Services\Hosting\ServerAgentBootstrapService;
use App\Services\Hosting\ServerAgentInstallerService;
use App\Services\Hosting\ServerOrderService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProvisionHostingOrderJobSharedTest extends TestCase
{
use RefreshDatabase;
public function test_shared_order_provisioning_creates_account_on_a_compatible_node(): void
{
$user = User::factory()->create([
'email' => 'customer@example.com',
]);
$product = HostingProduct::create([
'name' => 'Starter Shared',
'slug' => 'starter-shared-job-test',
'category' => HostingProduct::CATEGORY_SHARED,
'type' => HostingProduct::TYPE_SINGLE_DOMAIN,
'price_monthly' => 1,
'currency' => 'GHS',
'disk_gb' => 5,
'max_domains' => 1,
'is_active' => true,
'is_visible' => true,
]);
$starterNode = HostingNode::create($this->nodeAttributes([
'name' => 'Starter Node',
'hostname' => 'starter-node.local',
'ip_address' => '192.0.2.20',
'segment' => HostingNode::SEGMENT_STARTER,
]));
HostingNode::create($this->nodeAttributes([
'name' => 'High Resource Node',
'hostname' => 'pro-node.local',
'ip_address' => '192.0.2.21',
'segment' => HostingNode::SEGMENT_HIGH_RESOURCE,
]));
$order = CustomerHostingOrder::create([
'user_id' => $user->id,
'hosting_product_id' => $product->id,
'domain_name' => 'starter-example.test',
'billing_cycle' => CustomerHostingOrder::CYCLE_MONTHLY,
'amount_paid' => 1,
'currency' => 'GHS',
'status' => CustomerHostingOrder::STATUS_APPROVED,
]);
$sharedProvider = \Mockery::mock(SharedNodeProvider::class);
$sharedProvider->shouldReceive('createAccountOnNode')
->once()
->withArgs(function (HostingNode $node, array $config) use ($starterNode): bool {
$this->assertSame($starterNode->id, $node->id);
$this->assertSame(5 * 1024, $config['disk_quota_mb']);
$this->assertSame('starter-example.test', $config['domain']);
return true;
})
->andReturn([
'home_directory' => '/home/startacct',
'document_root' => '/home/startacct/public_html',
]);
$sharedProvider->shouldReceive('applyAccountResourceProfile')->once()->andReturn(true);
$sharedProvider->shouldReceive('installWordPress')->never();
(new ProvisionHostingOrderJob($order))->handle(
app(NodeCapacityService::class),
app(HostingResourcePolicyService::class),
$sharedProvider,
\Mockery::mock(ContaboProvider::class),
\Mockery::mock(ServerOrderService::class),
\Mockery::mock(ServerAgentBootstrapService::class),
\Mockery::mock(ServerAgentInstallerService::class),
);
$order->refresh();
$account = HostingAccount::query()->firstOrFail();
$site = HostedSite::query()->firstOrFail();
$this->assertSame(CustomerHostingOrder::STATUS_ACTIVE, $order->status);
$this->assertSame($starterNode->id, $order->hosting_node_id);
$this->assertSame($account->id, $order->hosting_account_id);
$this->assertSame($product->id, $account->hosting_product_id);
$this->assertSame(5, $account->allocated_disk_gb);
$this->assertSame('starter-example.test', $account->primary_domain);
$this->assertSame('starter-example.test', $site->domain);
$this->assertSame('primary', $site->type);
$this->assertDatabaseHas('provisioning_queue', [
'customer_hosting_order_id' => $order->id,
'status' => ProvisioningQueueItem::STATUS_COMPLETED,
]);
$this->assertSame('starter', data_get($order->meta, 'node_segment'));
}
private function nodeAttributes(array $overrides = []): array
{
return array_merge([
'name' => 'Shared Node',
'hostname' => 'shared-node.local',
'ip_address' => '192.0.2.15',
'type' => HostingNode::TYPE_SHARED,
'segment' => HostingNode::SEGMENT_GENERAL,
'provider' => HostingNode::PROVIDER_MANUAL,
'disk_gb' => 100,
'oversell_ratio' => 3,
'max_accounts' => 100,
'status' => HostingNode::STATUS_ACTIVE,
'ssh_port' => '22',
], $overrides);
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_that_true_is_true(): void
{
$this->assertTrue(true);
}
}