CluPilotCloud/tests/Feature/Billing/StorageAllowanceTest.php

420 lines
18 KiB
PHP

<?php
use App\Actions\ApplyPlanChange;
use App\Actions\BookAddon;
use App\Livewire\Billing;
use App\Livewire\ConfirmBookStorage;
use App\Models\Host;
use App\Models\Instance;
use App\Models\InstanceMetric;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\SubscriptionAddon;
use App\Models\User;
use App\Provisioning\Steps\Customer\ApplyStorageQuota;
use App\Provisioning\Steps\Customer\GrowGuestFilesystem;
use App\Provisioning\Steps\Customer\ResizeVirtualMachine;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\DowngradeCheck;
use App\Services\Billing\PlanCatalogue;
use App\Services\Billing\StorageAllowance;
use App\Services\Proxmox\ProxmoxClient;
use App\Support\ProvisioningSettings;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
/**
* Extra storage, from the purchase to the byte.
*
* A booked pack was a row in `subscription_addons` and nothing else: charged
* every month, and delivered nowhere. Nextcloud's quota came from the package
* alone, the disk was sized for the package alone, and on the rare occasion a
* disk did grow — a plan upgrade — the guest never saw it, because nothing
* stretched the partition or the filesystem over the new space.
*
* These prove the whole chain: the allowance is one figure everything reads, the
* machine really gets the space, the guest really sees it, and a customer whose
* downgrade is blocked by their own data is offered a way out that works.
*/
/** A customer on a package, with a machine that is built, running and served. */
function storageFixture(string $plan = 'team', array $instanceAttributes = []): array
{
$host = Host::factory()->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]);
// Some of these tests book storage packs through Billing::purchase(),
// which since Task 5 refuses outright without a Stripe key for the
// active mode.
withStripeSecret();
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);
});