235 lines
10 KiB
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');
|
|
});
|