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'); });