1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2025-12-12 09:34: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:
lukinovec 2023-11-08 11:38:26 +01:00 committed by GitHub
parent c34952f328
commit e25e7b7961
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 243 additions and 14 deletions

View file

@ -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": {

View file

@ -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(' / ')}</>";
}
}

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Contracts;
/**
* @property string|null $domain
*/
interface SingleDomainTenant extends Tenant
{
}

View file

@ -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
{

View file

@ -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()
/** @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 */
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');

View file

@ -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([

View file

@ -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'],

View 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');
});
}
}

View 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',
];
}
}

View 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],
],
]);