From e25e7b7961498290679d83331632e52fc9183c0b Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 8 Nov 2023 11:38:26 +0100 Subject: [PATCH] Single-domain tenants (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add SingleDomainTenant * Add logic for single domain tenants * Test that the single domain approach works (wip) * Fix code style (php-cs-fixer) * Simplify SubdomainTest tests * Add single domain tenant conditions to DomainTenantResolver * Test single domain tenants in resolver test * Fix test name typo * Improve runUsingBothDomainApproaches() * Delete extra tenancy()->end() * Test early identification with both domain approaches * Test that things work with both domain approaches in the rest of the tests * Fix falsely passing test * Fix PHPStan errors * Change SingleDomainTenant to a contract, add SingleDomainTenant test model * Fix TenantList domainsCLI() * Improve setCurrentDomain() check * Fix code style (php-cs-fixer) * Add annotation * Revert getCustomColumns() change * Add comments * Use the domain returned by the closure in runUsingBoth..() * Delete `migrate` from test * Improve test names * Use variable instead of repeating the same string multiple times * Update comment * Add comment * Clean up PreventAccess test * Don't assign domain to a single-use variable * Update comments * Uncomment datasets * Add todo * Fix user impersonation test * Don't specify tenant key when creating tenant in runUsingBoth..() * Improve universal route test * Improve `runUsingBothDomainApproaches()` * Add tests specific to single domain tenants * Get rid of the runUsingBothDomainApproaches method * Add test file specific for the single domain tenant feature * Rename test * Make getCustomColumns() function static * Positiopn datasets differently * Fix early id test * Add prevent MW to route MW in test * Fix single domain tenant tests * Delete SingleDomainTenantTest (CI testing) * Add the test file back * TUrn APP_DEBUG on temporarily * Turn debug off * Try creating tenant with non-unique domain (CI testing) * dd duplicate tenant records * Revert testing change * Make SingleDomainTenant not extend base tenant (VirtualColumn issues) * Fix early id test * add todo * Use dev-master stancl/virtualcolumn * Make SingleDomainTenant extend the tenant base model * remove todo * Clean up EarlyIdentificationTest changes * Finish test file cleanup * Fix test * improve test --------- Co-authored-by: PHP CS Fixer Co-authored-by: Samuel Štancl --- composer.json | 2 +- src/Commands/TenantList.php | 7 +- src/Contracts/SingleDomainTenant.php | 12 ++ .../Contracts/CachedTenantResolver.php | 2 +- src/Resolvers/DomainTenantResolver.php | 34 ++++- tests/DeleteDomainsJobTest.php | 2 +- tests/EarlyIdentificationTest.php | 7 +- .../2023_08_08_000001_add_domain_column.php | 29 ++++ tests/Etc/SingleDomainTenant.php | 29 ++++ tests/SingleDomainTenantTest.php | 133 ++++++++++++++++++ 10 files changed, 243 insertions(+), 14 deletions(-) create mode 100644 src/Contracts/SingleDomainTenant.php create mode 100644 tests/Etc/2023_08_08_000001_add_domain_column.php create mode 100644 tests/Etc/SingleDomainTenant.php create mode 100644 tests/SingleDomainTenantTest.php diff --git a/composer.json b/composer.json index cadd9ee5..ea282c97 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ "spatie/ignition": "^1.4", "ramsey/uuid": "^4.7.3", "stancl/jobpipeline": "2.0.0-rc1", - "stancl/virtualcolumn": "^1.3.1", + "stancl/virtualcolumn": "dev-master", "spatie/invade": "^1.1" }, "require-dev": { diff --git a/src/Commands/TenantList.php b/src/Commands/TenantList.php index c008ba59..dcbede84 100644 --- a/src/Commands/TenantList.php +++ b/src/Commands/TenantList.php @@ -7,6 +7,7 @@ namespace Stancl\Tenancy\Commands; use Illuminate\Console\Command; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Stancl\Tenancy\Contracts\SingleDomainTenant; use Stancl\Tenancy\Contracts\Tenant; class TenantList extends Command @@ -23,7 +24,9 @@ class TenantList extends Command foreach ($tenants as $tenant) { /** @var Model&Tenant $tenant */ - $this->components->twoColumnDetail($this->tenantCLI($tenant), $this->domainsCLI($tenant->domains)); + $domains = $tenant instanceof SingleDomainTenant ? collect([$tenant->domain]) : $tenant->domains?->pluck('domain'); + + $this->components->twoColumnDetail($this->tenantCLI($tenant), $this->domainsCLI($domains)); } $this->newLine(); @@ -44,6 +47,6 @@ class TenantList extends Command return null; } - return "{$domains->pluck('domain')->implode(' / ')}"; + return "{$domains->implode(' / ')}"; } } diff --git a/src/Contracts/SingleDomainTenant.php b/src/Contracts/SingleDomainTenant.php new file mode 100644 index 00000000..d0e84e3d --- /dev/null +++ b/src/Contracts/SingleDomainTenant.php @@ -0,0 +1,12 @@ +whereHas('domains', fn (Builder $query) => $query->where('domain', $domain)) - ->with('domains') - ->first(); + /** @var Tenant&Model $tenantModel */ + $tenantModel = config('tenancy.models.tenant')::make(); + if ($tenantModel instanceof SingleDomainTenant) { + $tenant = $tenantModel->newQuery() + ->firstWhere('domain', $domain); + } else { + $tenant = $tenantModel->newQuery() + ->whereHas('domains', fn (Builder $query) => $query->where('domain', $domain)) + ->with('domains') + ->first(); + } + + /** @var (Tenant&Model)|null $tenant */ if ($tenant) { $this->setCurrentDomain($tenant, $domain); return $tenant; } - throw new TenantCouldNotBeIdentifiedOnDomainException($args[0]); + throw new TenantCouldNotBeIdentifiedOnDomainException($domain); } public function resolved(Tenant $tenant, mixed ...$args): void @@ -41,11 +51,21 @@ class DomainTenantResolver extends Contracts\CachedTenantResolver protected function setCurrentDomain(Tenant $tenant, string $domain): void { /** @var Tenant&Model $tenant */ - static::$currentDomain = $tenant->domains->where('domain', $domain)->first(); + if (! $tenant instanceof SingleDomainTenant) { + static::$currentDomain = $tenant->domains->where('domain', $domain)->first(); + } } public function getArgsForTenant(Tenant $tenant): array { + if ($tenant instanceof SingleDomainTenant) { + /** @var SingleDomainTenant&Model $tenant */ + return [ + [$tenant->getOriginal('domain')], // Previous domain + [$tenant->domain], // Current domain + ]; + } + /** @var Tenant&Model $tenant */ $tenant->unsetRelation('domains'); diff --git a/tests/DeleteDomainsJobTest.php b/tests/DeleteDomainsJobTest.php index e109384e..ffdf37b8 100644 --- a/tests/DeleteDomainsJobTest.php +++ b/tests/DeleteDomainsJobTest.php @@ -9,7 +9,7 @@ beforeEach(function () { config(['tenancy.models.tenant' => DatabaseAndDomainTenant::class]); }); -test('job delete domains successfully', function () { +test('job deletes domains successfully', function () { $tenant = DatabaseAndDomainTenant::create(); $tenant->domains()->create([ diff --git a/tests/EarlyIdentificationTest.php b/tests/EarlyIdentificationTest.php index 58bee05f..6dc0aafb 100644 --- a/tests/EarlyIdentificationTest.php +++ b/tests/EarlyIdentificationTest.php @@ -181,7 +181,6 @@ test('early identification works with request data identification', function (st ]); test('early identification works with domain identification', function (string $middleware, string $domain, bool $useKernelIdentification, RouteMode $defaultRouteMode) { - config(['tenancy.tenant_model' => Tenant::class]); config(['tenancy.default_route_mode' => $defaultRouteMode]); if ($useKernelIdentification) { @@ -209,6 +208,10 @@ test('early identification works with domain identification', function (string $ $routeThatShouldReceiveMiddleware->middleware($defaultToTenantRoutes ? 'central' : 'tenant'); } elseif (! $defaultToTenantRoutes) { $tenantRoute->middleware('tenant'); + } else { + // Route-level identification + defaulting to tenant routes + // We still have to apply the tenant middleware to the routes, so they aren't really tenant by default + $tenantRoute->middleware([$middleware, PreventAccessFromUnwantedDomains::class]); } $tenant = Tenant::create(); @@ -234,7 +237,7 @@ test('early identification works with domain identification', function (string $ } // Expect tenancy is initialized (or not) for the right tenant at the tenant route - expect($response->getContent())->toBe('token:' . (tenant()?->getTenantKey() ?? 'central')); + expect($response->getContent())->toBe('token:' . tenant()->getTenantKey()); })->with([ 'domain identification' => ['middleware' => InitializeTenancyByDomain::class, 'domain' => 'foo.test'], 'subdomain identification' => ['middleware' => InitializeTenancyBySubdomain::class, 'domain' => 'foo'], diff --git a/tests/Etc/2023_08_08_000001_add_domain_column.php b/tests/Etc/2023_08_08_000001_add_domain_column.php new file mode 100644 index 00000000..2644e59a --- /dev/null +++ b/tests/Etc/2023_08_08_000001_add_domain_column.php @@ -0,0 +1,29 @@ +string('domain')->unique()->nullable(); + }); + } + + public function down() + { + Schema::table('tenants', function (Blueprint $table) { + $table->dropColumn('domain'); + }); + } +} diff --git a/tests/Etc/SingleDomainTenant.php b/tests/Etc/SingleDomainTenant.php new file mode 100644 index 00000000..b30324a4 --- /dev/null +++ b/tests/Etc/SingleDomainTenant.php @@ -0,0 +1,29 @@ + SingleDomainTenant::class]); + + pest()->artisan('migrate', [ + '--path' => __DIR__ . '/Etc/2023_08_08_000001_add_domain_column.php', + '--realpath' => true, + ])->assertExitCode(0); +}); + +test('tenant can be resolved by its domain using the cached resolver', function () { + $tenant = SingleDomainTenant::create(['domain' => 'acme']); + $tenant2 = SingleDomainTenant::create(['domain' => 'bar.domain.test']); + + expect($tenant->is(app(DomainTenantResolver::class)->resolve($tenant->domain)))->toBeTrue(); + expect($tenant->is(app(DomainTenantResolver::class)->resolve($tenant2->domain)))->toBeFalse(); + + expect($tenant2->is(app(DomainTenantResolver::class)->resolve($tenant2->domain)))->toBeTrue(); + expect($tenant2->is(app(DomainTenantResolver::class)->resolve($tenant->domain)))->toBeFalse(); +}); + +test('cache is invalidated when single domain tenant is updated', function () { + DB::enableQueryLog(); + + config([ + 'tenancy.models.tenant' => SingleDomainTenant::class, + 'tenancy.identification.resolvers.' . DomainTenantResolver::class . '.cache' => true + ]); + + $tenant = SingleDomainTenant::create(['domain' => $subdomain = 'acme']); + + expect($tenant->is(app(DomainTenantResolver::class)->resolve($subdomain)))->toBeTrue(); + DB::flushQueryLog(); + expect($tenant->is(app(DomainTenantResolver::class)->resolve($subdomain)))->toBeTrue(); + expect(DB::getQueryLog())->toBeEmpty(); // empty + + $tenant->update(['foo' => 'bar']); + + DB::flushQueryLog(); + expect($tenant->is(app(DomainTenantResolver::class)->resolve($subdomain)))->toBeTrue(); + pest()->assertNotEmpty(DB::getQueryLog()); // not empty +}); + +test('cache is invalidated when a single domain tenants domain is updated', function () { + DB::enableQueryLog(); + + config(['tenancy.identification.resolvers.' . DomainTenantResolver::class . '.cache' => true]); + + $tenant = SingleDomainTenant::create(['domain' => 'acme']); + + expect($tenant->is(app(DomainTenantResolver::class)->resolve('acme')))->toBeTrue(); + DB::flushQueryLog(); + expect($tenant->is(app(DomainTenantResolver::class)->resolve('acme')))->toBeTrue(); + pest()->assertEmpty(DB::getQueryLog()); // Empty – tenant retrieved from cache + + $tenant->update(['domain' => 'bar']); + + DB::flushQueryLog(); + expect(fn () => $tenant->is(app(DomainTenantResolver::class)->resolve('acme')))->toThrow(TenantCouldNotBeIdentifiedOnDomainException::class); + pest()->assertNotEmpty(DB::getQueryLog()); // resolving old subdomain (not in cache anymore) + + DB::flushQueryLog(); + expect($tenant->is(app(DomainTenantResolver::class)->resolve('bar')))->toBeTrue(); + pest()->assertNotEmpty(DB::getQueryLog()); // resolving using new subdomain for the first time + + DB::flushQueryLog(); + expect($tenant->is(app(DomainTenantResolver::class)->resolve('bar')))->toBeTrue(); + pest()->assertEmpty(DB::getQueryLog()); // resolving using new subdomain for the second time + + $tenant->update(['domain' => 'baz']); + + DB::flushQueryLog(); + expect(fn () => $tenant->is(app(DomainTenantResolver::class)->resolve('acme')))->toThrow(TenantCouldNotBeIdentifiedOnDomainException::class); + pest()->assertNotEmpty(DB::getQueryLog()); // resolving using first old subdomain - no cache + failed + + DB::flushQueryLog(); + expect(fn () => $tenant->is(app(DomainTenantResolver::class)->resolve('bar')))->toThrow(TenantCouldNotBeIdentifiedOnDomainException::class); + pest()->assertNotEmpty(DB::getQueryLog()); // resolving using second old subdomain - no cache + failed + + DB::flushQueryLog(); + expect($tenant->is(app(DomainTenantResolver::class)->resolve('baz')))->toBeTrue(); + pest()->assertNotEmpty(DB::getQueryLog()); // resolving using current subdomain for the first time + + DB::flushQueryLog(); + expect($tenant->is(app(DomainTenantResolver::class)->resolve('baz')))->toBeTrue(); + pest()->assertEmpty(DB::getQueryLog()); // resolving using current subdomain for the second time +}); + +test('tenant has to have a unique domain', function() { + SingleDomainTenant::create(['domain' => 'bar']); + + expect(fn () => SingleDomainTenant::create(['domain' => 'bar']))->toThrow(UniqueConstraintViolationException::class); +}); + +test('single domain tenant can be identified by domain or subdomain', function (string $domain, array $identificationMiddleware) { + $tenant = SingleDomainTenant::create(['domain' => $domain]); + + Route::get('/foo/{a}/{b}', function ($a, $b) { + return "$a + $b"; + })->middleware($identificationMiddleware); + + if ($domain === 'acme') { + $domain .= '.localhost'; + } + + pest() + ->get("http://{$domain}/foo/abc/xyz") + ->assertSee('abc + xyz'); + + expect(tenant('id'))->toBe($tenant->id); +})->with([ + [ + 'domain' => 'acme.localhost', + 'identification middleware' => [PreventAccessFromUnwantedDomains::class, InitializeTenancyByDomain::class], + ], + [ + 'subdomain' => 'acme', + 'identification middleware' => [PreventAccessFromUnwantedDomains::class, InitializeTenancyBySubdomain::class], + ], +]);