mirror of
https://github.com/archtechx/tenancy.git
synced 2025-12-12 06:44:04 +00:00
Single-domain tenants (#16)
* 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 <phpcsfixer@example.com> Co-authored-by: Samuel Štancl <samuel.stancl@gmail.com>
This commit is contained in:
parent
c34952f328
commit
e25e7b7961
10 changed files with 243 additions and 14 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 "<fg=blue;options=bold>{$domains->pluck('domain')->implode(' / ')}</>";
|
||||
return "<fg=blue;options=bold>{$domains->implode(' / ')}</>";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
12
src/Contracts/SingleDomainTenant.php
Normal file
12
src/Contracts/SingleDomainTenant.php
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy\Contracts;
|
||||
|
||||
/**
|
||||
* @property string|null $domain
|
||||
*/
|
||||
interface SingleDomainTenant extends Tenant
|
||||
{
|
||||
}
|
||||
|
|
@ -66,7 +66,7 @@ abstract class CachedTenantResolver implements TenantResolver
|
|||
*
|
||||
* @return array[]
|
||||
*/
|
||||
abstract public function getArgsForTenant(Tenant $tenant): array;
|
||||
abstract public function getArgsForTenant(Tenant $tenant): array; // todo@v4 make it clear that this is only used for cache *invalidation*
|
||||
|
||||
public static function shouldCache(): bool
|
||||
{
|
||||
|
|
|
|||
|
|
@ -7,30 +7,40 @@ namespace Stancl\Tenancy\Resolvers;
|
|||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Stancl\Tenancy\Contracts\Domain;
|
||||
use Stancl\Tenancy\Contracts\SingleDomainTenant;
|
||||
use Stancl\Tenancy\Contracts\Tenant;
|
||||
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException;
|
||||
|
||||
class DomainTenantResolver extends Contracts\CachedTenantResolver
|
||||
{
|
||||
/** The model representing the domain that the tenant was identified on. */
|
||||
public static Domain $currentDomain; // todo |null?
|
||||
public static Domain|null $currentDomain = null;
|
||||
|
||||
public function resolveWithoutCache(mixed ...$args): Tenant
|
||||
{
|
||||
$domain = $args[0];
|
||||
|
||||
$tenant = config('tenancy.models.tenant')::query()
|
||||
->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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
29
tests/Etc/2023_08_08_000001_add_domain_column.php
Normal file
29
tests/Etc/2023_08_08_000001_add_domain_column.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddDomainColumn extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('tenants', function (Blueprint $table) {
|
||||
$table->string('domain')->unique()->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::table('tenants', function (Blueprint $table) {
|
||||
$table->dropColumn('domain');
|
||||
});
|
||||
}
|
||||
}
|
||||
29
tests/Etc/SingleDomainTenant.php
Normal file
29
tests/Etc/SingleDomainTenant.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy\Tests\Etc;
|
||||
|
||||
use Stancl\Tenancy\Contracts;
|
||||
use Stancl\Tenancy\Events;
|
||||
use Stancl\Tenancy\Database\Concerns;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Stancl\VirtualColumn\VirtualColumn;
|
||||
use Stancl\Tenancy\Database\TenantCollection;
|
||||
use Stancl\Tenancy\Database\Models\Tenant as BaseTenant;
|
||||
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
|
||||
use Stancl\Tenancy\Exceptions\TenancyNotInitializedException;
|
||||
use Stancl\Tenancy\Contracts\SingleDomainTenant as SingleDomainTenantContract;
|
||||
|
||||
class SingleDomainTenant extends BaseTenant implements SingleDomainTenantContract, TenantWithDatabase
|
||||
{
|
||||
use Concerns\ConvertsDomainsToLowercase, Concerns\HasDatabase;
|
||||
|
||||
public function getCustomColumns(): array
|
||||
{
|
||||
return [
|
||||
'id',
|
||||
'domain',
|
||||
];
|
||||
}
|
||||
}
|
||||
133
tests/SingleDomainTenantTest.php
Normal file
133
tests/SingleDomainTenantTest.php
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Stancl\Tenancy\Tests\Etc\SingleDomainTenant;
|
||||
use Stancl\Tenancy\Resolvers\DomainTenantResolver;
|
||||
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain;
|
||||
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException;
|
||||
use Stancl\Tenancy\Middleware\PreventAccessFromUnwantedDomains;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['tenancy.models.tenant' => 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],
|
||||
],
|
||||
]);
|
||||
Loading…
Add table
Add a link
Reference in a new issue