diff --git a/docker-compose.yml b/docker-compose.yml index 70a68019..6cc03e12 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,8 +80,8 @@ services: mssql: image: mcr.microsoft.com/mssql/server:2022-latest environment: - - ACCEPT_EULA=Y - - SA_PASSWORD=P@ssword # must be the same as TENANCY_TEST_SQLSRV_PASSWORD + ACCEPT_EULA: "Y" + SA_PASSWORD: "P@ssword" # must be the same as TENANCY_TEST_SQLSRV_PASSWORD healthcheck: # https://github.com/Microsoft/mssql-docker/issues/133#issuecomment-1995615432 test: timeout 2 bash -c 'cat < /dev/null > /dev/tcp/127.0.0.1/1433' interval: 10s diff --git a/src/Bootstrappers/UrlGeneratorBootstrapper.php b/src/Bootstrappers/UrlGeneratorBootstrapper.php index 3708d636..ba1a6d05 100644 --- a/src/Bootstrappers/UrlGeneratorBootstrapper.php +++ b/src/Bootstrappers/UrlGeneratorBootstrapper.php @@ -16,7 +16,7 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver; /** * Makes the app use TenancyUrlGenerator (instead of Illuminate\Routing\UrlGenerator) which: * - prefixes route names with the tenant route name prefix (PathTenantResolver::tenantRouteNamePrefix() by default) - * - passes the tenant parameter to the link generated by route() and temporarySignedRoute() (PathTenantResolver::tenantParameterName() by default). + * - passes the tenant parameter (PathTenantResolver::tenantParameterName() by default) to the link generated by the affected methods like route() and temporarySignedRoute(). * * Used with path and query string identification. * diff --git a/src/Concerns/HasTenantOptions.php b/src/Concerns/HasTenantOptions.php index c1ea221f..3933c469 100644 --- a/src/Concerns/HasTenantOptions.php +++ b/src/Concerns/HasTenantOptions.php @@ -18,7 +18,7 @@ trait HasTenantOptions { return array_merge([ new InputOption('tenants', null, InputOption::VALUE_IS_ARRAY|InputOption::VALUE_OPTIONAL, 'The tenants to run this command for. Leave empty for all tenants', null), - new InputOption('with-pending', null, InputOption::VALUE_NONE, 'Include pending tenants in query'), // todo@pending should we also offer without-pending? if we add this, mention in docs + new InputOption('with-pending', null, InputOption::VALUE_OPTIONAL, 'Include pending tenants in query if true/1, exclude if false/0. Defaults to the tenancy.pending.include_in_queries config value.'), ], parent::getOptions()); } @@ -43,7 +43,11 @@ trait HasTenantOptions $query->whereIn(tenancy()->model()->getTenantKeyName(), $this->option('tenants')); }) ->when(tenancy()->model()::hasGlobalScope(PendingScope::class), function ($query) { - $query->withPending(config('tenancy.pending.include_in_queries') ?: $this->option('with-pending')); + $includePending = $this->input->hasParameterOption('--with-pending') + ? filter_var($this->option('with-pending') ?? true, FILTER_VALIDATE_BOOLEAN) + : config('tenancy.pending.include_in_queries'); + + $query->withPending($includePending); }); } diff --git a/src/Database/Concerns/HasPending.php b/src/Database/Concerns/HasPending.php index 0a572680..04fcccc1 100644 --- a/src/Database/Concerns/HasPending.php +++ b/src/Database/Concerns/HasPending.php @@ -28,6 +28,18 @@ trait HasPending public static function bootHasPending(): void { static::addGlobalScope(new PendingScope()); + + static::creating(function (self $tenant): void { + if ($tenant->pending()) { + event(new CreatingPendingTenant($tenant)); + } + }); + + static::created(function (self $tenant): void { + if ($tenant->pending()) { + event(new PendingTenantCreated($tenant)); + } + }); } /** Initialize the trait. */ @@ -49,22 +61,11 @@ trait HasPending */ public static function createPending(array $attributes = []): Model&Tenant { - $tenant = null; - - try { - $tenant = static::create(array_merge(static::getPendingAttributes($attributes), $attributes)); - event(new CreatingPendingTenant($tenant)); - } finally { - // Update the pending_since value only after the tenant is created so it's - // not marked as pending until after migrations, seeders, etc are run. - $tenant?->update([ - 'pending_since' => now()->timestamp, - ]); - } - - event(new PendingTenantCreated($tenant)); - - return $tenant; + return static::create(array_merge( + static::getPendingAttributes($attributes), + $attributes, + ['pending_since' => now()->timestamp], + )); } /** diff --git a/src/Database/Concerns/PendingScope.php b/src/Database/Concerns/PendingScope.php index 99a5ef59..e8805d8a 100644 --- a/src/Database/Concerns/PendingScope.php +++ b/src/Database/Concerns/PendingScope.php @@ -14,7 +14,7 @@ class PendingScope implements Scope /** * Apply the scope to a given Eloquent query builder. * - * @param Builder $builder + * @param Builder $builder * * @return void */ diff --git a/src/Database/ParentModelScope.php b/src/Database/ParentModelScope.php index 44f4ac12..9268fea9 100644 --- a/src/Database/ParentModelScope.php +++ b/src/Database/ParentModelScope.php @@ -12,7 +12,7 @@ use Illuminate\Database\Eloquent\Scope; class ParentModelScope implements Scope { /** - * @param Builder $builder + * @param Builder $builder */ public function apply(Builder $builder, Model $model): void { diff --git a/src/Database/TenantScope.php b/src/Database/TenantScope.php index 94ff4572..c6ce5f09 100644 --- a/src/Database/TenantScope.php +++ b/src/Database/TenantScope.php @@ -13,7 +13,7 @@ use Stancl\Tenancy\Tenancy; class TenantScope implements Scope { /** - * @param Builder $builder + * @param Builder $builder */ public function apply(Builder $builder, Model $model) { diff --git a/src/Features/UserImpersonation.php b/src/Features/UserImpersonation.php index d286b8ba..be2b01fd 100644 --- a/src/Features/UserImpersonation.php +++ b/src/Features/UserImpersonation.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Features; +use Exception; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Auth; @@ -61,9 +62,9 @@ class UserImpersonation implements Feature Auth::guard($token->auth_guard)->loginUsingId($token->user_id, $token->remember); - $token->delete(); + session()->put('tenancy_impersonation_guard', $token->auth_guard); - session()->put('tenancy_impersonating', true); + $token->delete(); return redirect($token->redirect_url); } @@ -76,16 +77,30 @@ class UserImpersonation implements Feature public static function isImpersonating(): bool { - return session()->has('tenancy_impersonating'); + return session()->has('tenancy_impersonation_guard'); } /** - * Logout from the current domain and forget impersonation session. + * Stop user impersonation by forgetting the impersonation session. + * + * When $logout is true, the user will also be logged out + * from the impersonation guard stored in the session. + * + * Throws an exception if impersonation is not active + * (= the impersonation guard is not in the session). */ - public static function stopImpersonating(): void + public static function stopImpersonating(bool $logout = true): void { - auth()->logout(); + if (! static::isImpersonating()) { + throw new Exception('Not currently impersonating any user.'); + } - session()->forget('tenancy_impersonating'); + if ($logout) { + $guard = session()->get('tenancy_impersonation_guard'); + + auth($guard)->logout(); + } + + session()->forget('tenancy_impersonation_guard'); } } diff --git a/src/Jobs/DeleteDatabase.php b/src/Jobs/DeleteDatabase.php index b59a1c05..ad022fda 100644 --- a/src/Jobs/DeleteDatabase.php +++ b/src/Jobs/DeleteDatabase.php @@ -22,12 +22,34 @@ class DeleteDatabase implements ShouldQueue protected TenantWithDatabase&Model $tenant, ) {} + /** Skip database deletion if the create_database internal attribute is false. */ + public static bool $skipWhenCreateDatabaseIsFalse = true; + + /** Ignore exceptions thrown during database deletion and continue execution. */ + public static bool $ignoreFailures = false; + public function handle(): void { + if (static::$skipWhenCreateDatabaseIsFalse && $this->tenant->getInternal('create_database') === false) { + // If database creation was skipped, we presume deletion should also be skipped. + // To avoid this skip, either unset the `create_database` attribute (or make it true), or + // set the $skipWhenCreateDatabaseIsFalse static property to false. + return; + } + event(new DeletingDatabase($this->tenant)); - $this->tenant->database()->manager()->deleteDatabase($this->tenant); + $deleted = false; - event(new DatabaseDeleted($this->tenant)); + try { + $this->tenant->database()->manager()->deleteDatabase($this->tenant); + $deleted = true; + } catch (\Throwable $e) { + if (! static::$ignoreFailures) { + throw $e; + } + } + + if ($deleted) event(new DatabaseDeleted($this->tenant)); } } diff --git a/src/Jobs/MigrateDatabase.php b/src/Jobs/MigrateDatabase.php index 424dacc9..b090b70a 100644 --- a/src/Jobs/MigrateDatabase.php +++ b/src/Jobs/MigrateDatabase.php @@ -17,6 +17,16 @@ class MigrateDatabase implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + /** + * Should pending tenants be included while migrating, + * regardless of the tenancy.pending.include_in_queries config value. + * + * If false, pending tenants will be specifically excluded. + * + * If null, default to tenancy.pending.include_in_queries config. + */ + public static ?bool $includePending = true; + public function __construct( protected TenantWithDatabase&Model $tenant, ) {} @@ -25,6 +35,7 @@ class MigrateDatabase implements ShouldQueue { Artisan::call('tenants:migrate', [ '--tenants' => [$this->tenant->getTenantKey()], + '--with-pending' => static::$includePending ?? config('tenancy.pending.include_in_queries'), ]); } } diff --git a/src/Jobs/SeedDatabase.php b/src/Jobs/SeedDatabase.php index 9958695e..85058b5d 100644 --- a/src/Jobs/SeedDatabase.php +++ b/src/Jobs/SeedDatabase.php @@ -17,6 +17,16 @@ class SeedDatabase implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + /** + * Should pending tenants be included while seeding, + * regardless of the tenancy.pending.include_in_queries config value. + * + * If false, pending tenants will be specifically excluded. + * + * If null, default to tenancy.pending.include_in_queries config. + */ + public static ?bool $includePending = true; + public function __construct( protected TenantWithDatabase&Model $tenant, ) {} @@ -25,6 +35,7 @@ class SeedDatabase implements ShouldQueue { Artisan::call('tenants:seed', [ '--tenants' => [$this->tenant->getTenantKey()], + '--with-pending' => static::$includePending ?? config('tenancy.pending.include_in_queries'), ]); } } diff --git a/src/Middleware/PreventAccessFromUnwantedDomains.php b/src/Middleware/PreventAccessFromUnwantedDomains.php index cdfa3b2c..7f628583 100644 --- a/src/Middleware/PreventAccessFromUnwantedDomains.php +++ b/src/Middleware/PreventAccessFromUnwantedDomains.php @@ -31,10 +31,12 @@ class PreventAccessFromUnwantedDomains { $route = tenancy()->getRoute($request); - if ($this->shouldBeSkipped($route) || tenancy()->routeIsUniversal($route)) { + if ($this->shouldBeSkipped($route)) { return $next($request); } + // If the route is universal, neither of these checks will pass and the logic will + // fall through to the $next($request) call at the end. if ($this->accessingTenantRouteFromCentralDomain($request, $route) || $this->accessingCentralRouteFromTenantDomain($request, $route)) { $abortRequest = static::$abortRequest ?? function () { abort(404); diff --git a/src/Overrides/TenancyUrlGenerator.php b/src/Overrides/TenancyUrlGenerator.php index f7ed9a84..bcf1bd3f 100644 --- a/src/Overrides/TenancyUrlGenerator.php +++ b/src/Overrides/TenancyUrlGenerator.php @@ -22,16 +22,18 @@ use Stancl\Tenancy\Resolvers\RequestDataTenantResolver; * - Automatically passing ['tenant' => ...] to each route() call -- if TenancyUrlGenerator::$passTenantParameterToRoutes is enabled * This is a more universal solution since it supports both path identification and query parameter identification. * - * - Prepends route names passed to route() and URL::temporarySignedRoute() - * with `tenant.` (or the configured prefix) if $prefixRouteNames is enabled. + * - Prepends route names with the tenant route name prefix ('tenant.' by default, + * configurable at tenant_route_name_prefix under PathTenantResolver) if $prefixRouteNames is enabled. * This is primarily useful when using route cloning with path identification. * - * To bypass this behavior on any single route() call, pass the $bypassParameter as true (['central' => true] by default). + * Affected methods: route(), toRoute(), temporarySignedRoute(), signedRoute() (the last two via the route() override). + * + * To bypass this behavior on any single affected method call, pass the $bypassParameter as true (['central' => true] by default). */ class TenancyUrlGenerator extends UrlGenerator { /** - * Parameter which works as a flag for bypassing the behavior modification of route() and temporarySignedRoute(). + * Parameter which works as a flag for bypassing the behavior modification of the affected methods. * * For example, in tenant context: * Route::get('/', ...)->name('home'); @@ -44,12 +46,12 @@ class TenancyUrlGenerator extends UrlGenerator * Note: UrlGeneratorBootstrapper::$addTenantParameterToDefaults is not affected by this, though * it doesn't matter since it doesn't pass any extra parameters when not needed. * - * @see UrlGeneratorBootstrapper + * @see Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper */ public static string $bypassParameter = 'central'; /** - * Should route names passed to route() or temporarySignedRoute() + * Should route names passed to the affected methods * get prefixed with the tenant route name prefix. * * This is useful when using e.g. path identification with third-party packages @@ -59,12 +61,12 @@ class TenancyUrlGenerator extends UrlGenerator public static bool $prefixRouteNames = false; /** - * Should the tenant parameter be passed to route() or temporarySignedRoute() calls. + * Should the tenant parameter be passed to the affected methods. * * This is useful with path or query parameter identification. The former can be handled * more elegantly using UrlGeneratorBootstrapper::$addTenantParameterToDefaults. * - * @see UrlGeneratorBootstrapper + * @see Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper */ public static bool $passTenantParameterToRoutes = false; @@ -105,8 +107,18 @@ class TenancyUrlGenerator extends UrlGenerator public static bool $passQueryParameter = true; /** - * Override the route() method so that the route name gets prefixed - * and the tenant parameter gets added when in tenant context. + * Override the route() method to prefix the route name before $this->routes->getByName($name) is called + * in the parent route() call. + * + * This is necessary because $this->routes->getByName($name) is called to retrieve the route + * before passing it to toRoute(). If only the prefixed route (e.g. 'tenant.foo') is registered + * and the original ('foo') isn't, route() would throw a RouteNotFoundException. + * So route() has to be overridden to prefix the passed route name, even though toRoute() is overridden already. + * + * Only the name is taken from prepareRouteInputs() here — parameter handling + * (adding tenant parameter, removing bypass parameter) is delegated to toRoute(). + * + * Affects temporarySignedRoute() and signedRoute() as well since they call route() under the hood. */ public function route($name, $parameters = [], $absolute = true) { @@ -114,32 +126,28 @@ class TenancyUrlGenerator extends UrlGenerator throw new InvalidArgumentException('Attribute [name] expects a string backed enum.'); } - [$name, $parameters] = $this->prepareRouteInputs($name, Arr::wrap($parameters)); // @phpstan-ignore argument.type + [$name] = $this->prepareRouteInputs(Arr::wrap($parameters), $name); // @phpstan-ignore argument.type return parent::route($name, $parameters, $absolute); } /** - * Override the temporarySignedRoute() method so that the route name gets prefixed - * and the tenant parameter gets added when in tenant context. + * Override the toRoute() to prefix the route name + * and add the tenant parameter when in tenant context. + * + * Also affects route(). Even though route() is overridden separately, it delegates parameter handling to toRoute(). */ - public function temporarySignedRoute($name, $expiration, $parameters = [], $absolute = true) + public function toRoute($route, $parameters, $absolute) { - if ($name instanceof BackedEnum && ! is_string($name = $name->value)) { - throw new InvalidArgumentException('Attribute [name] expects a string backed enum.'); + $name = $route->getName(); + + [$prefixedName, $parameters] = $this->prepareRouteInputs(Arr::wrap($parameters), $name); + + if ($name && $prefixedName !== $name && $tenantRoute = $this->routes->getByName($prefixedName)) { + $route = $tenantRoute; } - $wrappedParameters = Arr::wrap($parameters); - - [$name, $parameters] = $this->prepareRouteInputs($name, $wrappedParameters); // @phpstan-ignore argument.type - - if (isset($wrappedParameters[static::$bypassParameter])) { - // If the bypass parameter was passed, we need to add it back to the parameters after prepareRouteInputs() removes it, - // so that the underlying route() call in parent::temporarySignedRoute() can bypass the behavior modification as well. - $parameters[static::$bypassParameter] = $wrappedParameters[static::$bypassParameter]; - } - - return parent::temporarySignedRoute($name, $expiration, $parameters, $absolute); + return parent::toRoute($route, $parameters, $absolute); } /** @@ -155,16 +163,19 @@ class TenancyUrlGenerator extends UrlGenerator } /** - * Takes a route name and an array of parameters to return the prefixed route name + * Takes an array of parameters and a route name to return the prefixed route name * and the route parameters with the tenant parameter added. * * To skip these modifications, pass the bypass parameter in route parameters. * Before returning the modified route inputs, the bypass parameter is removed from the parameters. */ - protected function prepareRouteInputs(string $name, array $parameters): array + protected function prepareRouteInputs(array $parameters, string|null $name): array { if (! $this->routeBehaviorModificationBypassed($parameters)) { - $name = $this->routeNameOverride($name) ?? $this->prefixRouteName($name); + if (! is_null($name)) { + $name = $this->routeNameOverride($name) ?? $this->prefixRouteName($name); + } + $parameters = $this->addTenantParameter($parameters); } diff --git a/src/Resolvers/DomainTenantResolver.php b/src/Resolvers/DomainTenantResolver.php index 9535cdf2..59ebb81f 100644 --- a/src/Resolvers/DomainTenantResolver.php +++ b/src/Resolvers/DomainTenantResolver.php @@ -7,6 +7,7 @@ namespace Stancl\Tenancy\Resolvers; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Support\Arr; use Illuminate\Support\Str; use Stancl\Tenancy\Contracts\Domain; use Stancl\Tenancy\Contracts\SingleDomainTenant; @@ -58,7 +59,19 @@ class DomainTenantResolver extends Contracts\CachedTenantResolver public static function isSubdomain(string $domain): bool { - return Str::endsWith($domain, config('tenancy.identification.central_domains')); + $centralDomains = Arr::wrap(config('tenancy.identification.central_domains')); + + if (in_array($domain, $centralDomains, true)) { + return false; + } + + foreach ($centralDomains as $centralDomain) { + if (Str::endsWith($domain, '.' . $centralDomain)) { + return true; + } + } + + return false; } public function resolved(Tenant $tenant, mixed ...$args): void diff --git a/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php b/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php index f089207a..18664b06 100644 --- a/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php +++ b/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php @@ -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='); +}); diff --git a/tests/DatabasePreparationTest.php b/tests/DatabasePreparationTest.php index 1f3a4f09..ccf7eb2e 100644 --- a/tests/DatabasePreparationTest.php +++ b/tests/DatabasePreparationTest.php @@ -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 = []; diff --git a/tests/EarlyIdentificationTest.php b/tests/EarlyIdentificationTest.php index e6c08d26..4521b613 100644 --- a/tests/EarlyIdentificationTest.php +++ b/tests/EarlyIdentificationTest.php @@ -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); diff --git a/tests/PendingTenantsTest.php b/tests/PendingTenantsTest.php index 433b85fb..b04f8bc4 100644 --- a/tests/PendingTenantsTest.php +++ b/tests/PendingTenantsTest.php @@ -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); @@ -154,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(), @@ -164,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(), @@ -187,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(), @@ -206,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) { @@ -236,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()); + } +} diff --git a/tests/SubdomainTest.php b/tests/SubdomainTest.php index a7cc58ae..62e002f2 100644 --- a/tests/SubdomainTest.php +++ b/tests/SubdomainTest.php @@ -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; diff --git a/tests/TenantUserImpersonationTest.php b/tests/TenantUserImpersonationTest.php index ea679357..120ce826 100644 --- a/tests/TenantUserImpersonationTest.php +++ b/tests/TenantUserImpersonationTest.php @@ -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());