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 () { ['services' => $services, 'instance' => $instance] = routedInstance(); $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)->not->toBeNull(); // Still theirs, still served, for the whole of the term they bought. $this->artisan('clupilot:end-due-services')->assertSuccessful(); expect($services['traefik']->routes)->toHaveKey('berger'); // And gone the moment it is genuinely over. $this->travelTo($instance->service_ends_at->copy()->addMinute()); $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'); });