Deploy Ladill Merchant / deploy (push) Successful in 29s
Customers can connect their own domain to a merchant page (storefront/event): add a domain, point an A record (apex + www) to the app server, click Verify — DNS is checked, then Ladill Domains' central SSL service issues + installs the Let's Encrypt cert and calls back to flip it live. The custom domain then serves the mapped page (host resolution on /). Feature-gated: only active when a Domains SSL API key is set, so this deploy is inert until wired. - custom_domains table + CustomDomain model - CustomDomainService (DNS verify, request cert), DomainsSslClient, DnsResolver - settings UI panel, signed SSL callback receiver, host resolution on / - feature tests (DNS verify/fail, signed callback, ownership) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
125 lines
4.3 KiB
PHP
125 lines
4.3 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\CustomDomain;
|
|
use App\Models\QrCode;
|
|
use App\Models\User;
|
|
use App\Services\CustomDomain\CustomDomainService;
|
|
use App\Services\CustomDomain\DnsResolver;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Tests\TestCase;
|
|
|
|
class CustomDomainTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private string $serverIp = '161.97.138.149';
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
config([
|
|
'customdomain.enabled' => true,
|
|
'customdomain.server_ip' => $this->serverIp,
|
|
'customdomain.ssl_api_url' => 'https://domains.ladill.com/api',
|
|
'customdomain.ssl_api_key' => 'merchant-ssl-key',
|
|
'customdomain.callback_secret' => 'shared-secret',
|
|
]);
|
|
}
|
|
|
|
private function storefront(?User $owner = null): QrCode
|
|
{
|
|
$owner ??= User::factory()->create();
|
|
|
|
return QrCode::create([
|
|
'user_id' => $owner->id,
|
|
'short_code' => 'sc'.uniqid(),
|
|
'type' => QrCode::TYPE_SHOP,
|
|
'label' => 'My Shop',
|
|
'is_active' => true,
|
|
]);
|
|
}
|
|
|
|
private function fakeDns(array $ips): void
|
|
{
|
|
$this->app->bind(DnsResolver::class, fn () => new class($ips) extends DnsResolver {
|
|
public function __construct(private array $ips) {}
|
|
public function aRecords(string $host): array { return $this->ips; }
|
|
});
|
|
}
|
|
|
|
public function test_verify_requests_certificate_when_dns_points_to_app(): void
|
|
{
|
|
Http::fake(['*/ssl/certificates' => Http::response(['status' => 'pending'], 202)]);
|
|
$this->fakeDns([$this->serverIp]);
|
|
|
|
$store = $this->storefront();
|
|
$service = app(CustomDomainService::class);
|
|
$domain = $service->attach($store, 'brand.com', true);
|
|
|
|
[$ok] = $service->verifyAndProvision($domain->fresh());
|
|
|
|
$this->assertTrue($ok);
|
|
$this->assertNotNull($domain->fresh()->dns_verified_at);
|
|
Http::assertSent(fn ($r) => str_contains($r->url(), '/ssl/certificates')
|
|
&& $r['host'] === 'brand.com' && $r['target'] === 'app');
|
|
}
|
|
|
|
public function test_verify_fails_when_dns_not_pointing(): void
|
|
{
|
|
$this->fakeDns(['1.2.3.4']);
|
|
$store = $this->storefront();
|
|
$service = app(CustomDomainService::class);
|
|
$domain = $service->attach($store, 'brand.com', true);
|
|
|
|
[$ok, $msg] = $service->verifyAndProvision($domain->fresh());
|
|
|
|
$this->assertFalse($ok);
|
|
$this->assertStringContainsString('DNS', $msg);
|
|
$this->assertNull($domain->fresh()->dns_verified_at);
|
|
}
|
|
|
|
public function test_signed_callback_marks_domain_live_and_resolves(): void
|
|
{
|
|
$store = $this->storefront();
|
|
$domain = app(CustomDomainService::class)->attach($store, 'brand.com', true);
|
|
|
|
$payload = json_encode(['event' => 'ssl.active', 'data' => [
|
|
'host' => 'brand.com', 'status' => 'active', 'expires_at' => now()->addDays(89)->toIso8601String(),
|
|
]], JSON_UNESCAPED_SLASHES);
|
|
$sig = hash_hmac('sha256', $payload, 'shared-secret');
|
|
|
|
$this->call('POST', '/api/ssl-callback', [], [], [], [
|
|
'HTTP_X-Ladill-Signature' => $sig,
|
|
'CONTENT_TYPE' => 'application/json',
|
|
], $payload)->assertOk();
|
|
|
|
$domain->refresh();
|
|
$this->assertTrue($domain->isLive());
|
|
$resolved = app(CustomDomainService::class)->resolveStorefront('www.brand.com');
|
|
$this->assertSame($store->id, $resolved?->id);
|
|
}
|
|
|
|
public function test_callback_rejects_bad_signature(): void
|
|
{
|
|
app(CustomDomainService::class)->attach($this->storefront(), 'brand.com', true);
|
|
$payload = json_encode(['data' => ['host' => 'brand.com', 'status' => 'active']]);
|
|
|
|
$this->call('POST', '/api/ssl-callback', [], [], [], [
|
|
'HTTP_X-Ladill-Signature' => 'wrong', 'CONTENT_TYPE' => 'application/json',
|
|
], $payload)->assertStatus(401);
|
|
}
|
|
|
|
public function test_store_requires_storefront_ownership(): void
|
|
{
|
|
$store = $this->storefront();
|
|
$intruder = User::factory()->create();
|
|
|
|
$this->actingAs($intruder)
|
|
->post(route('merchant.custom-domain.store', $store), ['host' => 'brand.com'])
|
|
->assertForbidden();
|
|
}
|
|
}
|