CluPilotCloud/tests/Feature/Provisioning/EndInstanceServiceTest.php

235 lines
10 KiB
PHP

<?php
use App\Actions\EndInstanceService;
use App\Livewire\ConfirmCancelPackage;
use App\Models\Customer;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\User;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use App\Support\ProvisioningSettings;
use Livewire\Livewire;
/**
* The router that was never torn down.
*
* `TraefikWriter::remove()` had no caller anywhere in the application. Every
* route this platform wrote was written for good: the file stayed on the serving
* host, the hostname went on resolving, and it went on pointing at a guest
* address the host is free to hand to a different customer's VM later. What was
* missing was not the removal — it was the MOMENT. ConfirmCancelPackage wrote a
* date into `service_ends_at` and nothing ever went back to it.
*
* These tests are about that moment, and about the line either side of it: a
* cancellation that has merely been scheduled is a paying customer.
*/
/** A live, routed instance with a DNS record in our zone. */
function routedInstance(array $attributes = [], string $subdomain = 'berger'): array
{
$services = fakeServices();
$host = Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve', 'public_ip' => '203.0.113.9']);
$order = Order::factory()->withSubscription()->create(['datacenter' => 'fsn', 'plan' => 'business']);
$instance = Instance::factory()->create(array_merge([
'order_id' => $order->id,
'customer_id' => $order->customer_id,
'host_id' => $host->id,
'vmid' => 101,
'guest_ip' => '10.20.0.7',
'subdomain' => $subdomain,
'status' => 'active',
'route_written' => true,
'routed_hostnames' => [$subdomain.'.'.ProvisioningSettings::dnsZone()],
'cert_ok' => true,
], $attributes));
$recordId = $services['dns']->upsertRecord($subdomain.'.'.ProvisioningSettings::dnsZone(), 'A', $host->public_ip);
$instance->dnsRecords()->create([
'provider' => 'hetzner',
'record_id' => $recordId,
'fqdn' => $subdomain.'.'.ProvisioningSettings::dnsZone(),
'type' => 'A',
'value' => $host->public_ip,
]);
$services['traefik']->write($host->wg_ip ?? $host->public_ip, $subdomain, $instance->routed_hostnames, $instance->guest_ip);
return compact('services', 'host', 'order', 'instance');
}
it('takes the router and the DNS record down when the paid term has run out', function () {
['services' => $services, 'instance' => $instance] = routedInstance([
'status' => 'cancellation_scheduled',
'cancel_requested_at' => now()->subMonth(),
'service_ends_at' => now()->subHour(),
]);
expect($services['traefik']->routes)->toHaveKey('berger');
$this->artisan('clupilot:end-due-services')->assertSuccessful();
expect($services['traefik']->routes)->not->toHaveKey('berger')
// The A record goes with the route: it is in OUR zone, it points at a
// host that goes on serving other customers, and a name we own that
// resolves to a live machine with nothing of its owner's behind it is
// the shape of every subdomain takeover there has ever been.
->and($services['dns']->records)->toBe([])
->and($instance->dnsRecords()->count())->toBe(0)
->and($instance->fresh()->status)->toBe('ended')
// The address state goes too, so a revived instance is not announced
// and routed nowhere by a step that thinks it has already written.
->and($instance->fresh()->route_written)->toBeFalse()
->and($instance->fresh()->routed_hostnames)->toBeNull()
->and($instance->fresh()->cert_ok)->toBeFalse();
});
it('leaves a scheduled cancellation exactly where it is until the day arrives', function () {
['services' => $services, 'instance' => $instance] = routedInstance([
'status' => 'cancellation_scheduled',
'cancel_requested_at' => now(),
'service_ends_at' => now()->addWeeks(3),
]);
$this->artisan('clupilot:end-due-services')->assertSuccessful();
// They have paid to the end of the term and are working in it this
// afternoon. Pulling the address the day somebody schedules a cancellation
// would be a far worse fault than the leak this closes.
expect($services['traefik']->routes)->toHaveKey('berger')
->and($services['dns']->records)->not->toBe([])
->and($instance->fresh()->status)->toBe('cancellation_scheduled')
->and($instance->fresh()->route_written)->toBeTrue();
});
it('does not touch an active instance, however old', function () {
['services' => $services, 'instance' => $instance] = routedInstance([
'created_at' => now()->subYears(2),
]);
$this->artisan('clupilot:end-due-services')->assertSuccessful();
expect($services['traefik']->routes)->toHaveKey('berger')
->and($instance->fresh()->status)->toBe('active');
});
it('leaves a cancellation with no end date alone rather than inventing one', function () {
['services' => $services, 'instance' => $instance] = routedInstance([
'status' => 'cancellation_scheduled',
'cancel_requested_at' => now()->subMonth(),
'service_ends_at' => null,
]);
$this->artisan('clupilot:end-due-services')->assertSuccessful();
// A broken record, not an expired one. Guessing an end date for it would
// invent the very moment this whole thing is written to respect.
expect($services['traefik']->routes)->toHaveKey('berger')
->and($instance->fresh()->status)->toBe('cancellation_scheduled');
});
it('changes nothing in dry-run mode', function () {
['services' => $services, 'instance' => $instance] = routedInstance([
'status' => 'cancellation_scheduled',
'service_ends_at' => now()->subDay(),
]);
$this->artisan('clupilot:end-due-services', ['--dry-run' => true])
->expectsOutputToContain('Probelauf')
->assertSuccessful();
expect($services['traefik']->routes)->toHaveKey('berger')
->and($services['dns']->records)->not->toBe([])
->and($instance->fresh()->status)->toBe('cancellation_scheduled');
});
it('is safe to run twice', function () {
['services' => $services, 'instance' => $instance] = routedInstance([
'status' => 'cancellation_scheduled',
'service_ends_at' => now()->subDay(),
]);
$this->artisan('clupilot:end-due-services')->assertSuccessful();
$this->artisan('clupilot:end-due-services')->assertSuccessful();
expect($instance->fresh()->status)->toBe('ended')
->and($services['traefik']->routes)->toBe([]);
});
it('carries the whole way from the customer cancelling to the address going away', function () {
$this->travelTo('2026-05-10 09:00:00');
['services' => $services, 'instance' => $instance, 'order' => $order] = routedInstance();
$stripe = new FakeStripeClient;
app()->instance(StripeClient::class, $stripe);
$contract = Subscription::query()->where('order_id', $order->id)->sole();
$contract->update([
'stripe_subscription_id' => 'sub_journey',
// The boundary the customer has paid to, and the one Stripe pushes
// forward on every renewal. Written as a literal date on purpose: this
// test used to travel to whatever `service_ends_at` the implementation
// had just computed, which made it recompute the code instead of
// checking it — a monthly answer on a yearly contract passed as happily
// as the right one.
'current_period_end' => '2026-08-01 09:00:00',
]);
$user = User::factory()->create(['email_verified_at' => now()]);
Customer::query()->whereKey($instance->customer_id)->update(['user_id' => $user->id, 'email' => $user->email]);
Livewire::actingAs($user)->test(ConfirmCancelPackage::class)
->set('confirmName', $instance->subdomain)
->call('cancelPackage');
$instance->refresh();
expect($instance->status)->toBe('cancellation_scheduled')
->and($instance->service_ends_at->toDateTimeString())->toBe('2026-08-01 09:00:00');
// The billing half, which was the broken half: the money stops too. Nothing
// in this application could cancel a Stripe subscription at all, so a
// customer whose address came down here went on being charged every month.
expect($stripe->cancellations)->toHaveCount(1)
->and($stripe->cancellations[0]['subscription'])->toBe('sub_journey')
->and($stripe->cancellations[0]['when'])->toBe(StripeClient::CANCEL_AT_PERIOD_END)
->and($contract->fresh()->cancel_requested_at?->toDateTimeString())->toBe('2026-05-10 09:00:00')
// Still active: until the first of August this is a paying customer.
->and($contract->fresh()->status)->toBe('active');
// Still theirs, still served, for the whole of the term they bought — an hour
// before it runs out as much as on the day they cancelled.
$this->artisan('clupilot:end-due-services')->assertSuccessful();
expect($services['traefik']->routes)->toHaveKey('berger');
$this->travelTo('2026-08-01 08:00:00');
$this->artisan('clupilot:end-due-services')->assertSuccessful();
expect($services['traefik']->routes)->toHaveKey('berger');
// And gone the moment it is genuinely over.
$this->travelTo('2026-08-01 09:01:00');
$this->artisan('clupilot:end-due-services')->assertSuccessful();
expect($services['traefik']->routes)->toBe([])
->and($instance->fresh()->status)->toBe('ended');
});
it('refuses to end an instance whose term has not run out, whoever asks', function () {
['instance' => $instance] = routedInstance([
'status' => 'cancellation_scheduled',
'service_ends_at' => now()->addMonth(),
]);
// The rule lives on the action, not in the command's query — anything that
// ever ends an instance has to ask the same question.
expect(app(EndInstanceService::class)->hasEnded($instance))->toBeFalse()
->and(app(EndInstanceService::class)($instance))->toBeFalse()
->and($instance->fresh()->status)->toBe('cancellation_scheduled');
});