active()->create(['datacenter' => 'fsn', 'node' => 'pve']); $order = Order::factory()->withSubscription()->create(['datacenter' => 'fsn', 'plan' => $plan]); $subscription = $order->subscription; $customer = $order->customer; $user = User::factory()->create(['email' => $customer->email]); $instance = Instance::factory()->create(array_merge([ 'order_id' => $order->id, 'customer_id' => $customer->id, 'host_id' => $host->id, 'vmid' => 101, 'subdomain' => 'berger', 'plan' => $plan, 'quota_gb' => $subscription->quota_gb, 'disk_gb' => $subscription->disk_gb, 'ram_mb' => $subscription->ram_mb, 'cores' => $subscription->cores, 'status' => 'active', 'route_written' => true, 'routed_hostnames' => ['berger.'.ProvisioningSettings::dnsZone()], 'cert_ok' => true, ], $instanceAttributes)); $subscription->update(['instance_id' => $instance->id]); return [ 'host' => $host, 'order' => $order, 'customer' => $customer, 'user' => $user, 'subscription' => $subscription->fresh(), 'instance' => $instance, ]; } /** A run of the storage pipeline against that machine, ready for a step. */ function storageRun(array $fixture): ProvisioningRun { return ProvisioningRun::factory()->create([ 'subject_type' => Order::class, 'subject_id' => $fixture['order']->id, 'pipeline' => 'storage', 'context' => [ 'instance_id' => $fixture['instance']->id, 'host_id' => $fixture['host']->id, 'node' => 'pve', 'vmid' => 101, 'subdomain' => $fixture['instance']->subdomain, ], ]); } /** What a guest answers when it is asked about its data filesystem. */ function scriptGuestFilesystem($pve, string $source = '/dev/sda1', string $type = 'ext4', string $target = '/'): void { $pve->guestScript('findmnt', 0, "{$source} {$type} {$target}"); } beforeEach(function () { fakeServices(); // The delivery run is created and left for a worker: these tests are about // what the steps do when they run, not about the queue. Queue::fake(); }); // ─── A. one authority for the allowance ────────────────────────────────────── it('raises the allowance with a booked pack and tells Nextcloud the larger figure', function () { $fixture = storageFixture('team'); // 500 GB package $pve = app(ProxmoxClient::class); app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE); expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(600); expect(app(ApplyStorageQuota::class)->execute(storageRun($fixture))->type)->toBe('advance'); expect($pve->guestRan("files default_quota --value='600 GB'"))->toBeTrue() // Recorded as applied, so the sweep can tell an enforced machine from // one where the figure has only ever been a row in our database. ->and($fixture['instance']->fresh()->quota_applied_gb)->toBe(600) ->and($fixture['instance']->fresh()->quotaIsEnforced())->toBeTrue(); }); it('stacks two packs and lowers the allowance again when one is cancelled', function () { $fixture = storageFixture('team'); $contract = $fixture['subscription']; $first = app(BookAddon::class)($contract, AddonCatalogue::STORAGE); app(BookAddon::class)($contract, AddonCatalogue::STORAGE); expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(700); app(BookAddon::class)->cancel($first); expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(600) // Cancelled, never deleted: what somebody was paying, and until when, is // evidence. ->and(SubscriptionAddon::query()->count())->toBe(2); }); it('counts a pack booked with a quantity of more than one', function () { $fixture = storageFixture('team'); app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE, 3); expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(800); }); it('shows the whole allowance in the portal, not the package alone', function () { $fixture = storageFixture('team'); app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE, 2); Livewire::actingAs($fixture['user']) ->test(Billing::class) ->assertSee('700 GB') ->assertSee(__('billing.storage_includes_packs', ['count' => 2, 'gb' => 200])); }); it('starts one delivery run when a pack is booked, and none while another is in flight', function () { $fixture = storageFixture('team'); app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE); expect(ProvisioningRun::query()->where('pipeline', 'storage')->count())->toBe(1); // A second booking arrives while the first run is still pending. That run // reads the allowance when it reaches the quota step, which is this state or // newer — a second run beside it would resize the same disk twice. app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE); expect(ProvisioningRun::query()->where('pipeline', 'storage')->count())->toBe(1); }); it('starts a delivery run when a pack is cancelled, so the quota comes back down', function () { $fixture = storageFixture('team'); $addon = app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE); ProvisioningRun::query()->update(['status' => ProvisioningRun::STATUS_COMPLETED]); app(BookAddon::class)->cancel($addon); expect(ProvisioningRun::query()->where('pipeline', 'storage')->count())->toBe(2); }); // ─── B. the machine really gets the space ──────────────────────────────────── it('grows the disk to the allowance plus the package overhead, and the guest with it', function () { // Team is 500 GB sold on a 540 GB disk: 40 GB of overhead the package was // sized with, and the pack is a hundred gigabytes ON TOP of it. $fixture = storageFixture('team'); $pve = app(ProxmoxClient::class); scriptGuestFilesystem($pve); app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE); $run = storageRun($fixture); expect(app(ResizeVirtualMachine::class)->execute($run)->type)->toBe('advance') ->and($pve->resizeCalls)->toContain('101:scsi0:640G') ->and($fixture['instance']->fresh()->disk_gb)->toBe(640); expect(app(GrowGuestFilesystem::class)->execute($run)->type)->toBe('advance') ->and($pve->guestRan('/sys/class/block/sda/device/rescan'))->toBeTrue() ->and($pve->guestRan("growpart '/dev/sda' '1'"))->toBeTrue() ->and($pve->guestRan("resize2fs '/dev/sda1'"))->toBeTrue(); }); it('does nothing the second time round', function () { $fixture = storageFixture('team'); $pve = app(ProxmoxClient::class); scriptGuestFilesystem($pve); app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE); $run = storageRun($fixture); app(ResizeVirtualMachine::class)->execute($run); app(GrowGuestFilesystem::class)->execute($run); // Everything is already the right size. growpart answers the way it answers // when the partition already fills the disk: a non-zero exit with NOCHANGE // on its output, which is not a failure and must not be read as one. $pve->resizeCalls = []; $pve->guestCommands = []; $pve->guestScript('growpart', 1, 'NOCHANGE: partition 1 is size 1342177280. it cannot be grown'); expect(app(ResizeVirtualMachine::class)->execute($run->fresh())->type)->toBe('advance') ->and($pve->resizeCalls)->toBe([]) ->and($fixture['instance']->fresh()->disk_gb)->toBe(640); expect(app(GrowGuestFilesystem::class)->execute($run->fresh())->type)->toBe('advance'); }); it('grows an xfs filesystem with its own tool, pointed at the mount point', function () { $fixture = storageFixture('team'); $pve = app(ProxmoxClient::class); scriptGuestFilesystem($pve, '/dev/sda2', 'xfs', '/'); expect(app(GrowGuestFilesystem::class)->execute(storageRun($fixture))->type)->toBe('advance') ->and($pve->guestRan("growpart '/dev/sda' '2'"))->toBeTrue() // The mount point, not the device: that is what xfs_growfs takes. ->and($pve->guestRan("xfs_growfs '/'"))->toBeTrue() ->and($pve->guestRan('resize2fs'))->toBeFalse(); }); it('fails loudly on a filesystem it does not know how to grow', function () { $fixture = storageFixture('team'); $pve = app(ProxmoxClient::class); scriptGuestFilesystem($pve, '/dev/sda1', 'reiserfs', '/'); $result = app(GrowGuestFilesystem::class)->execute(storageRun($fixture)); // A failed run somebody can read, never a silent advance: a customer who has // paid for space that never appeared must not depend on anyone noticing. expect($result->type)->toBe('fail') ->and($result->reason)->toContain('reiserfs') ->and($pve->guestRan('resize2fs'))->toBeFalse() ->and($pve->guestRan('xfs_growfs'))->toBeFalse(); }); it('waits for a guest that is not answering rather than pretending it grew', function () { $fixture = storageFixture('team'); $pve = app(ProxmoxClient::class); $pve->guestAgentUp = false; $result = app(GrowGuestFilesystem::class)->execute(storageRun($fixture)); expect($result->type)->toBe('retry') ->and($pve->guestCommands)->toBe([]); }); // ─── C. a blocked downgrade with a way out ─────────────────────────────────── /** A reading of what is actually stored, dated. */ function storedBytes(Instance $instance, int $gib, ?string $day = null): InstanceMetric { return InstanceMetric::query()->updateOrCreate( ['instance_id' => $instance->id, 'day' => $day ?? now()->toDateString()], ['disk_used_bytes' => $gib * 1024 ** 3, 'disk_total_bytes' => 1050 * 1024 ** 3], ); } it('still blocks a downgrade the stored data does not fit, and says what would fix it', function () { $fixture = storageFixture('business'); // 1000 GB package storedBytes($fixture['instance'], 600); $team = app(PlanCatalogue::class)->sellable()['team']; // 500 GB $check = DowngradeCheck::for($fixture['customer'], $fixture['instance'], $team, 'team'); expect($check->allowed)->toBeFalse() ->and(collect($check->blockers)->pluck('key')->all())->toBe(['storage']) ->and($check->storageRemedy)->not->toBeNull() ->and($check->storageRemedy['limit'])->toBe('500 GB') // One pack covers the hundred gigabytes that are over. ->and($check->storageRemedy['packs'])->toBe(1) ->and($check->storageRemedy['pack_gb'])->toBe(100); }); it('lets exactly that downgrade through once the offered packs are booked', function () { $fixture = storageFixture('business'); storedBytes($fixture['instance'], 600); $team = app(PlanCatalogue::class)->sellable()['team']; $packs = DowngradeCheck::for($fixture['customer'], $fixture['instance'], $team, 'team')->storageRemedy['packs']; foreach (range(1, $packs) as $ignored) { app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE); } $after = DowngradeCheck::for($fixture['customer'], $fixture['instance']->fresh(), $team, 'team'); expect($after->allowed)->toBeTrue() ->and($after->blockers)->toBe([]); }); it('offers the packs through a modal, and books exactly the number that was offered', function () { $fixture = storageFixture('business'); storedBytes($fixture['instance'], 800); // three packs short of Team Livewire::actingAs($fixture['user']) ->test(Billing::class) ->assertSee(__('billing.downgrade_escape.remeasure')) // R23: the confirmation is this product's own dialog, never the // browser's — the card only opens it. ->assertSee('confirm-book-storage') ->call('bookStoragePacks', 3); expect(Order::query()->where('customer_id', $fixture['customer']->id)->where('type', 'storage')->count())->toBe(3); }); it('confirms the packs in this product own dialog and raises the event with the number', function () { Livewire::test(ConfirmBookStorage::class, ['packs' => 3]) ->assertSee(__('billing.storage_confirm_title', ['count' => 3])) ->call('proceed') ->assertDispatched('storage-packs-confirmed', packs: 3); }); it('refuses to put more packs in the cart than the ceiling allows', function () { $fixture = storageFixture('team'); Livewire::actingAs($fixture['user']) ->test(Billing::class) ->call('bookStoragePacks', 9999); expect(Order::query()->where('customer_id', $fixture['customer']->id)->where('type', 'storage')->count())->toBe(50); }); it('passes the check on a fresh reading after data was deleted, without waiting for the night', function () { $fixture = storageFixture('business'); $pve = app(ProxmoxClient::class); // Last night's reading: 600 GB stored, which does not fit Team. storedBytes($fixture['instance'], 600, now()->subDay()->toDateString()); $team = app(PlanCatalogue::class)->sellable()['team']; expect(DowngradeCheck::for($fixture['customer'], $fixture['instance'], $team, 'team')->allowed)->toBeFalse(); // The customer deletes 300 GB and asks us to look now. $pve->guestScript('df -B1', 0, "size used\n".(1050 * 1024 ** 3).' '.(300 * 1024 ** 3)); Livewire::actingAs($fixture['user']) ->test(Billing::class) ->call('remeasureStorage') ->assertDispatched('notify', message: __('billing.storage_remeasured')); expect(DowngradeCheck::for($fixture['customer'], $fixture['instance']->fresh(), $team, 'team')->allowed)->toBeTrue(); }); it('says it could not look rather than reporting an unreachable cloud as empty', function () { $fixture = storageFixture('business'); $pve = app(ProxmoxClient::class); storedBytes($fixture['instance'], 600, now()->subDay()->toDateString()); $pve->guestAgentUp = false; Livewire::actingAs($fixture['user']) ->test(Billing::class) ->call('remeasureStorage') ->assertDispatched('notify', message: __('billing.storage_remeasure_unavailable')); $team = app(PlanCatalogue::class)->sellable()['team']; expect(DowngradeCheck::for($fixture['customer'], $fixture['instance']->fresh(), $team, 'team')->allowed)->toBeFalse(); }); // ─── the packs survive a change of package ─────────────────────────────────── it('keeps booked packs through a plan change and delivers them on the new package', function () { $fixture = storageFixture('team'); $pve = app(ProxmoxClient::class); scriptGuestFilesystem($pve); app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE, 2); // The packs were paid for separately and have nothing to do with which // package the customer is on, so nothing cancels them. app(ApplyPlanChange::class)($fixture['subscription']->fresh(), 'business'); $instance = $fixture['instance']->fresh(); expect(SubscriptionAddon::query()->where('addon_key', AddonCatalogue::STORAGE)->active()->sum('quantity'))->toBe(2) // Business is 1000 GB; the two packs are still theirs on top of it. ->and(StorageAllowance::for($instance)->totalGb())->toBe(1200); $run = ProvisioningRun::query()->where('pipeline', 'plan-change')->latest('id')->firstOrFail(); app(ResizeVirtualMachine::class)->execute($run); app(GrowGuestFilesystem::class)->execute($run->fresh()); app(ApplyStorageQuota::class)->execute($run->fresh()); // 1200 GB for the customer, on Business's own 50 GB of overhead. expect($pve->resizeCalls)->toContain('101:scsi0:1250G') ->and($pve->guestRan("files default_quota --value='1200 GB'"))->toBeTrue() ->and($instance->fresh()->quota_applied_gb)->toBe(1200); });