Initial Ladill Hosting app with Gitea deploy pipeline.
Deploy Ladill Hosting / deploy (push) Failing after 17s
Deploy Ladill Hosting / deploy (push) Failing after 17s
Shared web hosting extracted from the platform monolith, with CI deploy to /var/www/ladill-hosting matching Bird/Domains/Email. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\EmailTeamMember;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\BillingClient;
|
||||
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 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('Mailbox defaults');
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
$m = Mockery::mock(MailboxClient::class);
|
||||
$m->shouldReceive('forUser')->andReturn([['address' => 'a@x.com', 'is_paid' => true, 'status' => 'active', 'quota_mb' => 10240]]);
|
||||
$this->app->instance(MailboxClient::class, $m);
|
||||
|
||||
$this->actingAs($this->user())->get(route('account.billing'))
|
||||
->assertOk()->assertSee('Mailbox subscriptions')->assertSee('a@x.com')->assertSee('10 GB');
|
||||
}
|
||||
|
||||
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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user