mirror of
https://github.com/archtechx/tenancy.git
synced 2026-06-21 03:04:04 +00:00
Merge branch 'master' into feat/skip-tenants
This commit is contained in:
commit
f524006000
33 changed files with 805 additions and 143 deletions
|
|
@ -103,6 +103,33 @@ test('central helper doesnt change tenancy state when called in central context'
|
|||
expect(tenant())->toBeNull();
|
||||
});
|
||||
|
||||
test('reinitialize method does nothing in the central context', function () {
|
||||
expect(tenancy()->initialized)->toBe(false);
|
||||
expect(fn () => tenancy()->reinitialize())->not()->toThrow(\Throwable::class);
|
||||
expect(tenancy()->initialized)->toBe(false);
|
||||
});
|
||||
|
||||
test('reinitialize method runs bootstrappers again for the current tenant', function () {
|
||||
config(['tenancy.bootstrappers' => [
|
||||
ReinitBootstrapper::class,
|
||||
]]);
|
||||
|
||||
tenancy()->initialize($tenant = Tenant::create(['reinit_bootstrapper_key' => 'foo']));
|
||||
|
||||
expect(tenant()->getKey())->toBe($tenant->getKey());
|
||||
expect(app('tenancy_reinit_bootstrapper_key'))->toBe('foo');
|
||||
|
||||
$tenant->update(['reinit_bootstrapper_key' => 'bar']);
|
||||
|
||||
// Unchanged until we reinitialize...
|
||||
expect(app('tenancy_reinit_bootstrapper_key'))->toBe('foo');
|
||||
|
||||
tenancy()->reinitialize();
|
||||
|
||||
expect(tenant()->getKey())->toBe($tenant->getKey());
|
||||
expect(app('tenancy_reinit_bootstrapper_key'))->toBe('bar');
|
||||
});
|
||||
|
||||
class MyBootstrapper implements TenancyBootstrapper
|
||||
{
|
||||
public function bootstrap(\Stancl\Tenancy\Contracts\Tenant $tenant): void
|
||||
|
|
@ -115,3 +142,16 @@ class MyBootstrapper implements TenancyBootstrapper
|
|||
app()->instance('tenancy_ended', true);
|
||||
}
|
||||
}
|
||||
|
||||
class ReinitBootstrapper implements TenancyBootstrapper
|
||||
{
|
||||
public function bootstrap(\Stancl\Tenancy\Contracts\Tenant $tenant): void
|
||||
{
|
||||
app()->instance('tenancy_reinit_bootstrapper_key', $tenant->getAttribute('reinit_bootstrapper_key'));
|
||||
}
|
||||
|
||||
public function revert(): void
|
||||
{
|
||||
app()->instance('tenancy_reinit_bootstrapper_key', null);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use Stancl\Tenancy\Events\TenancyInitialized;
|
|||
use Stancl\Tenancy\Jobs\CreateStorageSymlinks;
|
||||
use Stancl\Tenancy\Jobs\RemoveStorageSymlinks;
|
||||
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
||||
use Stancl\Tenancy\Listeners\DeleteTenantStorage;
|
||||
use Stancl\Tenancy\Jobs\DeleteTenantStorage;
|
||||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
|
@ -184,21 +184,63 @@ test('create and delete storage symlinks jobs work', function() {
|
|||
$this->assertDirectoryDoesNotExist(public_path("public-$tenantKey"));
|
||||
});
|
||||
|
||||
test('tenant storage can get deleted after the tenant when DeletingTenant listens to DeleteTenantStorage', function() {
|
||||
Event::listen(DeletingTenant::class, DeleteTenantStorage::class);
|
||||
test('tenant storage gets deleted during tenant deletion when the DeletingTenant pipeline contains DeleteTenantStorage', function() {
|
||||
Event::listen(DeletingTenant::class,
|
||||
JobPipeline::make([DeleteTenantStorage::class])->send(function (DeletingTenant $event) {
|
||||
return $event->tenant;
|
||||
})->shouldBeQueued(false)->toListener()
|
||||
);
|
||||
|
||||
$centralStoragePath = storage_path();
|
||||
tenancy()->initialize(Tenant::create());
|
||||
|
||||
// FilesystemTenancyBootstrapper not enabled,
|
||||
// tenant and central storage path is the same,
|
||||
// the storage deletion will be skipped.
|
||||
$tenantStoragePath = storage_path();
|
||||
expect($tenantStoragePath)->toBe($centralStoragePath);
|
||||
expect(File::isDirectory($centralStoragePath))->toBeTrue();
|
||||
tenant()->delete();
|
||||
|
||||
expect(File::isDirectory($centralStoragePath))->toBeTrue();
|
||||
|
||||
config([
|
||||
'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class],
|
||||
'tenancy.filesystem.suffix_storage_path' => false,
|
||||
]);
|
||||
|
||||
tenancy()->initialize(Tenant::create());
|
||||
|
||||
$tenantStoragePath = storage_path();
|
||||
|
||||
// FilesystemTenancyBootstrapper enabled,
|
||||
// but tenant and central storage path is still the same
|
||||
// because suffix_storage_path is false.
|
||||
// The storage deletion will be skipped.
|
||||
expect($tenantStoragePath)->toBe($centralStoragePath);
|
||||
expect(File::isDirectory($centralStoragePath))->toBeTrue();
|
||||
tenant()->delete();
|
||||
|
||||
expect(File::isDirectory($centralStoragePath))->toBeTrue();
|
||||
|
||||
config([
|
||||
'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class],
|
||||
'tenancy.filesystem.suffix_storage_path' => true,
|
||||
]);
|
||||
|
||||
tenancy()->initialize(Tenant::create());
|
||||
$tenantStoragePath = storage_path();
|
||||
|
||||
Storage::fake('test');
|
||||
|
||||
// FilesystemTenancyBootstrapper enabled,
|
||||
// suffix_storage_path enabled, so the two paths are distinct.
|
||||
// Tenant storage will be deleted.
|
||||
expect($tenantStoragePath)->not()->toBe($centralStoragePath);
|
||||
expect(File::isDirectory($tenantStoragePath))->toBeTrue();
|
||||
|
||||
Storage::put('test.txt', 'testing file');
|
||||
|
||||
tenant()->delete();
|
||||
|
||||
expect(File::isDirectory($tenantStoragePath))->toBeFalse();
|
||||
expect(File::isDirectory($centralStoragePath))->toBeTrue();
|
||||
});
|
||||
|
||||
test('the framework/cache directory is created when storage_path is scoped', function (bool $suffixStoragePath) {
|
||||
|
|
@ -256,4 +298,3 @@ test('scoped disks are scoped per tenant', function () {
|
|||
expect(file_get_contents(storage_path() . "/app/public/scoped_disk_prefix/foo.txt"))->toBe('central2');
|
||||
expect(file_get_contents(storage_path() . "/tenant{$tenant->id}/app/public/scoped_disk_prefix/foo.txt"))->toBe('tenant');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -401,3 +401,47 @@ test('the bypass parameter works correctly with temporarySignedRoute', function(
|
|||
->toContain('localhost/foo')
|
||||
->not()->toContain('central='); // Bypass parameter gets removed from the generated URL
|
||||
});
|
||||
|
||||
test('toRoute can automatically prefix the passed route name', function () {
|
||||
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
|
||||
|
||||
Route::get('/central/home', fn () => 'central')->name('home');
|
||||
Route::get('/tenant/home', fn () => 'tenant')->name('tenant.home');
|
||||
|
||||
TenancyUrlGenerator::$prefixRouteNames = true;
|
||||
|
||||
$tenant = Tenant::create();
|
||||
|
||||
tenancy()->initialize($tenant);
|
||||
|
||||
$centralRoute = Route::getRoutes()->getByName('home');
|
||||
|
||||
// url()->toRoute() prefixes the name of the passed route ('home') with the tenant prefix
|
||||
// and generates the URL for the tenant route (as if the 'tenant.home' route was passed to the method)
|
||||
expect(url()->toRoute($centralRoute, [], true))->toBe('http://localhost/tenant/home');
|
||||
|
||||
// Passing the bypass parameter skips the name prefixing, so the method returns the central route URL
|
||||
expect(url()->toRoute($centralRoute, ['central' => true], true))->toBe('http://localhost/central/home');
|
||||
});
|
||||
|
||||
test('toRoute modifies parameters even when the route has no name', function () {
|
||||
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
|
||||
|
||||
TenancyUrlGenerator::$passTenantParameterToRoutes = true;
|
||||
|
||||
$unnamedRoute = Route::get('/unnamed', fn () => 'unnamed');
|
||||
|
||||
$tenant = Tenant::create();
|
||||
|
||||
tenancy()->initialize($tenant);
|
||||
|
||||
// The tenant parameter is added to the URL even for unnamed routes
|
||||
expect(url()->toRoute($unnamedRoute, [], true))
|
||||
->toBe("http://localhost/unnamed?tenant={$tenant->getTenantKey()}");
|
||||
|
||||
// The bypass parameter prevents passing the tenant parameter and is stripped from the URL
|
||||
expect(url()->toRoute($unnamedRoute, ['central' => true], true))
|
||||
->toBe("http://localhost/unnamed")
|
||||
->not()->toContain('tenant=')
|
||||
->not()->toContain('central=');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,17 +2,27 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Stancl\JobPipeline\JobPipeline;
|
||||
use Stancl\Tenancy\Events\TenantCreated;
|
||||
use Stancl\Tenancy\Events\TenantDeleted;
|
||||
use Stancl\Tenancy\Jobs\CreateDatabase;
|
||||
use Stancl\Tenancy\Jobs\DeleteDatabase;
|
||||
use Stancl\Tenancy\Jobs\MigrateDatabase;
|
||||
use Stancl\Tenancy\Jobs\SeedDatabase;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use Illuminate\Foundation\Auth\User as Authenticable;
|
||||
use Stancl\Tenancy\Tests\Etc\TestSeeder;
|
||||
|
||||
beforeEach($cleanup = function () {
|
||||
DeleteDatabase::$ignoreFailures = false;
|
||||
DeleteDatabase::$skipWhenCreateDatabaseIsFalse = true;
|
||||
});
|
||||
|
||||
afterEach($cleanup);
|
||||
|
||||
test('database can be created after tenant creation', function () {
|
||||
config(['tenancy.database.template_tenant_connection' => 'mysql']);
|
||||
|
||||
|
|
@ -82,6 +92,73 @@ test('custom job can be added to the pipeline', function () {
|
|||
});
|
||||
});
|
||||
|
||||
test('database can be deleted after tenant deletion', function () {
|
||||
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
|
||||
return $event->tenant;
|
||||
})->toListener());
|
||||
|
||||
Event::listen(TenantDeleted::class, JobPipeline::make([DeleteDatabase::class])->send(function (TenantDeleted $event) {
|
||||
return $event->tenant;
|
||||
})->toListener());
|
||||
|
||||
$tenant = Tenant::create();
|
||||
$manager = $tenant->database()->manager();
|
||||
|
||||
expect($manager->databaseExists($tenant->database()->getName()))->toBeTrue();
|
||||
|
||||
$tenant->delete();
|
||||
|
||||
expect($manager->databaseExists($tenant->database()->getName()))->toBeFalse();
|
||||
});
|
||||
|
||||
test('database deletion is skipped when create_database is false', function (bool $skipWhenCreateDatabaseIsFalse) {
|
||||
Event::listen(TenantDeleted::class, JobPipeline::make([DeleteDatabase::class])->send(function (TenantDeleted $event) {
|
||||
return $event->tenant;
|
||||
})->toListener());
|
||||
|
||||
// create_database=false means no DB is created (e.g. tenant uses a pre-existing DB)
|
||||
// On deletion, DeleteDatabase should skip rather than attempting DROP DATABASE on a non-existent DB
|
||||
$tenant = Tenant::create(['tenancy_create_database' => false, 'tenancy_db_name' => 'non_existing_db']);
|
||||
|
||||
$manager = $tenant->database()->manager();
|
||||
expect($manager->databaseExists($tenant->database()->getName()))->toBeFalse();
|
||||
|
||||
DeleteDatabase::$skipWhenCreateDatabaseIsFalse = $skipWhenCreateDatabaseIsFalse;
|
||||
|
||||
if ($skipWhenCreateDatabaseIsFalse) {
|
||||
$tenant->delete(); // no exception
|
||||
} else {
|
||||
expect(fn () => $tenant->delete())->toThrow(QueryException::class, "database doesn't exist");
|
||||
}
|
||||
|
||||
expect($manager->databaseExists($tenant->database()->getName()))->toBeFalse();
|
||||
})->with([true, false]);
|
||||
|
||||
test('database deletion failure is ignored when ignoreFailures is true', function (bool $ignoreFailures) {
|
||||
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
|
||||
return $event->tenant;
|
||||
})->toListener());
|
||||
|
||||
Event::listen(TenantDeleted::class, JobPipeline::make([DeleteDatabase::class])->send(function (TenantDeleted $event) {
|
||||
return $event->tenant;
|
||||
})->toListener());
|
||||
|
||||
DeleteDatabase::$ignoreFailures = $ignoreFailures;
|
||||
|
||||
$tenant = Tenant::create();
|
||||
$manager = $tenant->database()->manager();
|
||||
expect($manager->databaseExists($tenant->database()->getName()))->toBeTrue();
|
||||
|
||||
$manager->deleteDatabase($tenant); // manually delete so the job fails
|
||||
expect($manager->databaseExists($tenant->database()->getName()))->toBeFalse();
|
||||
|
||||
if ($ignoreFailures) {
|
||||
$tenant->delete(); // no exception
|
||||
} else {
|
||||
expect(fn () => $tenant->delete())->toThrow(QueryException::class, "database doesn't exist");
|
||||
}
|
||||
})->with([true, false]);
|
||||
|
||||
class User extends Authenticable
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
|
|
|||
|
|
@ -300,7 +300,7 @@ test('using different default route modes works with global domain identificatio
|
|||
$exception = match ($middleware) {
|
||||
InitializeTenancyByDomain::class => TenantCouldNotBeIdentifiedOnDomainException::class,
|
||||
InitializeTenancyBySubdomain::class => NotASubdomainException::class,
|
||||
InitializeTenancyByDomainOrSubdomain::class => NotASubdomainException::class,
|
||||
InitializeTenancyByDomainOrSubdomain::class => TenantCouldNotBeIdentifiedOnDomainException::class,
|
||||
};
|
||||
|
||||
expect(fn () => $this->withoutExceptionHandling()->get('http://localhost/central-route'))->toThrow($exception);
|
||||
|
|
|
|||
|
|
@ -16,10 +16,25 @@ use Stancl\Tenancy\Events\PendingTenantPulled;
|
|||
use Stancl\Tenancy\Events\PullingPendingTenant;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
use Stancl\Tenancy\Events\TenantCreated;
|
||||
use Stancl\JobPipeline\JobPipeline;
|
||||
use Stancl\Tenancy\Jobs\CreateDatabase;
|
||||
use Stancl\Tenancy\Jobs\MigrateDatabase;
|
||||
use Stancl\Tenancy\Jobs\SeedDatabase;
|
||||
use Stancl\Tenancy\Tests\Etc\User;
|
||||
use Stancl\Tenancy\Tests\Etc\TestSeeder;
|
||||
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Events\TenancyInitialized;
|
||||
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
||||
use Stancl\Tenancy\Events\TenancyEnded;
|
||||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||
|
||||
beforeEach($cleanup = function () {
|
||||
Tenant::$extraCustomColumns = [];
|
||||
Tenant::$getPendingAttributesUsing = null;
|
||||
|
||||
MigrateDatabase::$includePending = true;
|
||||
SeedDatabase::$includePending = true;
|
||||
});
|
||||
|
||||
afterEach($cleanup);
|
||||
|
|
@ -111,6 +126,18 @@ test('a new tenant gets created while pulling a pending tenant if the pending po
|
|||
expect(Tenant::withPending()->get()->count())->toBe(1); // All tenants
|
||||
});
|
||||
|
||||
test('withoutPending chained with where clauses returns correct results', function () {
|
||||
$tenant = Tenant::create();
|
||||
$pendingTenant = Tenant::createPending();
|
||||
|
||||
// The query returned the correct tenant
|
||||
expect(Tenant::withoutPending()->where('id', $tenant->id)->first()->id)->toBe($tenant->id);
|
||||
// No tenant with this ID exists, the query returns null
|
||||
expect(Tenant::withoutPending()->where('id', Str::random(8) . 'nonexistent-id')->first())->toBeNull();
|
||||
// withoutPending() correctly excludes the pending tenant from the query
|
||||
expect(Tenant::withoutPending()->where('id', $pendingTenant->id)->first())->toBeNull();
|
||||
});
|
||||
|
||||
test('pending tenants are included in all queries based on the include_in_queries config', function () {
|
||||
Tenant::createPending();
|
||||
|
||||
|
|
@ -142,8 +169,8 @@ test('pending events are dispatched', function () {
|
|||
Event::assertDispatched(PendingTenantPulled::class);
|
||||
});
|
||||
|
||||
test('commands do not run for pending tenants if tenancy.pending.include_in_queries is false and the with pending option does not get passed', function() {
|
||||
config(['tenancy.pending.include_in_queries' => false]);
|
||||
test('commands include tenants based on the include_in_queries config when --with-pending is not passed', function (bool $includeInQueries) {
|
||||
config(['tenancy.pending.include_in_queries' => $includeInQueries]);
|
||||
|
||||
$tenants = collect([
|
||||
Tenant::create(),
|
||||
|
|
@ -152,21 +179,21 @@ test('commands do not run for pending tenants if tenancy.pending.include_in_quer
|
|||
Tenant::createPending(),
|
||||
]);
|
||||
|
||||
pest()->artisan('tenants:migrate --with-pending');
|
||||
$command = pest()->artisan("tenants:run 'bar testing testing@test.test password foo'");
|
||||
|
||||
$artisan = pest()->artisan("tenants:run 'foo foo --b=bar --c=xyz'");
|
||||
$tenants->each(function ($tenant) use ($command, $includeInQueries) {
|
||||
if ($tenant->pending() && ! $includeInQueries) {
|
||||
$command->doesntExpectOutputToContain("Tenant: {$tenant->getTenantKey()}");
|
||||
} else {
|
||||
$command->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}");
|
||||
}
|
||||
});
|
||||
|
||||
$pendingTenants = $tenants->filter->pending();
|
||||
$readyTenants = $tenants->reject->pending();
|
||||
$command->assertSuccessful();
|
||||
})->with([true, false]);
|
||||
|
||||
$pendingTenants->each(fn ($tenant) => $artisan->doesntExpectOutputToContain("Tenant: {$tenant->getTenantKey()}"));
|
||||
$readyTenants->each(fn ($tenant) => $artisan->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}"));
|
||||
|
||||
$artisan->assertExitCode(0);
|
||||
});
|
||||
|
||||
test('commands run for pending tenants too if tenancy.pending.include_in_queries is true', function() {
|
||||
config(['tenancy.pending.include_in_queries' => true]);
|
||||
test('commands include pending tenants when truthy --with-pending is passed', function (bool $includeInQueries) {
|
||||
config(['tenancy.pending.include_in_queries' => $includeInQueries]);
|
||||
|
||||
$tenants = collect([
|
||||
Tenant::create(),
|
||||
|
|
@ -175,17 +202,22 @@ test('commands run for pending tenants too if tenancy.pending.include_in_queries
|
|||
Tenant::createPending(),
|
||||
]);
|
||||
|
||||
pest()->artisan('tenants:migrate --with-pending');
|
||||
foreach ([
|
||||
'--with-pending',
|
||||
'--with-pending=true',
|
||||
'--with-pending=1'
|
||||
] as $option) {
|
||||
$command = pest()->artisan("tenants:run 'bar testing testing@test.test password foo' {$option}");
|
||||
|
||||
$artisan = pest()->artisan("tenants:run 'foo foo --b=bar --c=xyz'");
|
||||
// Pending tenants are included regardless of tenancy.pending.include_in_queries
|
||||
$tenants->each(fn ($tenant) => $command->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}"));
|
||||
|
||||
$tenants->each(fn ($tenant) => $artisan->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}"));
|
||||
$command->assertSuccessful();
|
||||
}
|
||||
})->with([true, false]);
|
||||
|
||||
$artisan->assertExitCode(0);
|
||||
});
|
||||
|
||||
test('commands run for pending tenants too if the with pending option is passed', function() {
|
||||
config(['tenancy.pending.include_in_queries' => false]);
|
||||
test('commands exclude pending tenants when falsy --with-pending is passed', function (bool $includeInQueries) {
|
||||
config(['tenancy.pending.include_in_queries' => $includeInQueries]);
|
||||
|
||||
$tenants = collect([
|
||||
Tenant::create(),
|
||||
|
|
@ -194,14 +226,25 @@ test('commands run for pending tenants too if the with pending option is passed'
|
|||
Tenant::createPending(),
|
||||
]);
|
||||
|
||||
pest()->artisan('tenants:migrate --with-pending');
|
||||
foreach ([
|
||||
'--with-pending=false',
|
||||
'--with-pending=0',
|
||||
'--with-pending=foo' // Invalid values are treated as false
|
||||
] as $option) {
|
||||
$command = pest()->artisan("tenants:run 'bar testing testing@test.test password foo' {$option}");
|
||||
|
||||
$artisan = pest()->artisan("tenants:run 'foo foo --b=bar --c=xyz' --with-pending");
|
||||
$tenants->each(function ($tenant) use ($command) {
|
||||
if ($tenant->pending()) {
|
||||
// Pending tenants are excluded regardless of tenancy.pending.include_in_queries
|
||||
$command->doesntExpectOutputToContain("Tenant: {$tenant->getTenantKey()}");
|
||||
} else {
|
||||
$command->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}");
|
||||
}
|
||||
});
|
||||
|
||||
$tenants->each(fn ($tenant) => $artisan->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}"));
|
||||
|
||||
$artisan->assertExitCode(0);
|
||||
});
|
||||
$command->assertSuccessful();
|
||||
}
|
||||
})->with([true, false]);
|
||||
|
||||
test('pending tenants can have default attributes for non-nullable columns', function (bool $withPendingAttributes) {
|
||||
Schema::table('tenants', function (Blueprint $table) {
|
||||
|
|
@ -224,3 +267,105 @@ test('pending tenants can have default attributes for non-nullable columns', fun
|
|||
else
|
||||
expect($fn)->toThrow(QueryException::class);
|
||||
})->with([true, false]);
|
||||
|
||||
test('pending tenant databases can be migrated using a job unless configured otherwise', function (bool $includeInQueries, ?bool $migrateWithPending) {
|
||||
config([
|
||||
'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class],
|
||||
'tenancy.pending.include_in_queries' => $includeInQueries,
|
||||
]);
|
||||
|
||||
MigrateDatabase::$includePending = $migrateWithPending;
|
||||
|
||||
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
||||
Event::listen(TenantCreated::class, JobPipeline::make([
|
||||
CreateDatabase::class,
|
||||
MigrateDatabase::class,
|
||||
])->send(function (TenantCreated $event) {
|
||||
return $event->tenant;
|
||||
})->toListener());
|
||||
|
||||
$pendingTenant = Tenant::createPending();
|
||||
|
||||
expect(Schema::hasTable('users'))->toBeFalse();
|
||||
|
||||
tenancy()->initialize($pendingTenant);
|
||||
|
||||
// MigrateDatabase includes/excludes pending tenants based on its $includePending property,
|
||||
// regardless of the tenancy.pending.include_in_queries config.
|
||||
expect(Schema::hasTable('users'))->toBe($migrateWithPending ?? $includeInQueries);
|
||||
})->with([
|
||||
'include pending in queries' => [true],
|
||||
'exclude pending from queries' => [false],
|
||||
])->with([
|
||||
'migrate with pending' => [true],
|
||||
'migrate without pending' => [false],
|
||||
'default to config' => [null],
|
||||
]);
|
||||
|
||||
test('pending tenant databases can be seeded using a job unless configured otherwise', function (bool $includeInQueries, ?bool $seedWithPending) {
|
||||
config([
|
||||
'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class],
|
||||
'tenancy.pending.include_in_queries' => $includeInQueries,
|
||||
'tenancy.seeder_parameters.--class' => TestSeeder::class,
|
||||
]);
|
||||
|
||||
MigrateDatabase::$includePending = true;
|
||||
SeedDatabase::$includePending = $seedWithPending;
|
||||
|
||||
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
||||
Event::listen(TenantCreated::class, JobPipeline::make([
|
||||
CreateDatabase::class,
|
||||
MigrateDatabase::class,
|
||||
SeedDatabase::class,
|
||||
])->send(function (TenantCreated $event) {
|
||||
return $event->tenant;
|
||||
})->toListener());
|
||||
|
||||
$pendingTenant = Tenant::createPending();
|
||||
|
||||
tenancy()->initialize($pendingTenant);
|
||||
|
||||
// SeedDatabase includes/excludes pending tenants based on its $includePending property,
|
||||
// regardless of the tenancy.pending.include_in_queries config.
|
||||
expect(User::where('email', 'seeded@user')->exists())->toBe($seedWithPending ?? $includeInQueries);
|
||||
})->with([
|
||||
'include pending in queries' => [true],
|
||||
'exclude pending from queries' => [false],
|
||||
])->with([
|
||||
'seed with pending' => [true],
|
||||
'seed without pending' => [false],
|
||||
'default to config' => [null],
|
||||
]);
|
||||
|
||||
test('jobs that run before tenants get fully created recognize pending tenants', function () {
|
||||
config([
|
||||
'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class],
|
||||
]);
|
||||
|
||||
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
||||
Event::listen(TenantCreated::class, JobPipeline::make([
|
||||
CreateDatabase::class,
|
||||
PendingTenantJob::class,
|
||||
])->send(function (TenantCreated $event) {
|
||||
return $event->tenant;
|
||||
})->toListener());
|
||||
|
||||
Tenant::createPending();
|
||||
|
||||
expect(app('tenant_is_pending'))->toBeTrue();
|
||||
});
|
||||
|
||||
class PendingTenantJob
|
||||
{
|
||||
public function __construct(
|
||||
public Tenant $tenant,
|
||||
) {}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
app()->instance('tenant_is_pending', $this->tenant->pending());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
|||
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
|
||||
use Stancl\Tenancy\Middleware\PreventAccessFromUnwantedDomains;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
||||
// todo@tests write similar low-level tests for the cache bootstrapper? including the database driver in a single-db setup
|
||||
|
|
@ -100,7 +101,7 @@ test('redis sessions are separated using the redis bootstrapper', function (bool
|
|||
expect($redisClient->getOption($redisClient::OPT_PREFIX) === "tenant_{$tenant->id}_")->toBe($bootstrappedEnabled);
|
||||
|
||||
expect(array_filter(Redis::keys('*'), function (string $key) use ($tenant) {
|
||||
return str($key)->startsWith("tenant_{$tenant->id}_laravel_cache_");
|
||||
return str($key)->startsWith(formatLaravelCacheKey(prefix: "tenant_{$tenant->id}_"));
|
||||
}))->toHaveCount($bootstrappedEnabled ? 1 : 0);
|
||||
})->with([true, false]);
|
||||
|
||||
|
|
@ -118,13 +119,13 @@ test('redis sessions are separated using the cache bootstrapper', function (bool
|
|||
Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar');
|
||||
pest()->get("/{$tenant->id}/foo");
|
||||
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === "laravel_cache_tenant_{$tenant->id}_")->toBe($scopeSessions);
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === formatLaravelCacheKey("tenant_{$tenant->id}_"))->toBe($scopeSessions);
|
||||
|
||||
tenancy()->end();
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe('laravel_cache_');
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe(formatLaravelCacheKey());
|
||||
|
||||
expect(array_filter(Redis::keys('*'), function (string $key) use ($tenant) {
|
||||
return str($key)->startsWith("foolaravel_cache_tenant_{$tenant->id}");
|
||||
return str($key)->startsWith(formatLaravelCacheKey(prefix: 'foo', suffix: "tenant_{$tenant->id}"));
|
||||
}))->toHaveCount($scopeSessions ? 1 : 0);
|
||||
})->with([true, false]);
|
||||
|
||||
|
|
@ -148,14 +149,14 @@ test('memcached sessions are separated using the cache bootstrapper', function (
|
|||
Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar');
|
||||
pest()->get("/{$tenant->id}/foo");
|
||||
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === "laravel_cache_tenant_{$tenant->id}_")->toBe($scopeSessions);
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === formatLaravelCacheKey("tenant_{$tenant->id}_"))->toBe($scopeSessions);
|
||||
|
||||
tenancy()->end();
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe('laravel_cache_');
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe(formatLaravelCacheKey());
|
||||
|
||||
sleep(1.1); // 1s+ sleep is necessary for getAllKeys() to work. if this causes race conditions or we want to avoid the delay, we can refactor this to some type of a mock
|
||||
expect(array_filter($allMemcachedKeys(), function (string $key) use ($tenant) {
|
||||
return str($key)->startsWith("laravel_cache_tenant_{$tenant->id}");
|
||||
return str($key)->startsWith(formatLaravelCacheKey("tenant_{$tenant->id}"));
|
||||
}))->toHaveCount($scopeSessions ? 1 : 0);
|
||||
|
||||
Artisan::call('cache:clear memcached');
|
||||
|
|
@ -177,13 +178,13 @@ test('dynamodb sessions are separated using the cache bootstrapper', function (b
|
|||
Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar');
|
||||
pest()->get("/{$tenant->id}/foo");
|
||||
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === "laravel_cache_tenant_{$tenant->id}_")->toBe($scopeSessions);
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === formatLaravelCacheKey("tenant_{$tenant->id}_"))->toBe($scopeSessions);
|
||||
|
||||
tenancy()->end();
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe('laravel_cache_');
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe(formatLaravelCacheKey());
|
||||
|
||||
expect(array_filter($allDynamodbKeys(), function (string $key) use ($tenant) {
|
||||
return str($key)->startsWith("laravel_cache_tenant_{$tenant->id}");
|
||||
return str($key)->startsWith(formatLaravelCacheKey("tenant_{$tenant->id}"));
|
||||
}))->toHaveCount($scopeSessions ? 1 : 0);
|
||||
})->with([true, false]);
|
||||
|
||||
|
|
@ -202,13 +203,13 @@ test('apc sessions are separated using the cache bootstrapper', function (bool $
|
|||
Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar');
|
||||
pest()->get("/{$tenant->id}/foo");
|
||||
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === "laravel_cache_tenant_{$tenant->id}_")->toBe($scopeSessions);
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === formatLaravelCacheKey("tenant_{$tenant->id}_"))->toBe($scopeSessions);
|
||||
|
||||
tenancy()->end();
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe('laravel_cache_');
|
||||
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe(formatLaravelCacheKey());
|
||||
|
||||
expect(array_filter($allApcuKeys(), function (string $key) use ($tenant) {
|
||||
return str($key)->startsWith("laravel_cache_tenant_{$tenant->id}");
|
||||
return str($key)->startsWith(formatLaravelCacheKey("tenant_{$tenant->id}"));
|
||||
}))->toHaveCount($scopeSessions ? 1 : 0);
|
||||
})->with([true, false]);
|
||||
|
||||
|
|
@ -250,3 +251,13 @@ test('database sessions are separated regardless of whether the session bootstra
|
|||
// [false, true], // when the connection IS set, the session bootstrapper becomes necessary
|
||||
[false, false],
|
||||
]);
|
||||
|
||||
function formatLaravelCacheKey(string $suffix = '', string $prefix = ''): string
|
||||
{
|
||||
// todo@release if we drop Laravel 12 support we can just switch to - syntax everywhere
|
||||
if (version_compare(app()->version(), '13.0.0') >= 0) {
|
||||
return $prefix . 'laravel-cache-' . $suffix;
|
||||
} else {
|
||||
return $prefix . 'laravel_cache_' . $suffix;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use Stancl\Tenancy\Database\Concerns\HasDomains;
|
|||
use Stancl\Tenancy\Exceptions\NotASubdomainException;
|
||||
use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain;
|
||||
use Stancl\Tenancy\Database\Models;
|
||||
use Stancl\Tenancy\Resolvers\DomainTenantResolver;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
||||
beforeEach(function () {
|
||||
|
|
@ -108,6 +109,14 @@ test('we cant use a subdomain that doesnt belong to our central domains', functi
|
|||
->get('http://foo.localhost/foo/abc/xyz');
|
||||
});
|
||||
|
||||
test('domain resolver correctly determines if string is a subdomain', function() {
|
||||
config(['tenancy.identification.central_domains' => ['site.com', 'blog.site.com']]);
|
||||
|
||||
expect(DomainTenantResolver::isSubdomain('blog.site.com'))->toBeFalse();
|
||||
expect(DomainTenantResolver::isSubdomain('tenant.site.com'))->toBeTrue();
|
||||
expect(DomainTenantResolver::isSubdomain('tenantsite.com'))->toBeFalse();
|
||||
});
|
||||
|
||||
class SubdomainTenant extends Models\Tenant
|
||||
{
|
||||
use HasDomains;
|
||||
|
|
|
|||
|
|
@ -89,13 +89,14 @@ test('tenant user can be impersonated on a tenant domain', function () {
|
|||
->assertSee('You are logged in as Joe');
|
||||
|
||||
expect(UserImpersonation::isImpersonating())->toBeTrue();
|
||||
expect(session('tenancy_impersonating'))->toBeTrue();
|
||||
expect(session('tenancy_impersonation_guard'))->toBe('web');
|
||||
expect($token->auth_guard)->toBe('web');
|
||||
|
||||
// Leave impersonation
|
||||
UserImpersonation::stopImpersonating();
|
||||
|
||||
expect(UserImpersonation::isImpersonating())->toBeFalse();
|
||||
expect(session('tenancy_impersonating'))->toBeNull();
|
||||
expect(session('tenancy_impersonation_guard'))->toBeNull();
|
||||
|
||||
// Assert can't access the tenant dashboard
|
||||
pest()->get('http://foo.localhost/dashboard')
|
||||
|
|
@ -135,19 +136,113 @@ test('tenant user can be impersonated on a tenant path', function () {
|
|||
->assertSee('You are logged in as Joe');
|
||||
|
||||
expect(UserImpersonation::isImpersonating())->toBeTrue();
|
||||
expect(session('tenancy_impersonating'))->toBeTrue();
|
||||
expect(session('tenancy_impersonation_guard'))->toBe('web');
|
||||
expect($token->auth_guard)->toBe('web');
|
||||
|
||||
// Leave impersonation
|
||||
UserImpersonation::stopImpersonating();
|
||||
|
||||
expect(UserImpersonation::isImpersonating())->toBeFalse();
|
||||
expect(session('tenancy_impersonating'))->toBeNull();
|
||||
expect(session('tenancy_impersonation_guard'))->toBeNull();
|
||||
|
||||
// Assert can't access the tenant dashboard
|
||||
pest()->get('/acme/dashboard')
|
||||
->assertRedirect('/login');
|
||||
});
|
||||
|
||||
test('stopImpersonating can keep the user authenticated', function () {
|
||||
makeLoginRoute();
|
||||
|
||||
Route::middleware(InitializeTenancyByPath::class)->prefix('/{tenant}')->group(getRoutes(false));
|
||||
|
||||
$tenant = Tenant::create([
|
||||
'id' => 'acme',
|
||||
'tenancy_db_name' => 'db' . Str::random(16),
|
||||
]);
|
||||
|
||||
migrateTenants();
|
||||
|
||||
$user = $tenant->run(function () {
|
||||
return ImpersonationUser::create([
|
||||
'name' => 'Joe',
|
||||
'email' => 'joe@local',
|
||||
'password' => bcrypt('secret'),
|
||||
]);
|
||||
});
|
||||
|
||||
// Impersonate the user
|
||||
$token = tenancy()->impersonate($tenant, $user->id, '/acme/dashboard');
|
||||
|
||||
pest()->get('/acme/impersonate/' . $token->token)
|
||||
->assertRedirect('/acme/dashboard');
|
||||
|
||||
expect(UserImpersonation::isImpersonating())->toBeTrue();
|
||||
|
||||
// Stop impersonating without logging out
|
||||
UserImpersonation::stopImpersonating(false);
|
||||
|
||||
// The impersonation session key should be cleared
|
||||
expect(UserImpersonation::isImpersonating())->toBeFalse();
|
||||
expect(session('tenancy_impersonation_guard'))->toBeNull();
|
||||
|
||||
// The user should still be authenticated
|
||||
pest()->get('/acme/dashboard')
|
||||
->assertSuccessful()
|
||||
->assertSee('You are logged in as Joe');
|
||||
});
|
||||
|
||||
test('stopImpersonating logs out the user from the impersonation guard stored in session', function () {
|
||||
Route::middleware(InitializeTenancyByPath::class)->prefix('/{tenant}')->group(getRoutes(false));
|
||||
|
||||
$tenant = Tenant::create([
|
||||
'id' => 'acme',
|
||||
'tenancy_db_name' => 'db' . Str::random(16),
|
||||
]);
|
||||
|
||||
migrateTenants();
|
||||
|
||||
$user = $tenant->run(function () {
|
||||
return ImpersonationUser::create([
|
||||
'name' => 'Joe',
|
||||
'email' => 'joe@local',
|
||||
'password' => bcrypt('secret'),
|
||||
]);
|
||||
});
|
||||
|
||||
// Impersonate the user
|
||||
$token = tenancy()->impersonate($tenant, $user->id, '/acme/dashboard');
|
||||
|
||||
pest()->get('/acme/impersonate/' . $token->token)
|
||||
->assertRedirect('/acme/dashboard');
|
||||
|
||||
expect(session('tenancy_impersonation_guard'))->toBe('web');
|
||||
|
||||
// Impersonation logged in the user using the current guard ('web')
|
||||
expect(auth('web')->check())->toBeTrue();
|
||||
|
||||
config(['auth.guards.test' => [
|
||||
'driver' => 'session',
|
||||
'provider' => 'users',
|
||||
]]);
|
||||
|
||||
// Manually log the user in through the 'test' guard
|
||||
auth('test')->loginUsingId($user->id);
|
||||
|
||||
// Should log the user out from the guard used for impersonation ('web')
|
||||
UserImpersonation::stopImpersonating();
|
||||
|
||||
expect(auth('web')->check())->toBeFalse();
|
||||
expect(auth('test')->check())->toBeTrue();
|
||||
|
||||
expect(UserImpersonation::isImpersonating())->toBeFalse();
|
||||
|
||||
// tenancy_impersonation_guard isn't in the session anymore,
|
||||
// stopImpersonating should throw an exception instead of logging out
|
||||
expect(fn() => UserImpersonation::stopImpersonating())->toThrow(Exception::class);
|
||||
|
||||
expect(auth('test')->check())->toBeTrue();
|
||||
});
|
||||
|
||||
test('tokens have a limited ttl', function () {
|
||||
Route::middleware(InitializeTenancyByDomain::class)->group(getRoutes());
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue