220 lines
8.5 KiB
PHP
220 lines
8.5 KiB
PHP
<?php
|
||
|
||
use App\Livewire\CustomDomain;
|
||
use App\Models\Customer;
|
||
use App\Models\Instance;
|
||
use App\Models\Subscription;
|
||
use App\Models\User;
|
||
use App\Services\Domains\DomainVerifier;
|
||
use Livewire\Livewire;
|
||
|
||
/**
|
||
* Proving a customer owns the domain they pointed at us.
|
||
*
|
||
* Before this, `custom_domain` was a free-text field and everything downstream
|
||
* believed it: the proxy served it, the certificate was issued for it, and
|
||
* Nextcloud trusted it. Anybody who pointed any hostname at the platform got
|
||
* somebody else's files under their own name.
|
||
*
|
||
* The tests are about the two ways that can come back: a domain being believed
|
||
* without proof, and a proof being believed after it has gone.
|
||
*/
|
||
function withResolver(array $records): void
|
||
{
|
||
// No real DNS: a suite that needs a nameserver fails on a train, and it
|
||
// would be testing the internet rather than this code.
|
||
app()->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();
|
||
});
|