bind(DomainVerifier::class, fn () => new DomainVerifier( fn (string $name) => $records[$name] ?? [], )); } function customerInstance(array $attributes = [], string $plan = 'business'): Instance { // customers.user_id, not users.customer_id — the link runs the other way. $user = User::factory()->create(['email_verified_at' => now()]); $customer = Customer::factory()->create(['user_id' => $user->id, 'email' => $user->email]); $instance = Instance::factory()->create([ 'customer_id' => $customer->id, 'status' => 'active', 'subdomain' => 'berger', ...$attributes, ]); // On business by default, because reaching this page at all now takes a // package that carries an own domain (CustomDomainAccess). These tests are // about proving ownership of a domain, not about who may have one — that // rule has its own suite — so they start from a customer who may. Subscription::factory()->plan($plan)->create([ 'customer_id' => $customer->id, 'instance_id' => $instance->id, ]); return $instance; } it('does not serve a domain nobody has proven', function () { // The hole, stated as a test: a hostname in the column is a request, not // an address. $instance = customerInstance(['custom_domain' => 'cloud.fremde-firma.at', 'domain_token' => 'abc']); expect($instance->domainIsVerified())->toBeFalse() ->and($instance->address('clupilot.com'))->toBe('berger.clupilot.com'); }); it('serves it once the proof is in the DNS', function () { $instance = customerInstance(['custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok123']); withResolver(['_clupilot-challenge.cloud.berger.at' => [['txt' => 'cp-verify=tok123']]]); expect(app(DomainVerifier::class)->proofPresent($instance))->toBeTrue(); $this->artisan('clupilot:verify-domains')->assertSuccessful(); $instance->refresh(); expect($instance->domainIsVerified())->toBeTrue() ->and($instance->address('clupilot.com'))->toBe('cloud.berger.at'); }); it('finds the proof among a domain’s other TXT records', function () { // A name commonly carries several: SPF, other providers' verifications. // DNS returns them in no particular order, so only checking the first // would fail on a domain that is already in use. $instance = customerInstance(['custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok123']); withResolver(['_clupilot-challenge.cloud.berger.at' => [ ['txt' => 'v=spf1 -all'], ['txt' => 'google-site-verification=xyz'], ['txt' => 'cp-verify=tok123'], ]]); expect(app(DomainVerifier::class)->proofPresent($instance))->toBeTrue(); }); it('is not satisfied by somebody else’s token', function () { $instance = customerInstance(['custom_domain' => 'cloud.berger.at', 'domain_token' => 'mine']); withResolver(['_clupilot-challenge.cloud.berger.at' => [['txt' => 'cp-verify=someone-elses']]]); expect(app(DomainVerifier::class)->proofPresent($instance))->toBeFalse(); }); it('withdraws a domain whose proof was taken away — but not on the first miss', function () { // The reason the check repeats at all: put the token in, pass, take it // straight back out. And the reason it does not act immediately: one failed // lookup is a nameserver having a bad minute, and withdrawing takes a // working Nextcloud off its own address. $instance = customerInstance([ 'custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok123', 'domain_verified_at' => now()->subDays(30), ]); withResolver([]); // the record is gone $this->artisan('clupilot:verify-domains'); expect($instance->fresh()->domainIsVerified())->toBeTrue() ->and($instance->fresh()->domain_failures)->toBe(1); $this->artisan('clupilot:verify-domains'); expect($instance->fresh()->domainIsVerified())->toBeTrue(); $this->artisan('clupilot:verify-domains'); expect($instance->fresh()->domainIsVerified())->toBeFalse() // The domain and its token stay on the row: the customer has to see // what is wrong and put the record back, not start over. ->and($instance->fresh()->custom_domain)->toBe('cloud.berger.at') ->and($instance->fresh()->domain_token)->toBe('tok123'); }); it('forgives a miss once the record comes back', function () { $instance = customerInstance([ 'custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok123', 'domain_verified_at' => now()->subDay(), 'domain_failures' => 2, ]); withResolver(['_clupilot-challenge.cloud.berger.at' => [['txt' => 'cp-verify=tok123']]]); $this->artisan('clupilot:verify-domains'); expect($instance->fresh()->domain_failures)->toBe(0) ->and($instance->fresh()->domainIsVerified())->toBeTrue(); }); it('mints a new token when the domain changes', function () { // Otherwise a customer who once verified example.com could claim any other // domain later without touching its DNS: the old record is still sitting // there and only the value is compared. $instance = customerInstance([ 'custom_domain' => 'alt.berger.at', 'domain_token' => 'old-token', 'domain_verified_at' => now(), ]); Livewire::actingAs(User::query()->findOrFail($instance->customer->user_id)) ->test(CustomDomain::class) ->set('domain', 'neu.berger.at') ->call('save') ->assertHasNoErrors(); $instance->refresh(); expect($instance->custom_domain)->toBe('neu.berger.at') ->and($instance->domain_token)->not->toBe('old-token') // And it is not live until the new one is proven. ->and($instance->domainIsVerified())->toBeFalse(); }); it('lets a customer take the domain away again', function () { // It was suggested this should be one-way. It should not: moving an address // is a proxy entry, a certificate and a line in trusted_domains. $instance = customerInstance([ 'custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok', 'domain_verified_at' => now(), ]); Livewire::actingAs(User::query()->findOrFail($instance->customer->user_id)) ->test(CustomDomain::class) ->set('domain', '') ->call('save'); $instance->refresh(); expect($instance->custom_domain)->toBeNull() ->and($instance->domain_verified_at)->toBeNull() ->and($instance->address('clupilot.com'))->toBe('berger.clupilot.com'); }); it('takes a pasted URL and stores a hostname', function () { $instance = customerInstance(); Livewire::actingAs(User::query()->findOrFail($instance->customer->user_id)) ->test(CustomDomain::class) ->set('domain', 'https://Cloud.Berger.at/') ->call('save'); expect($instance->fresh()->custom_domain)->toBe('cloud.berger.at'); }); it('refuses something that is not a hostname', function () { $instance = customerInstance(); Livewire::actingAs(User::query()->findOrFail($instance->customer->user_id)) ->test(CustomDomain::class) ->set('domain', 'nicht mal annähernd') ->call('save') ->assertHasErrors('domain'); expect($instance->fresh()->custom_domain)->toBeNull(); }); it('does not let the entry package reach the page at all', function () { // The guard, at the component rather than on the route: a Livewire action // posts to /livewire/update on its own, so a page-only check would leave // every action here open to a package that may not have a domain. $instance = customerInstance([], 'start'); Livewire::actingAs($instance->customer->user) ->test(CustomDomain::class) ->assertForbidden(); });