CluPilotCloud/tests/Feature/CustomDomainTest.php

197 lines
7.5 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
use App\Livewire\CustomDomain;
use App\Models\Customer;
use App\Models\Instance;
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 = []): 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]);
return Instance::factory()->create([
'customer_id' => $customer->id,
'status' => 'active',
'subdomain' => 'berger',
...$attributes,
]);
}
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 domains 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 elses 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();
});