diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef40e072..91699f08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: include: - - laravel: "^11.0" + - laravel: "^12.0" php: "8.4" steps: diff --git a/.github/workflows/queue.yml b/.github/workflows/queue.yml index 0f3ec82e..cb3937e0 100644 --- a/.github/workflows/queue.yml +++ b/.github/workflows/queue.yml @@ -10,11 +10,19 @@ jobs: steps: - name: Prepare composer version constraint prefix run: | - BRANCH=${GITHUB_REF#refs/heads/} - if [[ $BRANCH =~ ^[0-9] ]]; then - echo "VERSION_PREFIX=${BRANCH}-dev" >> $GITHUB_ENV + if [[ $GITHUB_REF == refs/tags/* ]]; then + # For refs like "refs/tags/v3.9.0", remove "refs/tags/v" prefix to get just "3.9.0" + VERSION=${GITHUB_REF#refs/tags/v} + echo "VERSION_PREFIX=${VERSION}" >> $GITHUB_ENV else - echo "VERSION_PREFIX=dev-${BRANCH}" >> $GITHUB_ENV + BRANCH=${GITHUB_REF#refs/heads/} + if [[ $BRANCH =~ ^[0-9]\.x$ ]]; then + # Branches starting with %d.x need to use -dev suffix + echo "VERSION_PREFIX=${BRANCH}-dev" >> $GITHUB_ENV + else + # All other branches use dev-${branch} prefix + echo "VERSION_PREFIX=dev-${BRANCH}" >> $GITHUB_ENV + fi fi - name: Clone test suite @@ -25,3 +33,5 @@ jobs: cd tenancy-queue-tester TENANCY_VERSION=${VERSION_PREFIX}#${GITHUB_SHA} ./setup.sh ./test.sh + ./alternative_config.sh + PERSISTENT=1 ./test.sh diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..bb6dedac --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,129 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +### Testing +- `composer test` - Run tests without coverage using Docker +- `./test tests/TestFile.php` - Run an entire test file +- `./t 'test name'` - Run a specific test +- You can append `-v` to get a full stack trace if a test fails due to an exception + +### Code Quality +- `composer phpstan` - Run PHPStan static analysis (level 8) +- `composer cs` - Fix code style using PHP CS Fixer + +### Docker Development +- `composer docker-up` - Start Docker environment +- `composer docker-down` - Stop Docker environment +- `composer docker-restart` - Restart Docker environment + +## Architecture Overview + +**Tenancy for Laravel** is a multi-tenancy package that automatically handles tenant isolation without requiring changes to application code. + +### Core Components + +**Central Classes:** +- `Tenancy` - Main orchestrator class managing tenant context and lifecycle +- `TenancyServiceProvider` (NOT the stub) - Registers services, commands, and bootstrappers +- `Tenant` (model) - Represents individual tenants with domains and databases +- `Domain` (model) - Maps domains/subdomains to tenants + +**Tenant Identification:** +- **Resolvers** (`src/Resolvers/`) - Identify tenants by domain, path, or request data - this data comes from middleware +- **Middleware** (`src/Middleware/`) - Middleware that calls resolvers and tries to initialize tenancy based on information from a request +- **Cached resolvers** - Cached wrapper around resolvers to avoid querying the central database + +**Tenancy Bootstrappers (`src/Bootstrappers/`):** +- `DatabaseTenancyBootstrapper` - Switches database connections +- `CacheTenancyBootstrapper` - Isolates cache by tenant +- `FilesystemTenancyBootstrapper` - Manages tenant-specific storage +- `QueueTenancyBootstrapper` - Ensures queued jobs run in correct tenant context +- `RedisTenancyBootstrapper` - Prefixes Redis keys by tenant + +**Database Management:** +- **DatabaseManager** - Creates/deletes tenant databases and users +- **TenantDatabaseManagers** - Database-specific implementations (MySQL, PostgreSQL, SQLite, SQL Server) +- **Row Level Security (RLS)** - PostgreSQL-based tenant isolation using policies + +**Advanced Features:** +- **Resource Syncing** - Sync central models to tenant databases +- **User Impersonation** - Admin access to tenant contexts +- **Cross-domain redirects** - Handle multi-domain tenant setups +- **Telescope integration** - Tag entries by tenant + +### Key Patterns + +**Tenant Context Management:** +```php +tenancy()->initialize($tenant); // Switch to tenant +tenancy()->run($tenant, $callback); // Atomic tenant execution +tenancy()->runForMultiple($tenants, $callback); // Batch operations +tenancy()->central($callback); // Run in central context +``` + +**Tenant Identification Flow:** +1. Middleware identifies tenant from request (domain/subdomain/path) +2. Resolver fetches tenant model from identification data +3. Tenancy initializes and bootstrappers configure tenant context +4. Application runs with tenant-specific database/cache/storage + +**Route Middleware Groups:** +All of these work as flags, i.e. middleware groups that are empty arrays with a purely semantic use. +- `tenant` - Routes requiring tenant context +- `central` - Routes for central/admin functionality +- `universal` - Routes working in both contexts +- `clone` - Tells route cloning logic to clone the route + +### Early Identification + +**Early identification** ensures tenancy is initialized before controller instantiation, which is critical for certain scenarios. + +**When needed:** +- Controllers using constructor dependency injection +- Integration with packages that inject dependencies in constructors + +**The Problem:** +Laravel executes controller constructors and route model binding before route-level middleware runs, causing services to use central context instead of tenant context. + +**Solutions:** +1. **Avoid Constructor Injection** - Use method injection instead +2. **Laravel's Native Solution** - Use controllers that implement `HasMiddleware` interface +3. **Kernel Identification** - Add middleware to HTTP Kernel's global stack: + +```php +// In HttpKernel.php +protected $middleware = [ + \Stancl\Tenancy\Middleware\InitializeTenancyByDomain::class, + // other middleware... +]; +``` + +Note you also need to flag the route with the `'tenant'` middleware if default route mode (set in config) isn't set to TENANT. + +**Benefits:** +- Constructor dependency injection receives tenant-aware services +- Seamless integration with existing Laravel applications + +### Testing Environment + +Tests use Docker with MySQL/PostgreSQL/Redis. The `./test` script runs Pest tests inside containers with proper database isolation. + +`./t 'test name'` is equivalent to `./test --filter 'test name'` + +**Key test patterns:** +- Database preparation and cleanup between tests +- Multi-database scenarios (central + tenant databases) +- Middleware and identification testing +- Resource syncing validation + +### Configuration + +Central config in `config/tenancy.php` controls: +- Tenant/domain model classes +- Database connection settings +- Enabled bootstrappers and features +- Identification middleware and resolvers +- Cache and storage prefixes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 498534f7..7d256f42 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,10 +24,12 @@ To fix this, simply delete the database memory by shutting down containers and s Same thing for `SQLSTATE[HY000]: General error: 1615 Prepared statement needs to be re-prepared`. -### Docker on M1 +### Docker on Apple Silicon Run `composer docker-m1` to symlink `docker-compose-m1.override.yml` to `docker-compose.override.yml`. This will reconfigure a few services in the docker compose config to be compatible with M1. +2025 note: By now only MSSQL doesn't have good M1 support. The override also started being a bit problematic, having issues with starts, often requiring multiple starts. This often makes the original image in docker-compose more stable, even if it's amd64-only. With Rosetta enabled, you should be able to use it without issues. + ### Coverage reports To run tests and generate coverage reports, use `composer test-full`. diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index 3d53529e..d9cfaef9 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -53,6 +53,7 @@ class TenancyServiceProvider extends ServiceProvider Events\DeletingTenant::class => [ JobPipeline::make([ Jobs\DeleteDomains::class, + // Jobs\RemoveStorageSymlinks::class, ])->send(function (Events\DeletingTenant $event) { return $event->tenant; })->shouldBeQueued(false), @@ -62,7 +63,6 @@ class TenancyServiceProvider extends ServiceProvider Events\TenantDeleted::class => [ JobPipeline::make([ Jobs\DeleteDatabase::class, - // Jobs\RemoveStorageSymlinks::class, ])->send(function (Events\TenantDeleted $event) { return $event->tenant; })->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production. @@ -145,24 +145,22 @@ class TenancyServiceProvider extends ServiceProvider */ protected function overrideUrlInTenantContext(): void { - /** - * Import your tenant model! - * - * \Stancl\Tenancy\Bootstrappers\RootUrlBootstrapper::$rootUrlOverride = function (Tenant $tenant, string $originalRootUrl) { - * $tenantDomain = $tenant instanceof \Stancl\Tenancy\Contracts\SingleDomainTenant - * ? $tenant->domain - * : $tenant->domains->first()->domain; - * - * $scheme = str($originalRootUrl)->before('://'); - * - * // If you're using domain identification: - * return $scheme . '://' . $tenantDomain . '/'; - * - * // If you're using subdomain identification: - * $originalDomain = str($originalRootUrl)->after($scheme . '://'); - * return $scheme . '://' . $tenantDomain . '.' . $originalDomain . '/'; - * }; - */ + // \Stancl\Tenancy\Bootstrappers\RootUrlBootstrapper::$rootUrlOverride = function (Tenant $tenant, string $originalRootUrl) { + // $tenantDomain = $tenant instanceof \Stancl\Tenancy\Contracts\SingleDomainTenant + // ? $tenant->domain + // : $tenant->domains->first()->domain; + // + // $scheme = str($originalRootUrl)->before('://'); + // + // if (str_contains($tenantDomain, '.')) { + // // Domain identification + // return $scheme . '://' . $tenantDomain . '/'; + // } else { + // // Subdomain identification + // $originalDomain = str($originalRootUrl)->after($scheme . '://')->before('/'); + // return $scheme . '://' . $tenantDomain . '.' . $originalDomain . '/'; + // } + // }; } public function register() @@ -178,32 +176,17 @@ class TenancyServiceProvider extends ServiceProvider $this->makeTenancyMiddlewareHighestPriority(); $this->overrideUrlInTenantContext(); - /** - * Include soft deleted resources in synced resource queries. - * - * ResourceSyncing\Listeners\UpdateOrCreateSyncedResource::$scopeGetModelQuery = function (Builder $query) { - * if ($query->hasMacro('withTrashed')) { - * $query->withTrashed(); - * } - * }; - */ + // // Include soft deleted resources in synced resource queries. + // ResourceSyncing\Listeners\UpdateOrCreateSyncedResource::$scopeGetModelQuery = function (Builder $query) { + // if ($query->hasMacro('withTrashed')) { + // $query->withTrashed(); + // } + // }; - /** - * To make Livewire v3 work with Tenancy, make the update route universal. - * - * Livewire::setUpdateRoute(function ($handle) { - * return RouteFacade::post('/livewire/update', $handle)->middleware(['web', 'universal']); - * }); - */ - - // if (InitializeTenancyByRequestData::inGlobalStack()) { - // FortifyRouteBootstrapper::$fortifyHome = 'dashboard'; - // TenancyUrlGenerator::$prefixRouteNames = false; - // } - - if (tenancy()->globalStackHasMiddleware(config('tenancy.identification.path_identification_middleware'))) { - TenancyUrlGenerator::$prefixRouteNames = true; - } + // // To make Livewire v3 work with Tenancy, make the update route universal. + // Livewire::setUpdateRoute(function ($handle) { + // return RouteFacade::post('/livewire/update', $handle)->middleware(['web', 'universal', \Stancl\Tenancy\Tenancy::defaultMiddleware()]); + // }); } protected function bootEvents() @@ -228,10 +211,7 @@ class TenancyServiceProvider extends ServiceProvider ->group(base_path('routes/tenant.php')); } - // Delete this condition when using route-level path identification - if (tenancy()->globalStackHasMiddleware(config('tenancy.identification.path_identification_middleware'))) { - $this->cloneRoutes(); - } + // $this->cloneRoutes(); }); } @@ -245,16 +225,13 @@ class TenancyServiceProvider extends ServiceProvider /** @var CloneRoutesAsTenant $cloneRoutes */ $cloneRoutes = $this->app->make(CloneRoutesAsTenant::class); - /** - * You can provide a closure for cloning a specific route, e.g.: - * $cloneRoutes->cloneUsing('welcome', function () { - * RouteFacade::get('/tenant-welcome', fn () => 'Current tenant: ' . tenant()->getTenantKey()) - * ->middleware(['universal', InitializeTenancyByPath::class]) - * ->name('tenant.welcome'); - * }); - * - * To see the default behavior of cloning the universal routes, check out the cloneRoute() method in CloneRoutesAsTenant. - */ + // // You can provide a closure for cloning a specific route, e.g.: + // $cloneRoutes->cloneUsing('welcome', function () { + // RouteFacade::get('/tenant-welcome', fn () => 'Current tenant: ' . tenant()->getTenantKey()) + // ->middleware(['universal', InitializeTenancyByPath::class]) + // ->name('tenant.welcome'); + // }); + // // To see the default behavior of cloning the universal routes, check out the cloneRoute() method in CloneRoutesAsTenant. $cloneRoutes->handle(); } diff --git a/assets/config.php b/assets/config.php index 3a521a6c..73becdee 100644 --- a/assets/config.php +++ b/assets/config.php @@ -15,6 +15,7 @@ return [ 'models' => [ 'tenant' => Stancl\Tenancy\Database\Models\Tenant::class, 'domain' => Stancl\Tenancy\Database\Models\Domain::class, + 'impersonation_token' => Stancl\Tenancy\Database\Models\ImpersonationToken::class, /** * Name of the column used to relate models to tenants. @@ -33,6 +34,7 @@ return [ * * @see \Stancl\Tenancy\UniqueIdentifierGenerators\UUIDGenerator * @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomHexGenerator + * @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomIntGenerator * @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomStringGenerator */ 'id_generator' => UniqueIdentifierGenerators\UUIDGenerator::class, @@ -90,7 +92,7 @@ return [ /** * Identification middleware tenancy recognizes as path identification middleware. * - * This is used during determining whether whether a path identification is used + * This is used for determining if a path identification middleware is used * during operations specific to path identification, e.g. forgetting the tenant parameter in ForgetTenantParameter. * * If you're using a custom path identification middleware, add it here. @@ -117,6 +119,7 @@ return [ Resolvers\PathTenantResolver::class => [ 'tenant_parameter_name' => 'tenant', 'tenant_model_column' => null, // null = tenant key + 'tenant_route_name_prefix' => 'tenant.', 'allowed_extra_model_columns' => [], // used with binding route fields 'cache' => false, @@ -124,13 +127,18 @@ return [ 'cache_store' => null, // null = default ], Resolvers\RequestDataTenantResolver::class => [ + // Set any of these to null to disable that method of identification + 'header' => 'X-Tenant', + 'cookie' => 'tenant', + 'query_parameter' => 'tenant', + + 'tenant_model_column' => null, // null = tenant key + 'cache' => false, 'cache_ttl' => 3600, // seconds 'cache_store' => null, // null = default ], ], - - // todo@docs update integration guides to use Stancl\Tenancy::defaultMiddleware() ], /** @@ -214,7 +222,14 @@ return [ // 'pgsql' => Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLSchemaManager::class, // Also permission controlled ], - // todo@docblock + /* + * Drop tenant databases when `php artisan migrate:fresh` is used. + * You may want to use this locally since deleting tenants only + * deletes their databases when they're deleted individually, not + * when the records are mass deleted from the database. + * + * Note: This overrides the default MigrateFresh command. + */ 'drop_tenant_databases_on_migrate_fresh' => false, ], @@ -319,7 +334,6 @@ return [ */ 'url_override' => [ // Note that the local disk you add must exist in the tenancy.filesystem.root_override config - // todo@v4 Rename url_override to something that describes the config key better 'public' => 'public-%tenant%', ], @@ -355,7 +369,7 @@ return [ * leave asset() helper tenancy disabled and explicitly use tenant_asset() calls in places * where you want to use tenant-specific assets (product images, avatars, etc). */ - 'asset_helper_tenancy' => false, // todo@rename asset_helper_override? + 'asset_helper_override' => false, ], /** diff --git a/composer.json b/composer.json index a63e828f..e3a7faf4 100644 --- a/composer.json +++ b/composer.json @@ -18,26 +18,24 @@ "require": { "php": "^8.4", "ext-json": "*", - "illuminate/support": "^10.1|^11.3", + "illuminate/support": "^12.0", "laravel/tinker": "^2.0", "facade/ignition-contracts": "^1.0.2", "spatie/ignition": "^1.4", "ramsey/uuid": "^4.7.3", - "stancl/jobpipeline": "2.0.0-rc2", - "stancl/virtualcolumn": "dev-master", - "spatie/invade": "^1.1", + "stancl/jobpipeline": "2.0.0-rc5", + "stancl/virtualcolumn": "^1.5.0", + "spatie/invade": "*", "laravel/prompts": "0.*" }, "require-dev": { - "laravel/framework": "^10.1|^11.3", - "orchestra/testbench": "^8.0|^9.0", + "laravel/framework": "^12.0", + "orchestra/testbench": "^10.0", "league/flysystem-aws-s3-v3": "^3.12.2", "doctrine/dbal": "^3.6.0", "spatie/valuestore": "^1.2.5", - "pestphp/pest": "^2.0", - "larastan/larastan": "^3.0", - "spatie/invade": "^1.1", - "aws/aws-sdk-php-laravel": "~3.0" + "pestphp/pest": "^3.0", + "larastan/larastan": "^3.0" }, "autoload": { "psr-4": { diff --git a/docker-compose-m1.override.yml b/docker-compose-m1.override.yml index e74781de..64b07d03 100644 --- a/docker-compose-m1.override.yml +++ b/docker-compose-m1.override.yml @@ -1,9 +1,3 @@ services: - mysql: - # platform: linux/amd64 # either one works - image: arm64v8/mysql - mysql2: - # platform: linux/amd64 # either one works - image: arm64v8/mysql mssql: image: mcr.microsoft.com/azure-sql-edge diff --git a/docker-compose.yml b/docker-compose.yml index a4857cbf..9d5eb6c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,7 +33,7 @@ services: stdin_open: true tty: true mysql: - image: mysql:5.7 + image: mysql:8 environment: MYSQL_ROOT_PASSWORD: password MYSQL_DATABASE: main @@ -46,7 +46,7 @@ services: tmpfs: - /var/lib/mysql mysql2: - image: mysql:5.7 + image: mysql:8 environment: MYSQL_ROOT_PASSWORD: password MYSQL_DATABASE: main @@ -72,12 +72,12 @@ services: tmpfs: - /var/lib/postgresql/data mssql: - image: mcr.microsoft.com/mssql/server:2019-latest + image: mcr.microsoft.com/mssql/server:2022-latest environment: - ACCEPT_EULA=Y - SA_PASSWORD=P@ssword # todo reuse env from above healthcheck: # https://github.com/Microsoft/mssql-docker/issues/133#issuecomment-1995615432 - test: timeout 1 bash -c 'cat < /dev/null > /dev/tcp/127.0.0.1/1433' + test: timeout 2 bash -c 'cat < /dev/null > /dev/tcp/127.0.0.1/1433' interval: 10s timeout: 10s retries: 10 diff --git a/phpstan.neon b/phpstan.neon index 984f5d2c..2c6e3d69 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,6 +1,5 @@ includes: - ./vendor/larastan/larastan/extension.neon - - ./vendor/spatie/invade/phpstan-extension.neon parameters: paths: @@ -16,6 +15,12 @@ parameters: ignoreErrors: - identifier: trait.unused - identifier: missingType.iterableValue + - + message: '#Spatie\\Invade\\Invader#' + identifier: method.notFound + - + message: '#Spatie\\Invade\\Invader#' + identifier: property.notFound - '#FFI#' - '#Return type(.*?) of method Stancl\\Tenancy\\Database\\Models\\Tenant\:\:newCollection\(\) should be compatible with return type#' - '#Method Stancl\\Tenancy\\Database\\Models\\Tenant\:\:newCollection\(\) should return#' diff --git a/src/Bootstrappers/FilesystemTenancyBootstrapper.php b/src/Bootstrappers/FilesystemTenancyBootstrapper.php index ab7dc856..d5088c5c 100644 --- a/src/Bootstrappers/FilesystemTenancyBootstrapper.php +++ b/src/Bootstrappers/FilesystemTenancyBootstrapper.php @@ -92,7 +92,7 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper protected function assetHelper(string|false $suffix): void { - if (! $this->app['config']['tenancy.filesystem.asset_helper_tenancy']) { + if (! $this->app['config']['tenancy.filesystem.asset_helper_override']) { return; } diff --git a/src/Bootstrappers/Integrations/FortifyRouteBootstrapper.php b/src/Bootstrappers/Integrations/FortifyRouteBootstrapper.php index 2c5712ee..fb371d6a 100644 --- a/src/Bootstrappers/Integrations/FortifyRouteBootstrapper.php +++ b/src/Bootstrappers/Integrations/FortifyRouteBootstrapper.php @@ -7,54 +7,63 @@ namespace Stancl\Tenancy\Bootstrappers\Integrations; use Illuminate\Config\Repository; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; -use Stancl\Tenancy\Enums\Context; use Stancl\Tenancy\Resolvers\PathTenantResolver; +use Stancl\Tenancy\Resolvers\RequestDataTenantResolver; /** - * Allows customizing Fortify action redirects - * so that they can also redirect to tenant routes instead of just the central routes. + * Allows customizing Fortify action redirects so that they can also redirect + * to tenant routes instead of just the central routes. * - * Works with path and query string identification. + * This should be used with path/query string identification OR when using Fortify + * universally, including with domains. + * + * When using domain identification, there's no need to pass the tenant parameter, + * you only want to customize the routes being used, so you can set $passTenantParameter + * to false. */ class FortifyRouteBootstrapper implements TenancyBootstrapper { /** - * Make Fortify actions redirect to custom routes. + * Fortify redirects that should be used in tenant context. * - * For each route redirect, specify the intended route context (central or tenant). - * Based on the provided context, we pass the tenant parameter to the route (or not). - * The tenant parameter is only passed to the route when you specify its context as tenant. - * - * The route redirects should be in the following format: - * - * 'fortify_action' => [ - * 'route_name' => 'tenant.route', - * 'context' => Context::TENANT, - * ] - * - * For example: - * - * FortifyRouteBootstrapper::$fortifyRedirectMap = [ - * // On logout, redirect the user to the "bye" route in the central app - * 'logout' => [ - * 'route_name' => 'bye', - * 'context' => Context::CENTRAL, - * ], - * - * // On login, redirect the user to the "welcome" route in the tenant app - * 'login' => [ - * 'route_name' => 'welcome', - * 'context' => Context::TENANT, - * ], - * ]; + * Syntax: ['redirect_name' => 'tenant_route_name'] */ public static array $fortifyRedirectMap = []; + /** + * Should the tenant parameter be passed to fortify routes in the tenant context. + * + * This should be enabled with path/query string identification and disabled with domain identification. + * + * You may also disable this when using path/query string identification if passing the tenant parameter + * is handled in another way (TenancyUrlGenerator::$passTenantParameter for both, + * UrlGeneratorBootstrapper:$addTenantParameterToDefaults for path identification). + */ + public static bool $passTenantParameter = false; + /** * Tenant route that serves as Fortify's home (e.g. a tenant dashboard route). * This route will always receive the tenant parameter. */ - public static string $fortifyHome = 'tenant.dashboard'; + public static string|null $fortifyHome = 'tenant.dashboard'; + + /** + * Follow the query_parameter config instead of the tenant_parameter_name (path identification) config. + * + * This only has an effect when: + * - $passTenantParameter is enabled, and + * - the tenant_parameter_name config for the path resolver differs from the query_parameter config for the request data resolver. + * + * In such a case, instead of adding ['tenant' => '...'] to the route parameters (or whatever your tenant_parameter_name is if not 'tenant'), + * the query_parameter will be passed instead, e.g. ['team' => '...'] if your query_parameter config is 'team'. + * + * This is enabled by default because typically you will not need $passTenantParameter with path identification. + * UrlGeneratorBootstrapper::$addTenantParameterToDefaults is recommended instead when using path identification. + * + * On the other hand, when using request data identification (specifically query string) you WILL need to + * pass the parameter therefore you would use $passTenantParameter. + */ + public static bool $passQueryParameter = true; protected array $originalFortifyConfig = []; @@ -76,27 +85,28 @@ class FortifyRouteBootstrapper implements TenancyBootstrapper protected function useTenantRoutesInFortify(Tenant $tenant): void { - $tenantKey = $tenant->getTenantKey(); - $tenantParameterName = PathTenantResolver::tenantParameterName(); + if (static::$passQueryParameter) { + // todo@tests + $tenantParameterName = RequestDataTenantResolver::queryParameterName(); + $tenantParameterValue = RequestDataTenantResolver::payloadValue($tenant); + } else { + $tenantParameterName = PathTenantResolver::tenantParameterName(); + $tenantParameterValue = PathTenantResolver::tenantParameterValue($tenant); + } - $generateLink = function (array $redirect) use ($tenantKey, $tenantParameterName) { - // Specifying the context is only required with query string identification - // because with path identification, the tenant parameter should always present - $passTenantParameter = $redirect['context'] === Context::TENANT; - - // Only pass the tenant parameter when the user should be redirected to a tenant route - return route($redirect['route_name'], $passTenantParameter ? [$tenantParameterName => $tenantKey] : []); + $generateLink = function (string $redirect) use ($tenantParameterValue, $tenantParameterName) { + return route($redirect, static::$passTenantParameter ? [$tenantParameterName => $tenantParameterValue] : []); }; // Get redirect URLs for the configured redirect routes $redirects = array_merge( $this->originalFortifyConfig['redirects'] ?? [], // Fortify config redirects - array_map(fn (array $redirect) => $generateLink($redirect), static::$fortifyRedirectMap), // Mapped redirects + array_map(fn (string $redirect) => $generateLink($redirect), static::$fortifyRedirectMap), // Mapped redirects ); if (static::$fortifyHome) { // Generate the home route URL with the tenant parameter and make it the Fortify home route - $this->config->set('fortify.home', route(static::$fortifyHome, [$tenantParameterName => $tenantKey])); + $this->config->set('fortify.home', $generateLink(static::$fortifyHome)); } $this->config->set('fortify.redirects', $redirects); diff --git a/src/Bootstrappers/PersistentQueueTenancyBootstrapper.php b/src/Bootstrappers/PersistentQueueTenancyBootstrapper.php new file mode 100644 index 00000000..90bb3d08 --- /dev/null +++ b/src/Bootstrappers/PersistentQueueTenancyBootstrapper.php @@ -0,0 +1,146 @@ +make(Dispatcher::class), $app->runningUnitTests()); + } + + public function __construct(Repository $config, QueueManager $queue) + { + $this->config = $config; + $this->queue = $queue; + + $this->setUpPayloadGenerator(); + } + + protected static function setUpJobListener(Dispatcher $dispatcher, bool $runningTests): void + { + $previousTenant = null; + + $dispatcher->listen(JobProcessing::class, function ($event) use (&$previousTenant) { + $previousTenant = tenant(); + + static::initializeTenancyForQueue($event->job->payload()['tenant_id'] ?? null); + }); + + $dispatcher->listen(JobRetryRequested::class, function ($event) use (&$previousTenant) { + $previousTenant = tenant(); + + static::initializeTenancyForQueue($event->payload()['tenant_id'] ?? null); + }); + + // If we're running tests, we make sure to clean up after any artisan('queue:work') calls + $revertToPreviousState = function ($event) use (&$previousTenant, $runningTests) { + if ($runningTests) { + static::revertToPreviousState($event->job->payload()['tenant_id'] ?? null, $previousTenant); + + // We don't need to reset $previousTenant since the value will be set again when a job is processed. + } + + // If we're not running tests, we remain in the tenant's context. This makes other JobProcessed + // listeners able to deserialize the job, including with SerializesModels, since the tenant connection + // remains open. + }; + + $dispatcher->listen(JobProcessed::class, $revertToPreviousState); // artisan('queue:work') which succeeds + $dispatcher->listen(JobFailed::class, $revertToPreviousState); // artisan('queue:work') which fails + } + + protected static function initializeTenancyForQueue(string|int|null $tenantId): void + { + if (! $tenantId) { + // The job is not tenant-aware + if (tenancy()->initialized) { + // Tenancy was initialized, so we revert back to the central context + tenancy()->end(); + } + + return; + } + + // Re-initialize tenancy between all jobs even if the tenant is the same + // so that we don't work with an outdated tenant() instance in case it + // was updated outside the queue worker. + tenancy()->end(); + + /** @var Tenant $tenant */ + $tenant = tenancy()->find($tenantId); + tenancy()->initialize($tenant); + } + + protected static function revertToPreviousState(string|int|null $tenantId, ?Tenant $previousTenant): void + { + // The job was not tenant-aware + if (! $tenantId) { + return; + } + + // Revert back to the previous tenant + if (tenant() && $previousTenant && $previousTenant->isNot(tenant())) { + tenancy()->initialize($previousTenant); + } + + // End tenancy + if (tenant() && (! $previousTenant)) { + tenancy()->end(); + } + } + + protected function setUpPayloadGenerator(): void + { + $bootstrapper = &$this; + + if (! $this->queue instanceof QueueFake) { + $this->queue->createPayloadUsing(function ($connection) use (&$bootstrapper) { + return $bootstrapper->getPayload($connection); + }); + } + } + + public function getPayload(string $connection): array + { + if (! tenancy()->initialized) { + return []; + } + + if ($this->config["queue.connections.$connection.central"]) { + return []; + } + + return [ + 'tenant_id' => tenant()->getTenantKey(), + ]; + } + + public function bootstrap(Tenant $tenant): void {} + public function revert(): void {} +} diff --git a/src/Bootstrappers/QueueTenancyBootstrapper.php b/src/Bootstrappers/QueueTenancyBootstrapper.php index 16ae043b..2ed6b7f1 100644 --- a/src/Bootstrappers/QueueTenancyBootstrapper.php +++ b/src/Bootstrappers/QueueTenancyBootstrapper.php @@ -24,16 +24,6 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper /** @var QueueManager */ protected $queue; - /** - * Don't persist the same tenant across multiple jobs even if they have the same tenant ID. - * - * This is useful when you're changing the tenant's state (e.g. properties in the `data` column) and want the next job to initialize tenancy again - * with the new data. Features like the Tenant Config are only executed when tenancy is initialized, so the re-initialization is needed in some cases. - * - * @var bool - */ - public static $forceRefresh = false; - /** * The normal constructor is only executed after tenancy is bootstrapped. * However, we're registering a hook to initialize tenancy. Therefore, @@ -68,9 +58,12 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper static::initializeTenancyForQueue($event->payload()['tenant_id'] ?? null); }); - // If we're running tests, we make sure to clean up after any artisan('queue:work') calls $revertToPreviousState = function ($event) use (&$previousTenant) { - static::revertToPreviousState($event, $previousTenant); + // In queue worker context, this reverts to the central context. + // In dispatchSync context, this reverts to the previous tenant's context. + // There's no need to reset $previousTenant here since it's always first + // set in the above listeners and the app is reverted back to that context. + static::revertToPreviousState($event->job->payload()['tenant_id'] ?? null, $previousTenant); }; $dispatcher->listen(JobProcessed::class, $revertToPreviousState); // artisan('queue:work') which succeeds @@ -79,61 +72,25 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper protected static function initializeTenancyForQueue(string|int|null $tenantId): void { - if ($tenantId === null) { - // The job is not tenant-aware - if (tenancy()->initialized) { - // Tenancy was initialized, so we revert back to the central context - tenancy()->end(); - } - + if (! $tenantId) { return; } - if (static::$forceRefresh) { - // Re-initialize tenancy between all jobs - if (tenancy()->initialized) { - tenancy()->end(); - } - - /** @var Tenant $tenant */ - $tenant = tenancy()->find($tenantId); - tenancy()->initialize($tenant); - - return; - } - - if (tenancy()->initialized) { - // Tenancy is already initialized - if (tenant()->getTenantKey() === $tenantId) { - // It's initialized for the same tenant (e.g. dispatchSync was used, or the previous job also ran for this tenant) - return; - } - } - - // Tenancy was either not initialized, or initialized for a different tenant. - // Therefore, we initialize it for the correct tenant. - /** @var Tenant $tenant */ $tenant = tenancy()->find($tenantId); tenancy()->initialize($tenant); } - protected static function revertToPreviousState(JobProcessed|JobFailed $event, ?Tenant &$previousTenant): void + protected static function revertToPreviousState(string|int|null $tenantId, ?Tenant $previousTenant): void { - $tenantId = $event->job->payload()['tenant_id'] ?? null; - - // The job was not tenant-aware + // The job was not tenant-aware so no context switch was done if (! $tenantId) { return; } - // Revert back to the previous tenant - if (tenant() && $previousTenant?->isNot(tenant())) { - tenancy()->initialize($previousTenant); - } - - // End tenancy - if (tenant() && (! $previousTenant)) { + // End tenancy when there's no previous tenant + // (= when running in a queue worker, not dispatchSync) + if (tenant() && ! $previousTenant) { tenancy()->end(); } } @@ -149,16 +106,6 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper } } - public function bootstrap(Tenant $tenant): void - { - // - } - - public function revert(): void - { - // - } - public function getPayload(string $connection): array { if (! tenancy()->initialized) { @@ -169,10 +116,11 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper return []; } - $id = tenant()->getTenantKey(); - return [ - 'tenant_id' => $id, + 'tenant_id' => tenant()->getTenantKey(), ]; } + + public function bootstrap(Tenant $tenant): void {} + public function revert(): void {} } diff --git a/src/Bootstrappers/RootUrlBootstrapper.php b/src/Bootstrappers/RootUrlBootstrapper.php index 6a523673..5958737c 100644 --- a/src/Bootstrappers/RootUrlBootstrapper.php +++ b/src/Bootstrappers/RootUrlBootstrapper.php @@ -7,7 +7,6 @@ namespace Stancl\Tenancy\Bootstrappers; use Closure; use Illuminate\Config\Repository; use Illuminate\Contracts\Foundation\Application; -use Illuminate\Routing\UrlGenerator; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; @@ -36,28 +35,43 @@ class RootUrlBootstrapper implements TenancyBootstrapper protected string|null $originalRootUrl = null; + /** + * Overriding the root url may cause issues in *some* tests, so you can disable + * the behavior by setting this property to false. + */ + public static bool $rootUrlOverrideInTests = true; + public function __construct( - protected UrlGenerator $urlGenerator, protected Repository $config, protected Application $app, ) {} public function bootstrap(Tenant $tenant): void { - if ($this->app->runningInConsole() && static::$rootUrlOverride) { - $this->originalRootUrl = $this->urlGenerator->to('/'); - - $newRootUrl = (static::$rootUrlOverride)($tenant, $this->originalRootUrl); - - $this->urlGenerator->forceRootUrl($newRootUrl); - $this->config->set('app.url', $newRootUrl); + if (static::$rootUrlOverride === null) { + return; } + + if (! $this->app->runningInConsole()) { + return; + } + + if ($this->app->runningUnitTests() && ! static::$rootUrlOverrideInTests) { + return; + } + + $this->originalRootUrl = $this->app['url']->to('/'); + + $newRootUrl = (static::$rootUrlOverride)($tenant, $this->originalRootUrl); + + $this->app['url']->forceRootUrl($newRootUrl); + $this->config->set('app.url', $newRootUrl); } public function revert(): void { if ($this->originalRootUrl) { - $this->urlGenerator->forceRootUrl($this->originalRootUrl); + $this->app['url']->forceRootUrl($this->originalRootUrl); $this->config->set('app.url', $this->originalRootUrl); } } diff --git a/src/Bootstrappers/UrlGeneratorBootstrapper.php b/src/Bootstrappers/UrlGeneratorBootstrapper.php index e3bb4a99..6c923d21 100644 --- a/src/Bootstrappers/UrlGeneratorBootstrapper.php +++ b/src/Bootstrappers/UrlGeneratorBootstrapper.php @@ -10,6 +10,7 @@ use Illuminate\Support\Facades\URL; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Overrides\TenancyUrlGenerator; +use Stancl\Tenancy\Resolvers\PathTenantResolver; /** * Makes the app use TenancyUrlGenerator (instead of Illuminate\Routing\UrlGenerator) which: @@ -19,10 +20,20 @@ use Stancl\Tenancy\Overrides\TenancyUrlGenerator; * Used with path and query string identification. * * @see TenancyUrlGenerator - * @see \Stancl\Tenancy\Resolvers\PathTenantResolver + * @see PathTenantResolver */ class UrlGeneratorBootstrapper implements TenancyBootstrapper { + /** + * Should the tenant route parameter get added to TenancyUrlGenerator::defaults(). + * + * This is recommended when using path identification since defaults() generally has better support in integrations, + * namely Ziggy, compared to TenancyUrlGenerator::$passTenantParameterToRoutes. + * + * With query string identification, this has no effect since URL::defaults() only works for route paramaters. + */ + public static bool $addTenantParameterToDefaults = true; + public function __construct( protected Application $app, protected UrlGenerator $originalUrlGenerator, @@ -32,12 +43,12 @@ class UrlGeneratorBootstrapper implements TenancyBootstrapper { URL::clearResolvedInstances(); - $this->useTenancyUrlGenerator(); + $this->useTenancyUrlGenerator($tenant); } public function revert(): void { - $this->app->bind('url', fn () => $this->originalUrlGenerator); + $this->app->extend('url', fn () => $this->originalUrlGenerator); } /** @@ -45,26 +56,38 @@ class UrlGeneratorBootstrapper implements TenancyBootstrapper * * @see \Illuminate\Routing\RoutingServiceProvider registerUrlGenerator() */ - protected function useTenancyUrlGenerator(): void + protected function useTenancyUrlGenerator(Tenant $tenant): void { - $this->app->extend('url', function (UrlGenerator $urlGenerator, Application $app) { - $newGenerator = new TenancyUrlGenerator( - $app['router']->getRoutes(), - $urlGenerator->getRequest(), - $app['config']->get('app.asset_url'), - ); + $newGenerator = new TenancyUrlGenerator( + $this->app['router']->getRoutes(), + $this->originalUrlGenerator->getRequest(), + $this->app['config']->get('app.asset_url'), + ); - $newGenerator->defaults($urlGenerator->getDefaultParameters()); + $defaultParameters = $this->originalUrlGenerator->getDefaultParameters(); - $newGenerator->setSessionResolver(function () { - return $this->app['session'] ?? null; - }); + if (static::$addTenantParameterToDefaults) { + $tenantParameterName = PathTenantResolver::tenantParameterName(); - $newGenerator->setKeyResolver(function () { - return $this->app->make('config')->get('app.key'); - }); + $defaultParameters = array_merge($defaultParameters, [ + $tenantParameterName => PathTenantResolver::tenantParameterValue($tenant), + ]); - return $newGenerator; + foreach (PathTenantResolver::allowedExtraModelColumns() as $column) { + $defaultParameters["$tenantParameterName:$column"] = $tenant->getAttribute($column); + } + } + + $newGenerator->defaults($defaultParameters); + + $newGenerator->setSessionResolver(function () { + return $this->app['session'] ?? null; }); + + $newGenerator->setKeyResolver(function () { + return $this->app->make('config')->get('app.key'); + }); + + $this->app->extend('url', fn () => $newGenerator); } } diff --git a/src/Commands/Run.php b/src/Commands/Run.php index afc9871a..7dd69e0f 100644 --- a/src/Commands/Run.php +++ b/src/Commands/Run.php @@ -7,7 +7,7 @@ namespace Stancl\Tenancy\Commands; use Illuminate\Console\Command; use Illuminate\Contracts\Console\Kernel; use Stancl\Tenancy\Concerns\HasTenantOptions; -use Symfony\Component\Console\Input\ArgvInput; +use Symfony\Component\Console\Input\StringInput; use Symfony\Component\Console\Output\ConsoleOutput; class Run extends Command @@ -21,30 +21,19 @@ class Run extends Command public function handle(): int { - $argvInput = $this->argvInput(); + /** @var string $commandName */ + $commandName = $this->argument('commandname'); - tenancy()->runForMultiple($this->getTenants(), function ($tenant) use ($argvInput) { + $stringInput = new StringInput($commandName); + + tenancy()->runForMultiple($this->getTenants(), function ($tenant) use ($stringInput) { $this->components->info("Tenant: {$tenant->getTenantKey()}"); $this->getLaravel() ->make(Kernel::class) - ->handle($argvInput, new ConsoleOutput); + ->handle($stringInput, new ConsoleOutput); }); return 0; } - - protected function argvInput(): ArgvInput - { - /** @var string $commandName */ - $commandName = $this->argument('commandname'); - - // Convert string command to array - $subCommand = explode(' ', $commandName); - - // Add "artisan" as first parameter because ArgvInput expects "artisan" as first parameter and later removes it - array_unshift($subCommand, 'artisan'); - - return new ArgvInput($subCommand); - } } diff --git a/src/Concerns/DealsWithTenantSymlinks.php b/src/Concerns/DealsWithTenantSymlinks.php index 37984dc8..114eadb5 100644 --- a/src/Concerns/DealsWithTenantSymlinks.php +++ b/src/Concerns/DealsWithTenantSymlinks.php @@ -56,6 +56,6 @@ trait DealsWithTenantSymlinks /** Determine if the provided path is an existing symlink. */ protected function symlinkExists(string $link): bool { - return file_exists($link) && is_link($link); + return is_link($link); } } diff --git a/src/Contracts/UniqueIdentifierGenerator.php b/src/Contracts/UniqueIdentifierGenerator.php index 14d91ae0..939f433c 100644 --- a/src/Contracts/UniqueIdentifierGenerator.php +++ b/src/Contracts/UniqueIdentifierGenerator.php @@ -11,5 +11,5 @@ interface UniqueIdentifierGenerator /** * Generate a unique identifier for a model. */ - public static function generate(Model $model): string; + public static function generate(Model $model): string|int; } diff --git a/src/Database/Concerns/CreatesDatabaseUsers.php b/src/Database/Concerns/CreatesDatabaseUsers.php index 8e102fd0..73d8e777 100644 --- a/src/Database/Concerns/CreatesDatabaseUsers.php +++ b/src/Database/Concerns/CreatesDatabaseUsers.php @@ -10,16 +10,11 @@ trait CreatesDatabaseUsers { public function createDatabase(TenantWithDatabase $tenant): bool { - parent::createDatabase($tenant); - - return $this->createUser($tenant->database()); + return parent::createDatabase($tenant) && $this->createUser($tenant->database()); } public function deleteDatabase(TenantWithDatabase $tenant): bool { - // Some DB engines require the user to be deleted before the database (e.g. Postgres) - $this->deleteUser($tenant->database()); - - return parent::deleteDatabase($tenant); + return $this->deleteUser($tenant->database()) && parent::deleteDatabase($tenant); } } diff --git a/src/Database/Concerns/HasPending.php b/src/Database/Concerns/HasPending.php index 8142e0ea..ffb35f0c 100644 --- a/src/Database/Concerns/HasPending.php +++ b/src/Database/Concerns/HasPending.php @@ -23,6 +23,8 @@ use Stancl\Tenancy\Events\PullingPendingTenant; */ trait HasPending { + public static string $pendingSinceCast = 'timestamp'; + /** Boot the trait. */ public static function bootHasPending(): void { @@ -32,7 +34,7 @@ trait HasPending /** Initialize the trait. */ public function initializeHasPending(): void { - $this->casts['pending_since'] = 'timestamp'; + $this->casts['pending_since'] = static::$pendingSinceCast; } /** Determine if the model instance is in a pending state. */ diff --git a/src/Database/Concerns/InvalidatesResolverCache.php b/src/Database/Concerns/InvalidatesResolverCache.php index cd9cb25b..71659b68 100644 --- a/src/Database/Concerns/InvalidatesResolverCache.php +++ b/src/Database/Concerns/InvalidatesResolverCache.php @@ -4,16 +4,13 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\Concerns; -use Illuminate\Database\Eloquent\Model; -use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Tenancy; trait InvalidatesResolverCache { public static function bootInvalidatesResolverCache(): void { - static::saved(function (Tenant&Model $tenant) { - Tenancy::invalidateResolverCache($tenant); - }); + static::saved(Tenancy::invalidateResolverCache(...)); + static::deleting(Tenancy::invalidateResolverCache(...)); } } diff --git a/src/Database/Concerns/InvalidatesTenantsResolverCache.php b/src/Database/Concerns/InvalidatesTenantsResolverCache.php index d954567f..48cacbbd 100644 --- a/src/Database/Concerns/InvalidatesTenantsResolverCache.php +++ b/src/Database/Concerns/InvalidatesTenantsResolverCache.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\Concerns; use Illuminate\Database\Eloquent\Model; -use Stancl\Tenancy\Resolvers\Contracts\CachedTenantResolver; use Stancl\Tenancy\Tenancy; /** @@ -15,13 +14,9 @@ trait InvalidatesTenantsResolverCache { public static function bootInvalidatesTenantsResolverCache(): void { - static::saved(function (Model $model) { - foreach (Tenancy::cachedResolvers() as $resolver) { - /** @var CachedTenantResolver $resolver */ - $resolver = app($resolver); + $invalidateCache = static fn (Model $model) => Tenancy::invalidateResolverCache($model->tenant); - $resolver->invalidateCache($model->tenant); - } - }); + static::saved($invalidateCache); + static::deleting($invalidateCache); } } diff --git a/src/Database/Concerns/PendingScope.php b/src/Database/Concerns/PendingScope.php index b5b6b9cb..d83a37dd 100644 --- a/src/Database/Concerns/PendingScope.php +++ b/src/Database/Concerns/PendingScope.php @@ -20,7 +20,7 @@ class PendingScope implements Scope /** * Apply the scope to a given Eloquent query builder. * - * @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder + * @param Builder $builder * * @return void */ diff --git a/src/Database/Models/ImpersonationToken.php b/src/Database/Models/ImpersonationToken.php index 6dabbd03..38d2463e 100644 --- a/src/Database/Models/ImpersonationToken.php +++ b/src/Database/Models/ImpersonationToken.php @@ -25,6 +25,9 @@ class ImpersonationToken extends Model { use CentralConnection; + /** You can set this property to customize the table name */ + public static string $tableName = 'tenant_user_impersonation_tokens'; + protected $guarded = []; public $timestamps = false; @@ -33,11 +36,15 @@ class ImpersonationToken extends Model public $incrementing = false; - protected $table = 'tenant_user_impersonation_tokens'; protected $casts = [ 'created_at' => 'datetime', ]; + public function getTable() + { + return static::$tableName; + } + public static function booted(): void { static::creating(function ($model) { diff --git a/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php index 1e5426ea..da993956 100644 --- a/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php @@ -11,8 +11,6 @@ class MicrosoftSQLDatabaseManager extends TenantDatabaseManager public function createDatabase(TenantWithDatabase $tenant): bool { $database = $tenant->database()->getName(); - $charset = $this->connection()->getConfig('charset'); - $collation = $this->connection()->getConfig('collation'); // todo check why these are not used return $this->connection()->statement("CREATE DATABASE [{$database}]"); } diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php index 5462eafe..933740ed 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php @@ -32,6 +32,8 @@ class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManage // Grant permissions to any existing tables. This is used with RLS // todo@samuel refactor this along with the todo in TenantDatabaseManager // and move the grantPermissions() call inside the condition in `ManagesPostgresUsers::createUser()` + // but maybe moving it inside $createUser is wrong because some central user may migrate new tables + // while the RLS user should STILL get access to those tables foreach ($tables as $table) { $tableName = $table->table_name; diff --git a/src/Events/Contracts/TenantEvent.php b/src/Events/Contracts/TenantEvent.php index e07708b7..fd48ac10 100644 --- a/src/Events/Contracts/TenantEvent.php +++ b/src/Events/Contracts/TenantEvent.php @@ -7,15 +7,11 @@ namespace Stancl\Tenancy\Events\Contracts; use Illuminate\Queue\SerializesModels; use Stancl\Tenancy\Contracts\Tenant; -abstract class TenantEvent // todo we could add a feature to JobPipeline that automatically gets data for the send() from here +abstract class TenantEvent { use SerializesModels; - /** @var Tenant */ - public $tenant; - - public function __construct(Tenant $tenant) - { - $this->tenant = $tenant; - } + public function __construct( + public Tenant $tenant, + ) {} } diff --git a/src/Exceptions/TenantCouldNotBeIdentifiedByIdException.php b/src/Exceptions/TenantCouldNotBeIdentifiedByIdException.php index b344be53..6f61455e 100644 --- a/src/Exceptions/TenantCouldNotBeIdentifiedByIdException.php +++ b/src/Exceptions/TenantCouldNotBeIdentifiedByIdException.php @@ -2,8 +2,6 @@ declare(strict_types=1); -// todo perhaps create Identification namespace - namespace Stancl\Tenancy\Exceptions; use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException; diff --git a/src/Features/UserImpersonation.php b/src/Features/UserImpersonation.php index fd608cc4..3db563a4 100644 --- a/src/Features/UserImpersonation.php +++ b/src/Features/UserImpersonation.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Features; +use Illuminate\Database\Eloquent\Model; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Auth; use Stancl\Tenancy\Contracts\Feature; @@ -18,8 +19,8 @@ class UserImpersonation implements Feature public function bootstrap(Tenancy $tenancy): void { - $tenancy->macro('impersonate', function (Tenant $tenant, string $userId, string $redirectUrl, string|null $authGuard = null, bool $remember = false): ImpersonationToken { - return ImpersonationToken::create([ + $tenancy->macro('impersonate', function (Tenant $tenant, string $userId, string $redirectUrl, string|null $authGuard = null, bool $remember = false): Model { + return UserImpersonation::modelClass()::create([ Tenancy::tenantKeyColumn() => $tenant->getTenantKey(), 'user_id' => $userId, 'redirect_url' => $redirectUrl, @@ -30,10 +31,15 @@ class UserImpersonation implements Feature } /** Impersonate a user and get an HTTP redirect response. */ - public static function makeResponse(#[\SensitiveParameter] string|ImpersonationToken $token, ?int $ttl = null): RedirectResponse + public static function makeResponse(#[\SensitiveParameter] string|Model $token, ?int $ttl = null): RedirectResponse { - /** @var ImpersonationToken $token */ - $token = $token instanceof ImpersonationToken ? $token : ImpersonationToken::findOrFail($token); + /** + * The model does NOT have to extend ImpersonationToken, but usually it WILL be a child + * of ImpersonationToken and this makes it clear to phpstan that the model has a redirect_url property. + * + * @var ImpersonationToken $token + */ + $token = $token instanceof Model ? $token : static::modelClass()::findOrFail($token); $ttl ??= static::$ttl; $tokenExpired = $token->created_at->diffInSeconds(now()) > $ttl; @@ -54,6 +60,12 @@ class UserImpersonation implements Feature return redirect($token->redirect_url); } + /** @return class-string */ + public static function modelClass(): string + { + return config('tenancy.models.impersonation_token'); + } + public static function isImpersonating(): bool { return session()->has('tenancy_impersonating'); @@ -62,7 +74,7 @@ class UserImpersonation implements Feature /** * Logout from the current domain and forget impersonation session. */ - public static function leave(): void // todo@name possibly rename + public static function stopImpersonating(): void { auth()->logout(); diff --git a/src/Listeners/ForgetTenantParameter.php b/src/Listeners/ForgetTenantParameter.php index 424b1440..0b1d1440 100644 --- a/src/Listeners/ForgetTenantParameter.php +++ b/src/Listeners/ForgetTenantParameter.php @@ -8,6 +8,8 @@ use Illuminate\Routing\Events\RouteMatched; use Stancl\Tenancy\Enums\RouteMode; use Stancl\Tenancy\Resolvers\PathTenantResolver; +// todo@earlyIdReview + /** * Remove the tenant parameter from the matched route when path identification is used globally. * diff --git a/src/Middleware/InitializeTenancyByRequestData.php b/src/Middleware/InitializeTenancyByRequestData.php index a4e8a9c2..d7a13e2c 100644 --- a/src/Middleware/InitializeTenancyByRequestData.php +++ b/src/Middleware/InitializeTenancyByRequestData.php @@ -18,9 +18,6 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware { use UsableWithEarlyIdentification; - public static string $header = 'X-Tenant'; - public static string $cookie = 'tenant'; - public static string $queryParameter = 'tenant'; public static ?Closure $onFail = null; public static bool $requireCookieEncryption = false; @@ -54,18 +51,19 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware protected function getPayload(Request $request): string|null { - if (static::$header && $request->hasHeader(static::$header)) { - $payload = $request->header(static::$header); - } elseif ( - static::$queryParameter && - $request->has(static::$queryParameter) - ) { - $payload = $request->get(static::$queryParameter); - } elseif (static::$cookie && $request->hasCookie(static::$cookie)) { - $payload = $request->cookie(static::$cookie); + $headerName = RequestDataTenantResolver::headerName(); + $queryParameterName = RequestDataTenantResolver::queryParameterName(); + $cookieName = RequestDataTenantResolver::cookieName(); + + if ($headerName && $request->hasHeader($headerName)) { + $payload = $request->header($headerName); + } elseif ($queryParameterName && $request->has($queryParameterName)) { + $payload = $request->get($queryParameterName); + } elseif ($cookieName && $request->hasCookie($cookieName)) { + $payload = $request->cookie($cookieName); if ($payload && is_string($payload)) { - $payload = $this->getTenantFromCookie($payload); + $payload = $this->getTenantFromCookie($cookieName, $payload); } } else { $payload = null; @@ -86,12 +84,12 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware return (bool) $this->getPayload($request); } - protected function getTenantFromCookie(string $cookie): string|null + protected function getTenantFromCookie(string $cookieName, string $cookieValue): string|null { // If the cookie looks like it's encrypted, we try decrypting it - if (str_starts_with($cookie, 'eyJpdiI')) { + if (str_starts_with($cookieValue, 'eyJpdiI')) { try { - $json = base64_decode($cookie); + $json = base64_decode($cookieValue); $data = json_decode($json, true); if ( @@ -100,9 +98,9 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware ) { // We can confidently assert that the cookie is encrypted. If this call were to fail, this method would just // return null and the cookie payload would get skipped. - $cookie = CookieValuePrefix::validate( - static::$cookie, - Crypt::decryptString($cookie), + $cookieValue = CookieValuePrefix::validate( + $cookieName, + Crypt::decryptString($cookieValue), Crypt::getAllKeys() ); } @@ -113,6 +111,6 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware return null; } - return $cookie; + return $cookieValue; } } diff --git a/src/Middleware/PreventAccessFromUnwantedDomains.php b/src/Middleware/PreventAccessFromUnwantedDomains.php index 05e0dbaa..91ebff05 100644 --- a/src/Middleware/PreventAccessFromUnwantedDomains.php +++ b/src/Middleware/PreventAccessFromUnwantedDomains.php @@ -68,7 +68,7 @@ class PreventAccessFromUnwantedDomains return in_array($request->getHost(), config('tenancy.identification.central_domains'), true); } - // todo@samuel + // todo@samuel technically not an identification middleware but probably ok to keep this here public function requestHasTenant(Request $request): bool { return false; diff --git a/src/Middleware/ScopeSessions.php b/src/Middleware/ScopeSessions.php index 46bd5dc4..879b9d97 100644 --- a/src/Middleware/ScopeSessions.php +++ b/src/Middleware/ScopeSessions.php @@ -19,6 +19,10 @@ class ScopeSessions public function handle(Request $request, Closure $next): mixed { if (! tenancy()->initialized) { + if (tenancy()->routeIsUniversal(tenancy()->getRoute($request))) { + return $next($request); + } + throw new TenancyNotInitializedException('Tenancy needs to be initialized before the session scoping middleware is executed'); } diff --git a/src/Overrides/CacheManager.php b/src/Overrides/CacheManager.php index 9c78288e..dfbe1c71 100644 --- a/src/Overrides/CacheManager.php +++ b/src/Overrides/CacheManager.php @@ -6,8 +6,6 @@ namespace Stancl\Tenancy\Overrides; use Illuminate\Cache\CacheManager as BaseCacheManager; -// todo@move move to Cache namespace? - class CacheManager extends BaseCacheManager { /** diff --git a/src/Overrides/TenancyUrlGenerator.php b/src/Overrides/TenancyUrlGenerator.php index 7c0a7879..ed14d5b5 100644 --- a/src/Overrides/TenancyUrlGenerator.php +++ b/src/Overrides/TenancyUrlGenerator.php @@ -9,43 +9,100 @@ use Illuminate\Routing\UrlGenerator; use Illuminate\Support\Arr; use InvalidArgumentException; use Stancl\Tenancy\Resolvers\PathTenantResolver; +use Stancl\Tenancy\Resolvers\RequestDataTenantResolver; /** * This class is used in place of the default UrlGenerator when UrlGeneratorBootstrapper is enabled. * - * TenancyUrlGenerator does two extra things: - * 1. Autofill the {tenant} parameter in the tenant context with the current tenant if $passTenantParameterToRoutes is enabled (enabled by default) - * 2. Prepend the route name with `tenant.` (or the configured prefix) if $prefixRouteNames is enabled (disabled by default) + * TenancyUrlGenerator does a few extra things: + * - Autofills the tenant parameter in the tenant context with the current tenant. + * This is done either by: + * - URL::defaults() -- if UrlGeneratorBootstrapper::$addTenantParameterToDefaults is enabled. + * This generally has the best support since tools like e.g. Ziggy read defaults(). + * - 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. * - * Both of these can be skipped by passing the $bypassParameter (`['central' => true]` by default) + * - Prepends route names passed to route() and URL::temporarySignedRoute() + * with `tenant.` (or the configured prefix) 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). */ class TenancyUrlGenerator extends UrlGenerator { /** - * Parameter which bypasses the behavior modification of route() and temporarySignedRoute(). + * Parameter which works as a flag for bypassing the behavior modification of route() and temporarySignedRoute(). * - * E.g. route('tenant') => app.test/{tenant}/tenant (or app.test/tenant?tenant=tenantKey if the route doesn't accept the tenant parameter) - * route('tenant', [$bypassParameter => true]) => app.test/tenant. + * For example, in tenant context: + * Route::get('/', ...)->name('home'); + * // query string identification + * Route::get('/tenant', ...)->middleware(InitializeTenancyByRequestData::class)->name('tenant.home'); + * - route('home') => app.test/tenant?tenant=tenantKey + * - route('home', [$bypassParameter => true]) => app.test/ + * - route('tenant.home', [$bypassParameter => true]) => app.test/tenant -- no query string added + * + * 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 */ public static string $bypassParameter = 'central'; /** - * Determine if the route names passed to `route()` or `temporarySignedRoute()` - * should get prefixed with the tenant route name prefix. + * Should route names passed to route() or temporarySignedRoute() + * get prefixed with the tenant route name prefix. * - * This is useful when using path identification with packages that generate URLs, - * like Jetstream, so that you don't have to manually prefix route names passed to each route() call. + * This is useful when using e.g. path identification with third-party packages + * where you don't have control over all route() calls or don't want to change + * too many files. Often this will be when using route cloning. */ public static bool $prefixRouteNames = false; /** - * Determine if the tenant parameter should get passed - * to the links generated by `route()` or `temporarySignedRoute()` whenever available - * (enabled by default – works with both path and query string identification). + * Should the tenant parameter be passed to route() or temporarySignedRoute() calls. * - * With path identification, you can disable this and use URL::defaults() instead (as an alternative solution). + * This is useful with path or query parameter identification. The former can be handled + * more elegantly using UrlGeneratorBootstrapper::$addTenantParameterToDefaults. + * + * @see UrlGeneratorBootstrapper */ - public static bool $passTenantParameterToRoutes = true; + public static bool $passTenantParameterToRoutes = false; + + /** + * Route name overrides. + * + * Note: This behavior can be bypassed using $bypassParameter just like + * $prefixRouteNames and $passTenantParameterToRoutes. + * + * Example from a Jetstream integration: + * [ + * 'profile.show' => 'tenant.profile.show', + * 'two-factor.login' => 'tenant.two-factor.login', + * ] + * + * In the tenant context: + * - `route('profile.show')` will return a URL as if you called `route('tenant.profile.show')`. + * - `route('profile.show', ['central' => true])` will return a URL as if you called `route('profile.show')`. + */ + public static array $overrides = []; + + /** + * Follow the query_parameter config instead of the tenant_parameter_name (path identification) config. + * + * This only has an effect when: + * - $passTenantParameterToRoutes is enabled, and + * - the tenant_parameter_name config for the path resolver differs from the query_parameter config for the request data resolver. + * + * In such a case, instead of adding ['tenant' => '...'] to the route parameters (or whatever your tenant_parameter_name is if not 'tenant'), + * the query_parameter will be passed instead, e.g. ['team' => '...'] if your query_parameter config is 'team'. + * + * This is enabled by default because typically you will not need $passTenantParameterToRoutes with path identification. + * UrlGeneratorBootstrapper::$addTenantParameterToDefaults is recommended instead when using path identification. + * + * On the other hand, when using request data identification (specifically query string) you WILL need to pass the parameter + * directly to route() calls, therefore you would use $passTenantParameterToRoutes to avoid having to do that manually. + */ + public static bool $passQueryParameter = true; /** * Override the route() method so that the route name gets prefixed @@ -99,7 +156,7 @@ class TenancyUrlGenerator extends UrlGenerator protected function prepareRouteInputs(string $name, array $parameters): array { if (! $this->routeBehaviorModificationBypassed($parameters)) { - $name = $this->prefixRouteName($name); + $name = $this->routeNameOverride($name) ?? $this->prefixRouteName($name); $parameters = $this->addTenantParameter($parameters); } @@ -124,10 +181,26 @@ class TenancyUrlGenerator extends UrlGenerator } /** - * If `tenant()` isn't null, add tenant paramter to the passed parameters. + * If `tenant()` isn't null, add the tenant parameter to the passed parameters. */ protected function addTenantParameter(array $parameters): array { - return tenant() && static::$passTenantParameterToRoutes ? array_merge($parameters, [PathTenantResolver::tenantParameterName() => tenant()->getTenantKey()]) : $parameters; + if (tenant() && static::$passTenantParameterToRoutes) { + if (static::$passQueryParameter) { + $queryParameterName = RequestDataTenantResolver::queryParameterName(); + if ($queryParameterName !== null) { + return array_merge($parameters, [$queryParameterName => RequestDataTenantResolver::payloadValue(tenant())]); + } + } + + return array_merge($parameters, [PathTenantResolver::tenantParameterName() => PathTenantResolver::tenantParameterValue(tenant())]); + } else { + return $parameters; + } + } + + protected function routeNameOverride(string $name): string|null + { + return static::$overrides[$name] ?? null; } } diff --git a/src/RLS/PolicyManagers/TableRLSManager.php b/src/RLS/PolicyManagers/TableRLSManager.php index 098e8015..8e941b31 100644 --- a/src/RLS/PolicyManagers/TableRLSManager.php +++ b/src/RLS/PolicyManagers/TableRLSManager.php @@ -75,7 +75,10 @@ class TableRLSManager implements RLSPolicyManager $builder = $this->database->getSchemaBuilder(); // We loop through each table in the database - foreach ($builder->getTableListing() as $table) { + foreach ($builder->getTableListing(schema: $this->database->getConfig('search_path')) as $table) { + // E.g. "public.table_name" -> "table_name" + $table = str($table)->afterLast('.')->toString(); + // For each table, we get a list of all foreign key columns $foreignKeys = collect($builder->getForeignKeys($table))->map(function ($foreign) use ($table) { return $this->formatForeignKey($foreign, $table); @@ -105,6 +108,12 @@ class TableRLSManager implements RLSPolicyManager protected function generatePaths(string $table, array $foreign, array &$paths, array $currentPath = []): void { + // If the foreign key has a comment of 'no-rls', we skip it + // Also skip the foreign key if implicit scoping is off and the foreign key has no comment + if ($foreign['comment'] === 'no-rls' || (! static::$scopeByDefault && $foreign['comment'] === null)) { + return; + } + if (in_array($foreign['foreignTable'], array_column($currentPath, 'foreignTable'))) { throw new RecursiveRelationshipException; } @@ -112,15 +121,7 @@ class TableRLSManager implements RLSPolicyManager $currentPath[] = $foreign; if ($foreign['foreignTable'] === tenancy()->model()->getTable()) { - $comments = array_column($currentPath, 'comment'); - $pathCanUseRls = static::$scopeByDefault ? - ! in_array('no-rls', $comments) : - ! in_array('no-rls', $comments) && ! in_array(null, $comments); - - if ($pathCanUseRls) { - // If the foreign table is the tenants table, add the current path to $paths - $paths[] = $currentPath; - } + $paths[] = $currentPath; } else { // If not, recursively generate paths for the foreign table foreach ($this->database->getSchemaBuilder()->getForeignKeys($foreign['foreignTable']) as $nextConstraint) { diff --git a/src/Resolvers/PathTenantResolver.php b/src/Resolvers/PathTenantResolver.php index 556ec4a6..773154e4 100644 --- a/src/Resolvers/PathTenantResolver.php +++ b/src/Resolvers/PathTenantResolver.php @@ -73,7 +73,7 @@ class PathTenantResolver extends Contracts\CachedTenantResolver public static function tenantRouteNamePrefix(): string { - return config('tenancy.identification.resolvers.' . static::class . '.tenant_route_name_prefix') ?? static::tenantParameterName() . '.'; + return config('tenancy.identification.resolvers.' . static::class . '.tenant_route_name_prefix') ?? 'tenant.'; } public static function tenantModelColumn(): string @@ -81,6 +81,11 @@ class PathTenantResolver extends Contracts\CachedTenantResolver return config('tenancy.identification.resolvers.' . static::class . '.tenant_model_column') ?? tenancy()->model()->getTenantKeyName(); } + public static function tenantParameterValue(Tenant $tenant): string + { + return $tenant->getAttribute(static::tenantModelColumn()); + } + /** @return string[] */ public static function allowedExtraModelColumns(): array { diff --git a/src/Resolvers/RequestDataTenantResolver.php b/src/Resolvers/RequestDataTenantResolver.php index 7ebc90ab..4d8b3277 100644 --- a/src/Resolvers/RequestDataTenantResolver.php +++ b/src/Resolvers/RequestDataTenantResolver.php @@ -20,7 +20,9 @@ class RequestDataTenantResolver extends Contracts\CachedTenantResolver { $payload = (string) $args[0]; - if ($payload && $tenant = tenancy()->find($payload, withRelations: true)) { + $column = static::tenantModelColumn(); + + if ($payload && $tenant = tenancy()->find($payload, $column, withRelations: true)) { return $tenant; } @@ -29,8 +31,43 @@ class RequestDataTenantResolver extends Contracts\CachedTenantResolver public function getPossibleCacheKeys(Tenant&Model $tenant): array { + // todo@tests return [ - $this->formatCacheKey($tenant->getTenantKey()), + $this->formatCacheKey(static::payloadValue($tenant)), ]; } + + public static function payloadValue(Tenant $tenant): string + { + return $tenant->getAttribute(static::tenantModelColumn()); + } + + public static function tenantModelColumn(): string + { + return config('tenancy.identification.resolvers.' . static::class . '.tenant_model_column') ?? tenancy()->model()->getTenantKeyName(); + } + + /** + * Returns the name of the header used for identification, or null if header identification is disabled. + */ + public static function headerName(): string|null + { + return config('tenancy.identification.resolvers.' . static::class . '.header'); + } + + /** + * Returns the name of the query parameter used for identification, or null if query parameter identification is disabled. + */ + public static function queryParameterName(): string|null + { + return config('tenancy.identification.resolvers.' . static::class . '.query_parameter'); + } + + /** + * Returns the name of the cookie used for identification, or null if cookie identification is disabled. + */ + public static function cookieName(): string|null + { + return config('tenancy.identification.resolvers.' . static::class . '.cookie'); + } } diff --git a/src/ResourceSyncing/SyncMaster.php b/src/ResourceSyncing/SyncMaster.php index 9a373930..882aeb54 100644 --- a/src/ResourceSyncing/SyncMaster.php +++ b/src/ResourceSyncing/SyncMaster.php @@ -9,8 +9,6 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; -// todo@move move all resource syncing-related things to a separate namespace? - /** * @property-read TenantWithDatabase[]|Collection $tenants */ diff --git a/src/Tenancy.php b/src/Tenancy.php index 7e509481..f96c0a51 100644 --- a/src/Tenancy.php +++ b/src/Tenancy.php @@ -77,18 +77,21 @@ class Tenancy public function run(Tenant $tenant, Closure $callback): mixed { $originalTenant = $this->tenant; + $result = null; - $this->initialize($tenant); - $result = $callback($tenant); + try { + $this->initialize($tenant); + $result = $callback($tenant); + } finally { + if ($result instanceof PendingDispatch) { // #1277 + $result = null; + } - if ($result instanceof PendingDispatch) { // #1277 - $result = null; - } - - if ($originalTenant) { - $this->initialize($originalTenant); - } else { - $this->end(); + if ($originalTenant) { + $this->initialize($originalTenant); + } else { + $this->end(); + } } return $result; @@ -204,8 +207,10 @@ class Tenancy // Wrap string in array $tenants = is_string($tenants) ? [$tenants] : $tenants; - // Use all tenants if $tenants is falsy - $tenants = $tenants ?: $this->model()->cursor(); // todo@phpstan phpstan thinks this isn't needed, but tests fail without it + // If $tenants is falsy by this point (e.g. an empty array) there's no work to be done + if (! $tenants) { + return; + } $originalTenant = $this->tenant; diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index 29caf7a7..4059479e 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Stancl\Tenancy; +use Closure; use Illuminate\Cache\CacheManager; use Illuminate\Database\Console\Migrations\FreshCommand; use Illuminate\Routing\Events\RouteMatched; @@ -18,9 +19,15 @@ use Stancl\Tenancy\Resolvers\DomainTenantResolver; class TenancyServiceProvider extends ServiceProvider { + public static Closure|null $configure = null; + /* Register services. */ public function register(): void { + if (static::$configure) { + (static::$configure)(); + } + $this->mergeConfigFrom(__DIR__ . '/../assets/config.php', 'tenancy'); $this->app->singleton(Database\DatabaseManager::class); diff --git a/src/UniqueIdentifierGenerators/RandomHexGenerator.php b/src/UniqueIdentifierGenerators/RandomHexGenerator.php index 3da05208..aa63d5d6 100644 --- a/src/UniqueIdentifierGenerators/RandomHexGenerator.php +++ b/src/UniqueIdentifierGenerators/RandomHexGenerator.php @@ -18,7 +18,7 @@ class RandomHexGenerator implements UniqueIdentifierGenerator { public static int $bytes = 6; - public static function generate(Model $model): string + public static function generate(Model $model): string|int { return bin2hex(random_bytes(static::$bytes)); } diff --git a/src/UniqueIdentifierGenerators/RandomIntGenerator.php b/src/UniqueIdentifierGenerators/RandomIntGenerator.php new file mode 100644 index 00000000..427dff1a --- /dev/null +++ b/src/UniqueIdentifierGenerators/RandomIntGenerator.php @@ -0,0 +1,22 @@ +toString(); + } +} diff --git a/src/UniqueIdentifierGenerators/UUIDGenerator.php b/src/UniqueIdentifierGenerators/UUIDGenerator.php index 51e4bc39..f8bf4b9c 100644 --- a/src/UniqueIdentifierGenerators/UUIDGenerator.php +++ b/src/UniqueIdentifierGenerators/UUIDGenerator.php @@ -13,7 +13,7 @@ use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator; */ class UUIDGenerator implements UniqueIdentifierGenerator { - public static function generate(Model $model): string + public static function generate(Model $model): string|int { return Uuid::uuid4()->toString(); } diff --git a/t b/t index 4fd5931c..36d2d391 100755 --- a/t +++ b/t @@ -1,3 +1,9 @@ #!/bin/bash -docker compose exec -e COLUMNS=$(tput cols) -T test vendor/bin/pest --color=always --no-coverage --filter "$@" +if [[ "${CLAUDECODE}" != "1" ]]; then + COLOR_FLAG="--colors=always" +else + COLOR_FLAG="--colors=never" +fi + +docker compose exec -e COLUMNS=$(tput cols) -T test vendor/bin/pest ${COLOR_FLAG} --no-coverage --filter "$@" diff --git a/test b/test index b8bd8fa0..0df8f63e 100755 --- a/test +++ b/test @@ -1,4 +1,10 @@ #!/bin/bash +if [[ "${CLAUDECODE}" != "1" ]]; then + COLOR_FLAG="--colors=always" +else + COLOR_FLAG="--colors=never" +fi + # --columns doesn't seem to work at the moment, so we're setting it using an environment variable -docker compose exec -e COLUMNS=$(tput cols) -T test vendor/bin/pest --colors=always "$@" +docker compose exec -e COLUMNS=$(tput cols) -T test vendor/bin/pest ${COLOR_FLAG} "$@" diff --git a/tests/ActionTest.php b/tests/ActionTest.php index 1adcb32d..63b6b377 100644 --- a/tests/ActionTest.php +++ b/tests/ActionTest.php @@ -11,6 +11,7 @@ use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Actions\CreateStorageSymlinksAction; use Stancl\Tenancy\Actions\RemoveStorageSymlinksAction; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; +use Illuminate\Support\Facades\File; beforeEach(function () { Event::listen(TenancyInitialized::class, BootstrapTenancy::class); @@ -35,11 +36,15 @@ test('create storage symlinks action works', function() { tenancy()->initialize($tenant); - $this->assertDirectoryDoesNotExist($publicPath = public_path("public-$tenantKey")); + // The symlink doesn't exist + expect(is_link($publicPath = public_path("public-$tenantKey")))->toBeFalse(); + expect(file_exists($publicPath))->toBeFalse(); (new CreateStorageSymlinksAction)($tenant); - $this->assertDirectoryExists($publicPath); + // The symlink exists and is valid + expect(is_link($publicPath = public_path("public-$tenantKey")))->toBeTrue(); + expect(file_exists($publicPath))->toBeTrue(); $this->assertEquals(storage_path("app/public/"), readlink($publicPath)); }); @@ -61,9 +66,48 @@ test('remove storage symlinks action works', function() { (new CreateStorageSymlinksAction)($tenant); - $this->assertDirectoryExists($publicPath = public_path("public-$tenantKey")); + // The symlink exists and is valid + expect(is_link($publicPath = public_path("public-$tenantKey")))->toBeTrue(); + expect(file_exists($publicPath))->toBeTrue(); (new RemoveStorageSymlinksAction)($tenant); - $this->assertDirectoryDoesNotExist($publicPath); + // The symlink doesn't exist + expect(is_link($publicPath))->toBeFalse(); + expect(file_exists($publicPath))->toBeFalse(); +}); + +test('removing tenant symlinks works even if the symlinks are invalid', function() { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'tenancy.filesystem.suffix_base' => 'tenant-', + 'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/', + 'tenancy.filesystem.url_override.public' => 'public-%tenant%' + ]); + + /** @var Tenant $tenant */ + $tenant = Tenant::create(); + $tenantKey = $tenant->getTenantKey(); + + tenancy()->initialize($tenant); + + (new CreateStorageSymlinksAction)($tenant); + + // The symlink exists and is valid + expect(is_link($publicPath = public_path("public-$tenantKey")))->toBeTrue(); + expect(file_exists($publicPath))->toBeTrue(); + + // Make the symlink invalid by deleting the tenant storage directory + $storagePath = storage_path(); + File::deleteDirectory($storagePath); + + // The symlink still exists, but isn't valid + expect(is_link($publicPath))->toBeTrue(); + expect(file_exists($publicPath))->toBeFalse(); + + (new RemoveStorageSymlinksAction)($tenant); + + expect(is_link($publicPath))->toBeFalse(); }); diff --git a/tests/AutomaticModeTest.php b/tests/AutomaticModeTest.php index f34aa7f1..fbeb06fc 100644 --- a/tests/AutomaticModeTest.php +++ b/tests/AutomaticModeTest.php @@ -9,6 +9,8 @@ use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Tests\Etc\Tenant; +use function Stancl\Tenancy\Tests\pest; +use function Stancl\Tenancy\Tests\withTenantDatabases; beforeEach(function () { Event::listen(TenancyInitialized::class, BootstrapTenancy::class); diff --git a/tests/Bootstrappers/BootstrapperTest.php b/tests/Bootstrappers/BootstrapperTest.php index 10120f85..c4fed90c 100644 --- a/tests/Bootstrappers/BootstrapperTest.php +++ b/tests/Bootstrappers/BootstrapperTest.php @@ -23,6 +23,7 @@ use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper; +use function Stancl\Tenancy\Tests\pest; beforeEach(function () { $this->mockConsoleOutput = false; diff --git a/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php b/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php index 4c3ea30a..785430f5 100644 --- a/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php @@ -15,6 +15,7 @@ use Stancl\Tenancy\Tests\Etc\TestingBroadcaster; use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper; +use function Stancl\Tenancy\Tests\pest; beforeEach(function () { Event::listen(TenancyInitialized::class, BootstrapTenancy::class); diff --git a/tests/Bootstrappers/CacheTagsBootstrapperTest.php b/tests/Bootstrappers/CacheTagsBootstrapperTest.php index fa63fc6c..660be1a7 100644 --- a/tests/Bootstrappers/CacheTagsBootstrapperTest.php +++ b/tests/Bootstrappers/CacheTagsBootstrapperTest.php @@ -9,6 +9,7 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Bootstrappers\CacheTagsBootstrapper; use Stancl\Tenancy\Events\TenancyEnded; use Stancl\Tenancy\Listeners\RevertToCentralContext; +use function Stancl\Tenancy\Tests\pest; beforeEach(function () { config(['tenancy.bootstrappers' => [CacheTagsBootstrapper::class]]); diff --git a/tests/Bootstrappers/DatabaseSessionBootstrapperTest.php b/tests/Bootstrappers/DatabaseSessionBootstrapperTest.php index f249c975..a6faa9a8 100644 --- a/tests/Bootstrappers/DatabaseSessionBootstrapperTest.php +++ b/tests/Bootstrappers/DatabaseSessionBootstrapperTest.php @@ -13,6 +13,7 @@ use Stancl\Tenancy\Jobs\CreateDatabase; use Stancl\Tenancy\Listeners; use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; use Stancl\Tenancy\Tests\Etc\Tenant; +use function Stancl\Tenancy\Tests\pest; /** * This collection of regression tests verifies that SessionTenancyBootstrapper diff --git a/tests/Bootstrappers/DatabaseTenancyBootstrapper.php b/tests/Bootstrappers/DatabaseTenancyBootstrapper.php index 8c8259cd..14109500 100644 --- a/tests/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/tests/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -6,6 +6,9 @@ use Stancl\Tenancy\Events\TenancyEnded; use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\RevertToCentralContext; +use Stancl\Tenancy\Tests\Etc\Tenant; + +use function Stancl\Tenancy\Tests\pest; beforeEach(function () { Event::listen(TenancyInitialized::class, BootstrapTenancy::class); diff --git a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php index 06aad296..d6b6a231 100644 --- a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php @@ -16,6 +16,7 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\DeleteTenantStorage; use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; +use function Stancl\Tenancy\Tests\pest; beforeEach(function () { Event::listen(TenancyInitialized::class, BootstrapTenancy::class); diff --git a/tests/Bootstrappers/FortifyRouteBootstrapperTest.php b/tests/Bootstrappers/FortifyRouteBootstrapperTest.php index 63a08c2f..63f0f2a0 100644 --- a/tests/Bootstrappers/FortifyRouteBootstrapperTest.php +++ b/tests/Bootstrappers/FortifyRouteBootstrapperTest.php @@ -13,6 +13,14 @@ use Stancl\Tenancy\Bootstrappers\Integrations\FortifyRouteBootstrapper; beforeEach(function () { Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyEnded::class, RevertToCentralContext::class); + FortifyRouteBootstrapper::$passTenantParameter = true; +}); + +afterEach(function () { + FortifyRouteBootstrapper::$passTenantParameter = true; + FortifyRouteBootstrapper::$fortifyRedirectMap = []; + FortifyRouteBootstrapper::$fortifyHome = 'tenant.dashboard'; + FortifyRouteBootstrapper::$passQueryParameter = false; }); test('fortify route tenancy bootstrapper updates fortify config correctly', function() { @@ -25,53 +33,31 @@ test('fortify route tenancy bootstrapper updates fortify config correctly', func return true; })->name($homeRouteName = 'home'); - Route::get('/{tenant}/home', function () { - return true; - })->name($pathIdHomeRouteName = 'tenant.home'); - Route::get('/welcome', function () { return true; })->name($welcomeRouteName = 'welcome'); - Route::get('/{tenant}/welcome', function () { - return true; - })->name($pathIdWelcomeRouteName = 'path.welcome'); - FortifyRouteBootstrapper::$fortifyHome = $homeRouteName; + FortifyRouteBootstrapper::$fortifyRedirectMap['login'] = $welcomeRouteName; - // Make login redirect to the central welcome route - FortifyRouteBootstrapper::$fortifyRedirectMap['login'] = [ - 'route_name' => $welcomeRouteName, - 'context' => Context::CENTRAL, - ]; + expect(config('fortify.home'))->toBe($originalFortifyHome); + expect(config('fortify.redirects'))->toBe($originalFortifyRedirects); + FortifyRouteBootstrapper::$passTenantParameter = true; tenancy()->initialize($tenant = Tenant::create()); - // The bootstraper makes fortify.home always receive the tenant parameter expect(config('fortify.home'))->toBe('http://localhost/home?tenant=' . $tenant->getTenantKey()); - - // The login redirect route has the central context specified, so it doesn't receive the tenant parameter - expect(config('fortify.redirects'))->toEqual(['login' => 'http://localhost/welcome']); + expect(config('fortify.redirects'))->toEqual(['login' => 'http://localhost/welcome?tenant=' . $tenant->getTenantKey()]); tenancy()->end(); expect(config('fortify.home'))->toBe($originalFortifyHome); expect(config('fortify.redirects'))->toBe($originalFortifyRedirects); - // Making a route's context will pass the tenant parameter to the route - FortifyRouteBootstrapper::$fortifyRedirectMap['login']['context'] = Context::TENANT; - + FortifyRouteBootstrapper::$passTenantParameter = false; tenancy()->initialize($tenant); - - expect(config('fortify.redirects'))->toEqual(['login' => 'http://localhost/welcome?tenant=' . $tenant->getTenantKey()]); - - // Make the home and login route accept the tenant as a route parameter - // To confirm that tenant route parameter gets filled automatically too (path identification works as well as query string) - FortifyRouteBootstrapper::$fortifyHome = $pathIdHomeRouteName; - FortifyRouteBootstrapper::$fortifyRedirectMap['login']['route_name'] = $pathIdWelcomeRouteName; + expect(config('fortify.home'))->toBe('http://localhost/home'); + expect(config('fortify.redirects'))->toEqual(['login' => 'http://localhost/welcome']); tenancy()->end(); - - tenancy()->initialize($tenant); - - expect(config('fortify.home'))->toBe("http://localhost/{$tenant->getTenantKey()}/home"); - expect(config('fortify.redirects'))->toEqual(['login' => "http://localhost/{$tenant->getTenantKey()}/welcome"]); + expect(config('fortify.home'))->toBe($originalFortifyHome); + expect(config('fortify.redirects'))->toBe($originalFortifyRedirects); }); diff --git a/tests/Bootstrappers/RootUrlBootstrapperTest.php b/tests/Bootstrappers/RootUrlBootstrapperTest.php index ee17a802..c25a8bae 100644 --- a/tests/Bootstrappers/RootUrlBootstrapperTest.php +++ b/tests/Bootstrappers/RootUrlBootstrapperTest.php @@ -10,18 +10,23 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Bootstrappers\RootUrlBootstrapper; use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain; +use Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper; +use Stancl\Tenancy\Middleware\InitializeTenancyByPath; +use Stancl\Tenancy\Overrides\TenancyUrlGenerator; beforeEach(function () { Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyEnded::class, RevertToCentralContext::class); RootUrlBootstrapper::$rootUrlOverride = null; + RootUrlBootstrapper::$rootUrlOverrideInTests = true; }); afterEach(function () { RootUrlBootstrapper::$rootUrlOverride = null; + RootUrlBootstrapper::$rootUrlOverrideInTests = false; }); -test('root url bootstrapper overrides the root url when tenancy gets initialized and reverts the url to the central one after tenancy ends', function() { +test('root url bootstrapper overrides the root url when tenancy gets initialized and reverts the url to the central one when ending tenancy', function() { config(['tenancy.bootstrappers' => [RootUrlBootstrapper::class]]); Route::group([ diff --git a/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php b/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php index 8ef3169d..39fcc475 100644 --- a/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php +++ b/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php @@ -1,5 +1,6 @@ not()->toBeInstanceOf(TenancyUrlGenerator::class); }); -test('url generator bootstrapper can prefix route names passed to the route helper', function() { - Route::get('/central/home', fn () => route('home'))->name('home'); - // Tenant route name prefix is 'tenant.' by default - Route::get('/{tenant}/home', fn () => route('tenant.home'))->name('tenant.home')->middleware(['tenant', InitializeTenancyByPath::class]); +test('tenancy url generator can prefix route names passed to the route helper', function() { + config([ + 'tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_route_name_prefix' => 'custom_prefix.', + ]); + + Route::get('/central/home', fn () => '')->name('home'); + Route::get('/tenant/home', fn () => '')->name('custom_prefix.home'); $tenant = Tenant::create(); - $tenantKey = $tenant->getTenantKey(); - $centralRouteUrl = route('home'); - $tenantRouteUrl = route('tenant.home', ['tenant' => $tenantKey]); - TenancyUrlGenerator::$bypassParameter = 'bypassParameter'; config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]); tenancy()->initialize($tenant); - // Route names don't get prefixed when TenancyUrlGenerator::$prefixRouteNames is false - expect(route('home'))->not()->toBe($centralRouteUrl); - // When TenancyUrlGenerator::$passTenantParameterToRoutes is true (default) - // The route helper receives the tenant parameter - // So in order to generate central URL, we have to pass the bypass parameter - expect(route('home', ['bypassParameter' => true]))->toBe($centralRouteUrl); - + // Route names don't get prefixed when TenancyUrlGenerator::$prefixRouteNames is false (default) + expect(route('home'))->toBe('http://localhost/central/home'); + // When $prefixRouteNames is true, the route name passed to the route() helper ('home') gets prefixed automatically. TenancyUrlGenerator::$prefixRouteNames = true; - // The $prefixRouteNames property is true - // The route name passed to the route() helper ('home') gets prefixed prefixed with 'tenant.' automatically - expect(route('home'))->toBe($tenantRouteUrl); - // The 'tenant.home' route name doesn't get prefixed because it is already prefixed with 'tenant.' - // Also, the route receives the tenant parameter automatically - expect(route('tenant.home'))->toBe($tenantRouteUrl); + expect(route('home'))->toBe('http://localhost/tenant/home'); + + // The 'custom_prefix.home' route name doesn't get prefixed -- it is already prefixed with 'custom_prefix.' + expect(route('custom_prefix.home'))->toBe('http://localhost/tenant/home'); // Ending tenancy reverts route() behavior changes tenancy()->end(); - expect(route('home'))->toBe($centralRouteUrl); + expect(route('home'))->toBe('http://localhost/central/home'); +}); + +test('path identification route helper behavior', function (bool $addTenantParameterToDefaults, bool $passTenantParameterToRoutes) { + config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]); + + UrlGeneratorBootstrapper::$addTenantParameterToDefaults = $addTenantParameterToDefaults; + TenancyUrlGenerator::$passTenantParameterToRoutes = $passTenantParameterToRoutes; + + $tenant = Tenant::create(); + + Route::get('/{tenant}/home', fn () => tenant('id')) + ->name('tenant.home') + ->middleware([InitializeTenancyByPath::class]); + + tenancy()->initialize($tenant); + + if (! $addTenantParameterToDefaults && ! $passTenantParameterToRoutes) { + expect(fn () => route('tenant.home'))->toThrow(UrlGenerationException::class, 'Missing parameter: tenant'); + } else { + // If at least *one* of the approaches was used, the parameter will make its way to the route + expect(route('tenant.home'))->toBe("http://localhost/{$tenant->id}/home"); + pest()->get(route('tenant.home'))->assertSee($tenant->id); + } +})->with([true, false]) // UrlGeneratorBootstrapper::$addTenantParameterToDefaults + ->with([true, false]); // TenancyUrlGenerator::$passTenantParameterToRoutes + +test('request data identification route helper behavior', function (bool $addTenantParameterToDefaults, bool $passTenantParameterToRoutes) { + config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]); + + UrlGeneratorBootstrapper::$addTenantParameterToDefaults = $addTenantParameterToDefaults; + TenancyUrlGenerator::$passTenantParameterToRoutes = $passTenantParameterToRoutes; + + $tenant = Tenant::create(); + + Route::get('/tenant/home', fn () => tenant('id')) + ->name('tenant.home') + ->middleware([InitializeTenancyByRequestData::class]); + + tenancy()->initialize($tenant); + + if ($passTenantParameterToRoutes) { + // Only $passTenantParameterToRoutes has an effect, defaults do not affect request data URL generation + expect(route('tenant.home'))->toBe("http://localhost/tenant/home?tenant={$tenant->id}"); + pest()->get(route('tenant.home'))->assertSee($tenant->id); + } else { + expect(route('tenant.home'))->toBe("http://localhost/tenant/home"); + expect(fn () => $this->withoutExceptionHandling()->get(route('tenant.home')))->toThrow(TenantCouldNotBeIdentifiedByRequestDataException::class); + } +})->with([true, false]) // UrlGeneratorBootstrapper::$addTenantParameterToDefaults + ->with([true, false]); // TenancyUrlGenerator::$passTenantParameterToRoutes + +test('changing request data query parameter and model column is respected by the url generator', function () { + config([ + 'tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class], + 'tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.query_parameter' => 'team', + 'tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.tenant_model_column' => 'slug', + ]); + + Tenant::$extraCustomColumns = ['slug']; + + Schema::table('tenants', function (Blueprint $table) { + $table->string('slug')->unique(); + }); + + TenancyUrlGenerator::$passTenantParameterToRoutes = true; + + $tenant = Tenant::create(['slug' => 'acme']); + + Route::get('/tenant/home', fn () => tenant('id')) + ->name('tenant.home') + ->middleware([InitializeTenancyByRequestData::class]); + + tenancy()->initialize($tenant); + + expect(route('tenant.home'))->toBe("http://localhost/tenant/home?team=acme"); + pest()->get(route('tenant.home'))->assertSee($tenant->id); +}); + +test('setting extra model columns sets additional URL defaults', function () { + Tenant::$extraCustomColumns = ['slug']; + TenancyUrlGenerator::$passTenantParameterToRoutes = false; + UrlGeneratorBootstrapper::$addTenantParameterToDefaults = true; + + config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]); + config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.allowed_extra_model_columns' => ['slug']]); + + Schema::table('tenants', function (Blueprint $table) { + $table->string('slug')->unique(); + }); + + Route::get('/{tenant}/foo/{user}', function (string $user) { + return tenant()->getTenantKey() . " $user"; + })->middleware([InitializeTenancyByPath::class, 'web'])->name('foo'); + + Route::get('/{tenant:slug}/fooslug/{user}', function (string $user) { + return tenant()->getTenantKey() . " $user"; + })->middleware([InitializeTenancyByPath::class, 'web'])->name('fooslug'); + + $tenant = Tenant::create(['slug' => 'acme']); + + // In central context, no URL defaults are applied + expect(route('foo', [$tenant->getTenantKey(), 'bar']))->toBe("http://localhost/{$tenant->getTenantKey()}/foo/bar"); + pest()->get(route('foo', [$tenant->getTenantKey(), 'bar']))->assertSee(tenant()->getTenantKey() . ' bar'); + tenancy()->end(); + + expect(route('fooslug', ['acme', 'bar']))->toBe('http://localhost/acme/fooslug/bar'); + pest()->get(route('fooslug', ['acme', 'bar']))->assertSee(tenant()->getTenantKey() . ' bar'); + tenancy()->end(); + + // In tenant context, URL defaults are applied + tenancy()->initialize($tenant); + expect(route('foo', ['bar']))->toBe("http://localhost/{$tenant->getTenantKey()}/foo/bar"); + pest()->get(route('foo', ['bar']))->assertSee(tenant()->getTenantKey() . ' bar'); + + expect(route('fooslug', ['bar']))->toBe('http://localhost/acme/fooslug/bar'); + pest()->get(route('fooslug', ['bar']))->assertSee(tenant()->getTenantKey() . ' bar'); +}); + +test('changing the tenant model column changes the default value for the tenant parameter', function () { + Tenant::$extraCustomColumns = ['slug']; + TenancyUrlGenerator::$passTenantParameterToRoutes = false; + UrlGeneratorBootstrapper::$addTenantParameterToDefaults = true; + + config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]); + config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_model_column' => 'slug']); + + Schema::table('tenants', function (Blueprint $table) { + $table->string('slug')->unique(); + }); + + Route::get('/{tenant}/foo/{user}', function (string $user) { + return tenant()->getTenantKey() . " $user"; + })->middleware([InitializeTenancyByPath::class, 'web'])->name('foo'); + + $tenant = Tenant::create(['slug' => 'acme']); + + // In central context, no URL defaults are applied + expect(route('foo', ['acme', 'bar']))->toBe("http://localhost/acme/foo/bar"); + pest()->get(route('foo', ['acme', 'bar']))->assertSee(tenant()->getTenantKey() . ' bar'); + tenancy()->end(); + + // In tenant context, URL defaults are applied + tenancy()->initialize($tenant); + expect(route('foo', ['bar']))->toBe("http://localhost/acme/foo/bar"); + pest()->get(route('foo', ['bar']))->assertSee(tenant()->getTenantKey() . ' bar'); +}); + +test('changing the tenant parameter name is respected by the url generator', function () { + Tenant::$extraCustomColumns = ['slug', 'slug2']; + TenancyUrlGenerator::$passTenantParameterToRoutes = false; + UrlGeneratorBootstrapper::$addTenantParameterToDefaults = true; + + config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]); + config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_parameter_name' => 'team']); + config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_model_column' => 'slug']); + config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.allowed_extra_model_columns' => ['slug2']]); + + Schema::table('tenants', function (Blueprint $table) { + $table->string('slug')->unique(); + $table->string('slug2')->unique(); + }); + + Route::get('/{team}/foo/{user}', function (string $user) { + return tenant()->getTenantKey() . " $user"; + })->middleware([InitializeTenancyByPath::class, 'web'])->name('foo'); + + Route::get('/{team:slug2}/fooslug2/{user}', function (string $user) { + return tenant()->getTenantKey() . " $user"; + })->middleware([InitializeTenancyByPath::class, 'web'])->name('fooslug2'); + + $tenant = Tenant::create(['slug' => 'acme', 'slug2' => 'acme2']); + + // In central context, no URL defaults are applied + expect(route('foo', ['acme', 'bar']))->toBe("http://localhost/acme/foo/bar"); + pest()->get(route('foo', ['acme', 'bar']))->assertSee(tenant()->getTenantKey() . ' bar'); + tenancy()->end(); + + expect(route('fooslug2', ['acme2', 'bar']))->toBe("http://localhost/acme2/fooslug2/bar"); + pest()->get(route('fooslug2', ['acme2', 'bar']))->assertSee(tenant()->getTenantKey() . ' bar'); + tenancy()->end(); + + // In tenant context, URL defaults are applied + tenancy()->initialize($tenant); + expect(route('foo', ['bar']))->toBe("http://localhost/acme/foo/bar"); + pest()->get(route('foo', ['bar']))->assertSee(tenant()->getTenantKey() . ' bar'); + + expect(route('fooslug2', ['bar']))->toBe("http://localhost/acme2/fooslug2/bar"); + pest()->get(route('fooslug2', ['bar']))->assertSee(tenant()->getTenantKey() . ' bar'); +}); + +test('url generator can override specific route names', function() { + config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]); + + Route::get('/foo', fn () => 'foo')->name('foo'); + Route::get('/bar', fn () => 'bar')->name('bar'); + Route::get('/baz', fn () => 'baz')->name('baz'); // Not overridden + + TenancyUrlGenerator::$overrides = ['foo' => 'bar']; + + expect(route('foo'))->toBe(url('/foo')); + expect(route('bar'))->toBe(url('/bar')); + expect(route('baz'))->toBe(url('/baz')); + + tenancy()->initialize(Tenant::create()); + + expect(route('foo'))->toBe(url('/bar')); + expect(route('bar'))->toBe(url('/bar')); // not overridden + expect(route('baz'))->toBe(url('/baz')); // not overridden + + // Bypass the override + expect(route('foo', ['central' => true]))->toBe(url('/foo')); }); test('both the name prefixing and the tenant parameter logic gets skipped when bypass parameter is used', function () { @@ -105,54 +317,8 @@ test('both the name prefixing and the tenant parameter logic gets skipped when b ->not()->toContain('bypassParameter'); // When the bypass parameter is false, the generated route URL points to the prefixed route ('tenant.home') - expect(route('home', ['bypassParameter' => false]))->toBe($tenantRouteUrl) + // The tenant parameter is not passed automatically since both + // UrlGeneratorBootstrapper::$addTenantParameterToDefaults and TenancyUrlGenerator::$passTenantParameterToRoutes are false by default + expect(route('home', ['bypassParameter' => false, 'tenant' => $tenant->getTenantKey()]))->toBe($tenantRouteUrl) ->not()->toContain('bypassParameter'); }); - -test('url generator bootstrapper can make route helper generate links with the tenant parameter', function() { - Route::get('/query_string', fn () => route('query_string'))->name('query_string')->middleware(['universal', InitializeTenancyByRequestData::class]); - Route::get('/path', fn () => route('path'))->name('path'); - Route::get('/{tenant}/path', fn () => route('tenant.path'))->name('tenant.path')->middleware([InitializeTenancyByPath::class]); - - $tenant = Tenant::create(); - $tenantKey = $tenant->getTenantKey(); - $queryStringCentralUrl = route('query_string'); - $queryStringTenantUrl = route('query_string', ['tenant' => $tenantKey]); - $pathCentralUrl = route('path'); - $pathTenantUrl = route('tenant.path', ['tenant' => $tenantKey]); - - // Makes the route helper receive the tenant parameter whenever available - // Unless the bypass parameter is true - TenancyUrlGenerator::$passTenantParameterToRoutes = true; - - TenancyUrlGenerator::$bypassParameter = 'bypassParameter'; - - config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]); - - expect(route('path'))->toBe($pathCentralUrl); - // Tenant parameter required, but not passed since tenancy wasn't initialized - expect(fn () => route('tenant.path'))->toThrow(UrlGenerationException::class); - - tenancy()->initialize($tenant); - - // Tenant parameter is passed automatically - expect(route('path'))->not()->toBe($pathCentralUrl); // Parameter added as query string – bypassParameter needed - expect(route('path', ['bypassParameter' => true]))->toBe($pathCentralUrl); - expect(route('tenant.path'))->toBe($pathTenantUrl); - - expect(route('query_string'))->toBe($queryStringTenantUrl)->toContain('tenant='); - expect(route('query_string', ['bypassParameter' => 'true']))->toBe($queryStringCentralUrl)->not()->toContain('tenant='); - - tenancy()->end(); - - expect(route('query_string'))->toBe($queryStringCentralUrl); - - // Tenant parameter required, but shouldn't be passed since tenancy isn't initialized - expect(fn () => route('tenant.path'))->toThrow(UrlGenerationException::class); - - // Route-level identification - pest()->get("http://localhost/query_string")->assertSee($queryStringCentralUrl); - pest()->get("http://localhost/query_string?tenant=$tenantKey")->assertSee($queryStringTenantUrl); - pest()->get("http://localhost/path")->assertSee($pathCentralUrl); - pest()->get("http://localhost/$tenantKey/path")->assertSee($pathTenantUrl); -}); diff --git a/tests/BroadcastingTest.php b/tests/BroadcastingTest.php index ba221307..c3509426 100644 --- a/tests/BroadcastingTest.php +++ b/tests/BroadcastingTest.php @@ -19,6 +19,7 @@ use Illuminate\Broadcasting\Broadcasters\NullBroadcaster; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper; use Illuminate\Contracts\Broadcasting\Broadcaster as BroadcasterContract; +use function Stancl\Tenancy\Tests\withTenantDatabases; beforeEach(function () { withTenantDatabases(); diff --git a/tests/CachedTenantResolverTest.php b/tests/CachedTenantResolverTest.php index 0ca9553f..558fb345 100644 --- a/tests/CachedTenantResolverTest.php +++ b/tests/CachedTenantResolverTest.php @@ -11,9 +11,11 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver; use Stancl\Tenancy\Resolvers\DomainTenantResolver; use Illuminate\Support\Facades\Route as RouteFacade; use Illuminate\Support\Facades\Schema; +use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException; +use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException; use Stancl\Tenancy\Middleware\InitializeTenancyByPath; -use Stancl\Tenancy\PathIdentificationManager; use Stancl\Tenancy\Resolvers\RequestDataTenantResolver; +use function Stancl\Tenancy\Tests\pest; test('tenants can be resolved using cached resolvers', function (string $resolver) { $tenant = Tenant::create(['id' => $tenantKey = 'acme']); @@ -84,6 +86,34 @@ test('cache is invalidated when the tenant is updated', function (string $resolv RequestDataTenantResolver::class, ]); +test('cache is invalidated when the tenant is deleted', function (string $resolver) { + DB::statement('SET FOREIGN_KEY_CHECKS=0;'); // allow deleting the tenant + $tenant = Tenant::create(['id' => $tenantKey = 'acme']); + $tenant->createDomain($tenantKey); + + DB::enableQueryLog(); + + config(['tenancy.identification.resolvers.' . $resolver . '.cache' => true]); + + expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenantKey))))->toBeTrue(); + expect(DB::getQueryLog())->not()->toBeEmpty(); + + DB::flushQueryLog(); + + expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenantKey))))->toBeTrue(); + expect(DB::getQueryLog())->toBeEmpty(); + + $tenant->delete(); + DB::flushQueryLog(); + + expect(fn () => app($resolver)->resolve(getResolverArgument($resolver, $tenantKey)))->toThrow(TenantCouldNotBeIdentifiedException::class); + expect(DB::getQueryLog())->not()->toBeEmpty(); // Cache was invalidated, so the DB was queried +})->with([ + DomainTenantResolver::class, + PathTenantResolver::class, + RequestDataTenantResolver::class, +]); + test('cache is invalidated when a tenants domain is changed', function () { $tenant = Tenant::create(['id' => $tenantKey = 'acme']); $tenant->createDomain($tenantKey); @@ -110,6 +140,26 @@ test('cache is invalidated when a tenants domain is changed', function () { pest()->assertNotEmpty(DB::getQueryLog()); // not empty }); +test('cache is invalidated when a tenants domain is deleted', function () { + $tenant = Tenant::create(['id' => $tenantKey = 'acme']); + $tenant->createDomain($tenantKey); + + DB::enableQueryLog(); + + config(['tenancy.identification.resolvers.' . DomainTenantResolver::class . '.cache' => true]); + + expect($tenant->is(app(DomainTenantResolver::class)->resolve('acme')))->toBeTrue(); + DB::flushQueryLog(); + expect($tenant->is(app(DomainTenantResolver::class)->resolve('acme')))->toBeTrue(); + expect(DB::getQueryLog())->toBeEmpty(); // empty + + $tenant->domains->first()->delete(); + DB::flushQueryLog(); + + expect(fn () => $tenant->is(app(DomainTenantResolver::class)->resolve('acme')))->toThrow(TenantCouldNotBeIdentifiedOnDomainException::class); + expect(DB::getQueryLog())->not()->toBeEmpty(); // Cache was invalidated, so the DB was queried +}); + test('PathTenantResolver forgets the tenant route parameter when the tenant is resolved from cache', function() { config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.cache' => true]); DB::enableQueryLog(); diff --git a/tests/CloneActionTest.php b/tests/CloneActionTest.php index 8be8881b..3706f31e 100644 --- a/tests/CloneActionTest.php +++ b/tests/CloneActionTest.php @@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Route as RouteFacade; use Stancl\Tenancy\Tests\Etc\HasMiddlewareController; use Stancl\Tenancy\Middleware\InitializeTenancyByPath; use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; +use function Stancl\Tenancy\Tests\pest; test('a route can be universal using path identification', function (array $routeMiddleware, array $globalMiddleware) { foreach ($globalMiddleware as $middleware) { diff --git a/tests/CombinedDomainAndSubdomainIdentificationTest.php b/tests/CombinedDomainAndSubdomainIdentificationTest.php index 85f11182..1cae6408 100644 --- a/tests/CombinedDomainAndSubdomainIdentificationTest.php +++ b/tests/CombinedDomainAndSubdomainIdentificationTest.php @@ -3,10 +3,9 @@ declare(strict_types=1); use Illuminate\Support\Facades\Route; -use Stancl\Tenancy\Database\Concerns\HasDomains; use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain; -use Stancl\Tenancy\Database\Models; use Stancl\Tenancy\Tests\Etc\Tenant; +use function Stancl\Tenancy\Tests\pest; beforeEach(function () { Route::group([ diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index ad50ba1a..7ebb07a8 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -26,6 +26,7 @@ use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException; +use function Stancl\Tenancy\Tests\pest; beforeEach(function () { if (file_exists($schemaPath = 'tests/Etc/tenant-schema-test.dump')) { @@ -389,6 +390,21 @@ test('run command works when sub command asks questions and accepts arguments', expect($user->email)->toBe('email@localhost'); }); +test('run command accepts arguments and options correctly', function() { + $tenant = Tenant::create(); + $id = $tenant->getTenantKey(); + + // Use unquoted single-word arguments and quoted arguments with spaces + pest()->artisan("tenants:run \"bar username 'email@localhost' adsfg123 'some Arg' --option='some option'\" --tenants=$id") + ->expectsOutputToContain("Tenant: $id.") + ->expectsOutput("Name: username") + ->expectsOutput("Email: email@localhost") + ->expectsOutput("Password: adsfg123") + ->expectsOutput("Argument: some Arg") + ->expectsOutput("Option: some option") + ->assertExitCode(0); +}); + test('migrate fresh command only deletes tenant databases if drop_tenant_databases_on_migrate_fresh is true', function (bool $dropTenantDBsOnMigrateFresh) { Event::listen(DeletingTenant::class, JobPipeline::make([DeleteDomains::class])->send(function (DeletingTenant $event) { diff --git a/tests/DatabaseUsersTest.php b/tests/DatabaseUsersTest.php index d10aca57..aed487ac 100644 --- a/tests/DatabaseUsersTest.php +++ b/tests/DatabaseUsersTest.php @@ -24,6 +24,7 @@ use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMySQLData use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLSchemaManager; use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLDatabaseManager; use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMicrosoftSQLServerDatabaseManager; +use function Stancl\Tenancy\Tests\pest; beforeEach(function () { config([ diff --git a/tests/DomainTest.php b/tests/DomainTest.php index cb104532..e393f538 100644 --- a/tests/DomainTest.php +++ b/tests/DomainTest.php @@ -10,6 +10,7 @@ use Stancl\Tenancy\Exceptions\DomainOccupiedByOtherTenantException; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException; use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; use Stancl\Tenancy\Resolvers\DomainTenantResolver; +use function Stancl\Tenancy\Tests\pest; beforeEach(function () { InitializeTenancyByDomain::$onFail = null; diff --git a/tests/EarlyIdentificationTest.php b/tests/EarlyIdentificationTest.php index 4fbf6e3a..48ac4d12 100644 --- a/tests/EarlyIdentificationTest.php +++ b/tests/EarlyIdentificationTest.php @@ -24,6 +24,7 @@ use Stancl\Tenancy\Middleware\InitializeTenancyByOriginHeader; use Stancl\Tenancy\Tests\Etc\EarlyIdentification\ControllerWithMiddleware; use Stancl\Tenancy\Tests\Etc\EarlyIdentification\ControllerWithRouteMiddleware; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException; +use function Stancl\Tenancy\Tests\pest; beforeEach(function () { config()->set([ diff --git a/tests/Etc/Console/AnotherExampleCommand.php b/tests/Etc/Console/AnotherExampleCommand.php new file mode 100644 index 00000000..eba0a0c3 --- /dev/null +++ b/tests/Etc/Console/AnotherExampleCommand.php @@ -0,0 +1,31 @@ +line('Name: ' . $this->argument('name')); + $this->line('Email: ' . $this->argument('email')); + $this->line('Password: ' . $this->argument('password')); + $this->line('Argument: ' . $this->argument('arg')); + $this->line('Option: ' . $this->option('option')); + } +} diff --git a/tests/Etc/Console/ConsoleKernel.php b/tests/Etc/Console/ConsoleKernel.php index c5e5ee85..b46d21c7 100644 --- a/tests/Etc/Console/ConsoleKernel.php +++ b/tests/Etc/Console/ConsoleKernel.php @@ -10,6 +10,7 @@ class ConsoleKernel extends Kernel { protected $commands = [ ExampleCommand::class, + AnotherExampleCommand::class, ExampleQuestionCommand::class, AddUserCommand::class, ]; diff --git a/tests/EventListenerTest.php b/tests/EventListenerTest.php index d88d63de..5aeb4769 100644 --- a/tests/EventListenerTest.php +++ b/tests/EventListenerTest.php @@ -19,6 +19,7 @@ use Stancl\Tenancy\Events\BootstrappingTenancy; use Stancl\Tenancy\Listeners\QueueableListener; use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; +use function Stancl\Tenancy\Tests\pest; beforeEach(function () { FooListener::$shouldQueue = false; diff --git a/tests/Features/NoAttachTest.php b/tests/Features/NoAttachTest.php index 9ba6079d..a1588a24 100644 --- a/tests/Features/NoAttachTest.php +++ b/tests/Features/NoAttachTest.php @@ -18,14 +18,9 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Middleware\InitializeTenancyByPath; use Stancl\Tenancy\Tests\Etc\Tenant; +use function Stancl\Tenancy\Tests\pest; test('sqlite ATTACH statements can be blocked', function (bool $disallow) { - try { - readlink(base_path('vendor')); - } catch (\Throwable) { - symlink(base_path('vendor'), '/var/www/html/vendor'); - } - if (php_uname('m') == 'aarch64') { // Escape testbench prison. Can't hardcode /var/www/html/extensions/... here // since GHA doesn't mount the filesystem on the container's workdir diff --git a/tests/Features/RedirectTest.php b/tests/Features/RedirectTest.php index 7aca2e92..a4102070 100644 --- a/tests/Features/RedirectTest.php +++ b/tests/Features/RedirectTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); use Illuminate\Support\Facades\Route; use Stancl\Tenancy\Features\CrossDomainRedirect; use Stancl\Tenancy\Tests\Etc\Tenant; +use function Stancl\Tenancy\Tests\pest; test('tenant redirect macro replaces only the hostname', function () { config([ diff --git a/tests/Features/TenantConfigTest.php b/tests/Features/TenantConfigTest.php index 5c12c5f0..b06ddba9 100644 --- a/tests/Features/TenantConfigTest.php +++ b/tests/Features/TenantConfigTest.php @@ -9,6 +9,7 @@ use Stancl\Tenancy\Features\TenantConfig; use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Tests\Etc\Tenant; +use function Stancl\Tenancy\Tests\pest; afterEach(function () { TenantConfig::$storageToConfigMap = []; diff --git a/tests/MailTest.php b/tests/MailTest.php index c41b5578..be651765 100644 --- a/tests/MailTest.php +++ b/tests/MailTest.php @@ -10,6 +10,8 @@ use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Bootstrappers\MailConfigBootstrapper; +use function Stancl\Tenancy\Tests\pest; +use function Stancl\Tenancy\Tests\withTenantDatabases; beforeEach(function() { config(['mail.default' => 'smtp']); diff --git a/tests/MaintenanceModeTest.php b/tests/MaintenanceModeTest.php index 59024c96..9c90f0d3 100644 --- a/tests/MaintenanceModeTest.php +++ b/tests/MaintenanceModeTest.php @@ -8,6 +8,7 @@ use Stancl\Tenancy\Database\Concerns\MaintenanceMode; use Illuminate\Support\Facades\Route; use Stancl\Tenancy\Middleware\CheckTenantForMaintenanceMode; use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; +use function Stancl\Tenancy\Tests\pest; use Stancl\Tenancy\Tests\Etc\Tenant; beforeEach(function () { diff --git a/tests/ManualModeTest.php b/tests/ManualModeTest.php index f9983cf7..2e723586 100644 --- a/tests/ManualModeTest.php +++ b/tests/ManualModeTest.php @@ -11,7 +11,8 @@ use Stancl\Tenancy\Jobs\CreateDatabase; use Stancl\Tenancy\Listeners\CreateTenantConnection; use Stancl\Tenancy\Listeners\UseCentralConnection; use Stancl\Tenancy\Listeners\UseTenantConnection; -use \Stancl\Tenancy\Tests\Etc\Tenant; +use Stancl\Tenancy\Tests\Etc\Tenant; +use function Stancl\Tenancy\Tests\pest; test('manual tenancy initialization works', function () { Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { diff --git a/tests/OriginHeaderIdentificationTest.php b/tests/OriginHeaderIdentificationTest.php index 83737f1f..071aa493 100644 --- a/tests/OriginHeaderIdentificationTest.php +++ b/tests/OriginHeaderIdentificationTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); use Stancl\Tenancy\Tests\Etc\Tenant; use Illuminate\Support\Facades\Route; use Stancl\Tenancy\Middleware\InitializeTenancyByOriginHeader; +use function Stancl\Tenancy\Tests\pest; beforeEach(function () { InitializeTenancyByOriginHeader::$onFail = null; diff --git a/tests/PathIdentificationTest.php b/tests/PathIdentificationTest.php index 9fbaf68b..79cd3816 100644 --- a/tests/PathIdentificationTest.php +++ b/tests/PathIdentificationTest.php @@ -12,6 +12,7 @@ use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByPathException; use Stancl\Tenancy\Middleware\InitializeTenancyByPath; use Stancl\Tenancy\Resolvers\PathTenantResolver; use Stancl\Tenancy\Tests\Etc\Tenant; +use function Stancl\Tenancy\Tests\pest; beforeEach(function () { // Make sure the tenant parameter is set to 'tenant' @@ -34,6 +35,11 @@ beforeEach(function () { }); }); +afterEach(function () { + InitializeTenancyByPath::$onFail = null; + Tenant::$extraCustomColumns = []; +}); + test('tenant can be identified by path', function () { Tenant::create([ 'id' => 'acme', @@ -149,6 +155,7 @@ test('central route can have a parameter with the same name as the tenant parame config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_parameter_name' => 'team']); $tenantKey = Tenant::create()->getTenantKey(); + // The route is flagged as central (while using kernel identification) so the {team} parameter should not be used for tenancy initialization Route::get('/central/route/{team}/{a}/{b}', function ($team, $a, $b) { return "$a + $b + $team"; })->middleware('central')->name('central-route'); @@ -184,8 +191,6 @@ test('the tenant model column can be customized in the config', function () { $this->withoutExceptionHandling(); pest()->get('/acme/foo')->assertSee($tenant->getTenantKey()); expect(fn () => pest()->get($tenant->id . '/foo'))->toThrow(TenantCouldNotBeIdentifiedByPathException::class); - - Tenant::$extraCustomColumns = []; // static property reset }); test('the tenant model column can be customized in the route definition', function () { @@ -217,8 +222,6 @@ test('the tenant model column can be customized in the route definition', functi // Binding field defined pest()->get('/acme/bar')->assertSee($tenant->getTenantKey()); expect(fn () => pest()->get($tenant->id . '/bar'))->toThrow(TenantCouldNotBeIdentifiedByPathException::class); - - Tenant::$extraCustomColumns = []; // static property reset }); test('any extra model column needs to be whitelisted', function () { @@ -242,6 +245,4 @@ test('any extra model column needs to be whitelisted', function () { // After whitelisting the column it works config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.allowed_extra_model_columns' => ['slug']]); pest()->get('/acme/foo')->assertSee($tenant->getTenantKey()); - - Tenant::$extraCustomColumns = []; // static property reset }); diff --git a/tests/PendingTenantsTest.php b/tests/PendingTenantsTest.php index 26fd5c34..3339baaf 100644 --- a/tests/PendingTenantsTest.php +++ b/tests/PendingTenantsTest.php @@ -11,6 +11,7 @@ use Stancl\Tenancy\Events\PendingTenantCreated; use Stancl\Tenancy\Events\PendingTenantPulled; use Stancl\Tenancy\Events\PullingPendingTenant; use Stancl\Tenancy\Tests\Etc\Tenant; +use function Stancl\Tenancy\Tests\pest; test('tenants are correctly identified as pending', function (){ Tenant::createPending(); diff --git a/tests/Pest.php b/tests/Pest.php index 5380da0a..cd18d174 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,7 @@ in(__DIR__); -function pest(): TestCase -{ - return Pest\TestSuite::getInstance()->test; -} - function withTenantDatabases() { Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { return $event->tenant; })->toListener()); } + +function pest(): TestCase +{ + return \Pest\TestSuite::getInstance()->test; +} diff --git a/tests/PreventAccessFromUnwantedDomainsTest.php b/tests/PreventAccessFromUnwantedDomainsTest.php index 99d0c2fe..9c4764d2 100644 --- a/tests/PreventAccessFromUnwantedDomainsTest.php +++ b/tests/PreventAccessFromUnwantedDomainsTest.php @@ -11,6 +11,7 @@ use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain; use Stancl\Tenancy\Middleware\PreventAccessFromUnwantedDomains; use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain; use Stancl\Tenancy\Tests\Etc\EarlyIdentification\ControllerWithMiddleware; +use function Stancl\Tenancy\Tests\pest; test('correct routes are accessible in route-level identification', function (RouteMode $defaultRouteMode) { config()->set([ diff --git a/tests/QueueTest.php b/tests/QueueTest.php index 4d3f5ce0..25ab320e 100644 --- a/tests/QueueTest.php +++ b/tests/QueueTest.php @@ -22,16 +22,14 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; +use Stancl\Tenancy\Bootstrappers\PersistentQueueTenancyBootstrapper; use Stancl\Tenancy\Listeners\QueueableListener; +use function Stancl\Tenancy\Tests\pest; +use function Stancl\Tenancy\Tests\withTenantDatabases; beforeEach(function () { - $this->mockConsoleOutput = false; - config([ - 'tenancy.bootstrappers' => [ - QueueTenancyBootstrapper::class, - DatabaseTenancyBootstrapper::class, - ], + 'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class], 'queue.default' => 'redis', ]); @@ -45,7 +43,22 @@ afterEach(function () { pest()->valuestore->flush(); }); -test('tenant id is passed to tenant queues', function () { +dataset('queue_bootstrappers', [ + QueueTenancyBootstrapper::class, + PersistentQueueTenancyBootstrapper::class, +]); + +function withQueueBootstrapper(string $class) { + config(['tenancy.bootstrappers' => [ + DatabaseTenancyBootstrapper::class, + $class, + ]]); + + $class::__constructStatic(app()); +} + +test('tenant id is passed to tenant queues', function (string $bootstrapper) { + withQueueBootstrapper($bootstrapper); withTenantDatabases(); config(['queue.default' => 'sync']); @@ -61,9 +74,10 @@ test('tenant id is passed to tenant queues', function () { Event::assertDispatched(JobProcessing::class, function ($event) { return $event->job->payload()['tenant_id'] === tenant('id'); }); -}); +})->with('queue_bootstrappers'); -test('tenant id is not passed to central queues', function () { +test('tenant id is not passed to central queues', function (string $bootstrapper) { + withQueueBootstrapper($bootstrapper); withTenantDatabases(); $tenant = Tenant::create(); @@ -82,9 +96,10 @@ test('tenant id is not passed to central queues', function () { Event::assertDispatched(JobProcessing::class, function ($event) { return ! isset($event->job->payload()['tenant_id']); }); -}); +})->with('queue_bootstrappers'); -test('tenancy is initialized inside queues', function (bool $shouldEndTenancy) { +test('tenancy is initialized inside queues', function (bool $shouldEndTenancy, string $bootstrapper) { + withQueueBootstrapper($bootstrapper); withTenantDatabases(); withFailedJobs(); @@ -117,7 +132,7 @@ test('tenancy is initialized inside queues', function (bool $shouldEndTenancy) { $tenant->run(function () use ($user) { expect($user->fresh()->name)->toBe('Bar'); }); -})->with([true, false]); +})->with([true, false])->with('queue_bootstrappers'); test('changing the shouldQueue static property in parent class affects child classes unless the property is redefined', function () { // Parent – $shouldQueue is true @@ -142,7 +157,8 @@ test('changing the shouldQueue static property in parent class affects child cla expect(app(ShouldNotQueueListener::class)->shouldQueue(new stdClass()))->toBeFalse(); }); -test('tenancy is initialized when retrying jobs', function (bool $shouldEndTenancy) { +test('tenancy is initialized when retrying jobs', function (bool $shouldEndTenancy, string $bootstrapper) { + withQueueBootstrapper($bootstrapper); withFailedJobs(); withTenantDatabases(); @@ -189,9 +205,10 @@ test('tenancy is initialized when retrying jobs', function (bool $shouldEndTenan $tenant->run(function () use ($user) { expect($user->fresh()->name)->toBe('Bar'); }); -})->with([true, false]); +})->with([true, false])->with('queue_bootstrappers'); -test('the tenant used by the job doesnt change when the current tenant changes', function () { +test('the tenant used by the job doesnt change when the current tenant changes', function (string $bootstrapper) { + withQueueBootstrapper($bootstrapper); withTenantDatabases(); $tenant1 = Tenant::create(); @@ -208,26 +225,11 @@ test('the tenant used by the job doesnt change when the current tenant changes', pest()->artisan('queue:work --once'); expect(pest()->valuestore->get('tenant_id'))->toBe('The current tenant id is: ' . $tenant1->getTenantKey()); -}); - -test('tenant connections do not persist after tenant jobs get processed', function() { - withTenantDatabases(); - - $tenant = Tenant::create(); - - tenancy()->initialize($tenant); - - dispatch(new TestJob(pest()->valuestore)); - - tenancy()->end(); - - pest()->artisan('queue:work --once'); - - expect(collect(DB::select('SHOW FULL PROCESSLIST'))->pluck('db'))->not()->toContain($tenant->database()->getName()); -}); +})->with('queue_bootstrappers'); // Regression test for #1277 -test('dispatching a job from a tenant run arrow function dispatches it immediately', function () { +test('dispatching a job from a tenant run arrow function dispatches it immediately', function (string $bootstrapper) { + withQueueBootstrapper($bootstrapper); withTenantDatabases(); $tenant = Tenant::create(); @@ -241,7 +243,7 @@ test('dispatching a job from a tenant run arrow function dispatches it immediate expect(tenant())->toBe(null); expect(pest()->valuestore->get('tenant_id'))->toBe('The current tenant id is: ' . $tenant->getTenantKey()); -}); +})->with('queue_bootstrappers'); function createValueStore(): void { diff --git a/tests/RLS/PolicyTest.php b/tests/RLS/PolicyTest.php index 7c7165bc..7278776a 100644 --- a/tests/RLS/PolicyTest.php +++ b/tests/RLS/PolicyTest.php @@ -17,6 +17,7 @@ use Stancl\Tenancy\Commands\CreateUserWithRLSPolicies; use Stancl\Tenancy\RLS\PolicyManagers\TableRLSManager; use Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager; use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper; +use function Stancl\Tenancy\Tests\pest; beforeEach(function () { TraitRLSManager::$excludedModels = [Article::class]; diff --git a/tests/RLS/TableManagerTest.php b/tests/RLS/TableManagerTest.php index 84689a72..fd9c6f44 100644 --- a/tests/RLS/TableManagerTest.php +++ b/tests/RLS/TableManagerTest.php @@ -19,6 +19,7 @@ use Stancl\Tenancy\Commands\CreateUserWithRLSPolicies; use Stancl\Tenancy\RLS\PolicyManagers\TableRLSManager; use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper; use Stancl\Tenancy\Database\Exceptions\RecursiveRelationshipException; +use function Stancl\Tenancy\Tests\pest; beforeEach(function () { TableRLSManager::$scopeByDefault = true; @@ -502,7 +503,7 @@ test('table rls manager generates relationship trees with tables related to the // Add non-nullable comment_id foreign key Schema::table('ratings', function (Blueprint $table) { - $table->foreignId('comment_id')->onUpdate('cascade')->onDelete('cascade')->comment('rls')->constrained('comments'); + $table->foreignId('comment_id')->comment('rls')->constrained('comments')->onUpdate('cascade')->onDelete('cascade'); }); // Non-nullable paths are preferred over nullable paths @@ -639,16 +640,29 @@ test('table rls manager generates queries correctly', function() { test('table manager throws an exception when encountering a recursive relationship', function() { Schema::create('recursive_posts', function (Blueprint $table) { $table->id(); - $table->foreignId('highlighted_comment_id')->constrained('comments')->nullable()->comment('rls'); + $table->foreignId('highlighted_comment_id')->nullable()->comment('rls')->constrained('comments'); }); Schema::table('comments', function (Blueprint $table) { - $table->foreignId('recursive_post_id')->constrained('recursive_posts')->comment('rls'); + $table->foreignId('recursive_post_id')->comment('rls')->constrained('recursive_posts'); }); expect(fn () => app(TableRLSManager::class)->generateTrees())->toThrow(RecursiveRelationshipException::class); }); +test('table manager ignores recursive relationship if the foreign key responsible for the recursion has no-rls comment', function() { + Schema::create('recursive_posts', function (Blueprint $table) { + $table->id(); + $table->foreignId('highlighted_comment_id')->nullable()->comment('no-rls')->constrained('comments'); + }); + + Schema::table('comments', function (Blueprint $table) { + $table->foreignId('recursive_post_id')->comment('rls')->constrained('recursive_posts'); + }); + + expect(fn () => app(TableRLSManager::class)->generateTrees())->not()->toThrow(RecursiveRelationshipException::class); +}); + class Post extends Model { protected $guarded = []; diff --git a/tests/RLS/TraitManagerTest.php b/tests/RLS/TraitManagerTest.php index 7a7dd37a..af2f6f84 100644 --- a/tests/RLS/TraitManagerTest.php +++ b/tests/RLS/TraitManagerTest.php @@ -25,6 +25,7 @@ use Stancl\Tenancy\Commands\CreateUserWithRLSPolicies; use Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager; use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper; use Stancl\Tenancy\Database\Concerns\BelongsToPrimaryModel; +use function Stancl\Tenancy\Tests\pest; beforeEach(function () { TraitRLSManager::$implicitRLS = true; diff --git a/tests/RequestDataIdentificationTest.php b/tests/RequestDataIdentificationTest.php index 70792adb..0f635bf1 100644 --- a/tests/RequestDataIdentificationTest.php +++ b/tests/RequestDataIdentificationTest.php @@ -2,10 +2,13 @@ declare(strict_types=1); +use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Route; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByRequestDataException; use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData; +use Stancl\Tenancy\Resolvers\RequestDataTenantResolver; use Stancl\Tenancy\Tests\Etc\Tenant; +use function Stancl\Tenancy\Tests\pest; beforeEach(function () { config([ @@ -14,45 +17,90 @@ beforeEach(function () { ], ]); - InitializeTenancyByRequestData::$header = 'X-Tenant'; - InitializeTenancyByRequestData::$cookie = 'X-Tenant'; - InitializeTenancyByRequestData::$queryParameter = 'tenant'; - - Route::middleware(['tenant', InitializeTenancyByRequestData::class])->get('/test', function () { + Route::middleware([InitializeTenancyByRequestData::class])->get('/test', function () { return 'Tenant id: ' . tenant('id'); }); }); -test('header identification works', function () { - $tenant = Tenant::create(); +test('header identification works', function (string|null $tenantModelColumn) { + if ($tenantModelColumn) { + Schema::table('tenants', function (Blueprint $table) use ($tenantModelColumn) { + $table->string($tenantModelColumn)->unique(); + }); + Tenant::$extraCustomColumns = [$tenantModelColumn]; + } - $this - ->withoutExceptionHandling() - ->withHeader('X-Tenant', $tenant->id) - ->get('test') - ->assertSee($tenant->id); -}); + config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.tenant_model_column' => $tenantModelColumn]); -test('query parameter identification works', function () { - $tenant = Tenant::create(); + $tenant = Tenant::create($tenantModelColumn ? [$tenantModelColumn => 'acme'] : []); + $payload = $tenantModelColumn ? 'acme' : $tenant->id; - $this - ->withoutExceptionHandling() - ->get('test?tenant=' . $tenant->id) - ->assertSee($tenant->id); -}); + // Default header name + $this->withoutExceptionHandling()->withHeader('X-Tenant', $payload)->get('test')->assertSee($tenant->id); -test('cookie identification works', function () { - $tenant = Tenant::create(); + // Custom header name + config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.header' => 'X-Custom-Tenant']); + $this->withoutExceptionHandling()->withHeader('X-Custom-Tenant', $payload)->get('test')->assertSee($tenant->id); - $this - ->withoutExceptionHandling() - ->withUnencryptedCookie('X-Tenant', $tenant->id) - ->get('test') - ->assertSee($tenant->id); -}); + // Setting the header to null disables header identification + config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.header' => null]); + expect(fn () => $this->withoutExceptionHandling()->withHeader('X-Tenant', $payload)->get('test'))->toThrow(TenantCouldNotBeIdentifiedByRequestDataException::class); +})->with([null, 'slug']); -test('middleware throws exception when tenant data is not provided in the request', function () { +test('query parameter identification works', function (string|null $tenantModelColumn) { + if ($tenantModelColumn) { + Schema::table('tenants', function (Blueprint $table) use ($tenantModelColumn) { + $table->string($tenantModelColumn)->unique(); + }); + Tenant::$extraCustomColumns = [$tenantModelColumn]; + } + + config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.tenant_model_column' => $tenantModelColumn]); + + $tenant = Tenant::create($tenantModelColumn ? [$tenantModelColumn => 'acme'] : []); + $payload = $tenantModelColumn ? 'acme' : $tenant->id; + + // Default query parameter name + $this->withoutExceptionHandling()->get('test?tenant=' . $payload)->assertSee($tenant->id); + + // Custom query parameter name + config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.query_parameter' => 'custom_tenant']); + $this->withoutExceptionHandling()->get('test?custom_tenant=' . $payload)->assertSee($tenant->id); + + // Setting the query parameter to null disables query parameter identification + config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.query_parameter' => null]); + expect(fn () => $this->withoutExceptionHandling()->get('test?tenant=' . $payload))->toThrow(TenantCouldNotBeIdentifiedByRequestDataException::class); +})->with([null, 'slug']); + +test('cookie identification works', function (string|null $tenantModelColumn) { + if ($tenantModelColumn) { + Schema::table('tenants', function (Blueprint $table) use ($tenantModelColumn) { + $table->string($tenantModelColumn)->unique(); + }); + Tenant::$extraCustomColumns = [$tenantModelColumn]; + } + + config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.tenant_model_column' => $tenantModelColumn]); + + $tenant = Tenant::create($tenantModelColumn ? [$tenantModelColumn => 'acme'] : []); + $payload = $tenantModelColumn ? 'acme' : $tenant->id; + + // Default cookie name + $this->withoutExceptionHandling()->withUnencryptedCookie('tenant', $payload)->get('test')->assertSee($tenant->id); + + // Custom cookie name + config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.cookie' => 'custom_tenant_id']); + $this->withoutExceptionHandling()->withUnencryptedCookie('custom_tenant_id', $payload)->get('test')->assertSee($tenant->id); + + // Setting the cookie to null disables cookie identification + config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.cookie' => null]); + expect(fn () => $this->withoutExceptionHandling()->withUnencryptedCookie('tenant', $payload)->get('test'))->toThrow(TenantCouldNotBeIdentifiedByRequestDataException::class); +})->with([null, 'slug']); + +// todo@tests encrypted cookie + +test('an exception is thrown when no tenant data is provided in the request', function () { pest()->expectException(TenantCouldNotBeIdentifiedByRequestDataException::class); $this->withoutExceptionHandling()->get('test'); }); + diff --git a/tests/ResourceSyncingTest.php b/tests/ResourceSyncingTest.php index 399bf994..cb1d40a9 100644 --- a/tests/ResourceSyncingTest.php +++ b/tests/ResourceSyncingTest.php @@ -46,6 +46,7 @@ use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSavedInForeignDatabase; use Illuminate\Database\Eloquent\Scope; use Illuminate\Database\Eloquent\Attributes\ScopedBy; use Illuminate\Database\QueryException; +use function Stancl\Tenancy\Tests\pest; beforeEach(function () { config(['tenancy.bootstrappers' => [ diff --git a/tests/RunForMultipleTest.php b/tests/RunForMultipleTest.php new file mode 100644 index 00000000..c82385f6 --- /dev/null +++ b/tests/RunForMultipleTest.php @@ -0,0 +1,73 @@ +send(function (TenantCreated $event) { + return $event->tenant; + })->toListener()); + + config(['tenancy.bootstrappers' => [ + DatabaseTenancyBootstrapper::class, + ]]); +}); + +test('runForMultiple runs the passed closure for the right tenants', function() { + $tenants = [Tenant::create(), Tenant::create(), Tenant::create()]; + + $createUser = fn ($username) => function () use ($username) { + User::create(['name' => $username, 'email' => Str::random(8) . '@example.com', 'password' => bcrypt('password')]); + }; + + // tenancy()->runForMultiple([], ...) shouldn't do anything + // No users should be created -- the closure should not run at all + tenancy()->runForMultiple([], $createUser('none')); + // Try the same with an empty collection -- the result should be the same for any traversable + tenancy()->runForMultiple(collect(), $createUser('none')); + + foreach ($tenants as $tenant) { + $tenant->run(function() { + expect(User::count())->toBe(0); + }); + } + + // tenancy()->runForMultiple(['foo', 'bar'], ...) should run the closure only for the passed tenants + tenancy()->runForMultiple([$tenants[0]->getTenantKey(), $tenants[1]->getTenantKey()], $createUser('user')); + + // User should be created for tenants[0] and tenants[1], but not for tenants[2] + foreach ($tenants as $tenant) { + $tenant->run(function() use ($tenants) { + if (tenant()->getTenantKey() !== $tenants[2]->getTenantKey()) { + expect(User::first()->name)->toBe('user'); + } else { + expect(User::count())->toBe(0); + } + }); + } + + // tenancy()->runForMultiple(null, ...) should run the closure for all tenants + tenancy()->runForMultiple(null, $createUser('new_user')); + + foreach ($tenants as $tenant) { + $tenant->run(function() { + expect(User::all()->pluck('name'))->toContain('new_user'); + }); + } +}); diff --git a/tests/ScopeSessionsTest.php b/tests/ScopeSessionsTest.php index 5a8a9e51..4fccac58 100644 --- a/tests/ScopeSessionsTest.php +++ b/tests/ScopeSessionsTest.php @@ -8,6 +8,7 @@ use Stancl\Tenancy\Exceptions\TenancyNotInitializedException; use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain; use Stancl\Tenancy\Middleware\ScopeSessions; use Stancl\Tenancy\Tests\Etc\Tenant; +use function Stancl\Tenancy\Tests\pest; beforeEach(function () { Route::group([ @@ -54,3 +55,15 @@ test('an exception is thrown when the middleware is executed before tenancy is i pest()->expectException(TenancyNotInitializedException::class); $this->withoutExceptionHandling()->get('http://acme.localhost/bar'); }); + +test('scope sessions mw can be used on universal routes', function() { + Route::get('/universal', function () { + return true; + })->middleware(['universal', InitializeTenancyBySubdomain::class, ScopeSessions::class]); + + Tenant::create([ + 'id' => 'acme', + ])->createDomain('acme'); + + pest()->withoutExceptionHandling()->get('http://localhost/universal')->assertSuccessful(); +}); diff --git a/tests/SessionSeparationTest.php b/tests/SessionSeparationTest.php index 6706a18e..02b018d1 100644 --- a/tests/SessionSeparationTest.php +++ b/tests/SessionSeparationTest.php @@ -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 diff --git a/tests/SingleDatabaseTenancyTest.php b/tests/SingleDatabaseTenancyTest.php index c71e6d38..c0d3aef3 100644 --- a/tests/SingleDatabaseTenancyTest.php +++ b/tests/SingleDatabaseTenancyTest.php @@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Stancl\Tenancy\Database\Concerns\BelongsToTenant; use Stancl\Tenancy\Database\Concerns\BelongsToPrimaryModel; use Stancl\Tenancy\Database\Concerns\HasScopedValidationRules; +use function Stancl\Tenancy\Tests\pest; beforeEach(function () { Schema::create('posts', function (Blueprint $table) { diff --git a/tests/SingleDomainTenantTest.php b/tests/SingleDomainTenantTest.php index 3a68ee8b..49bd7d95 100644 --- a/tests/SingleDomainTenantTest.php +++ b/tests/SingleDomainTenantTest.php @@ -11,6 +11,7 @@ use Illuminate\Database\UniqueConstraintViolationException; use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException; use Stancl\Tenancy\Middleware\PreventAccessFromUnwantedDomains; +use function Stancl\Tenancy\Tests\pest; beforeEach(function () { config(['tenancy.models.tenant' => SingleDomainTenant::class]); diff --git a/tests/SubdomainTest.php b/tests/SubdomainTest.php index 9ddc48ba..a7cc58ae 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 function Stancl\Tenancy\Tests\pest; beforeEach(function () { // Global state cleanup after some tests diff --git a/tests/TenantAssetTest.php b/tests/TenantAssetTest.php index f7191831..5c223fe2 100644 --- a/tests/TenantAssetTest.php +++ b/tests/TenantAssetTest.php @@ -19,6 +19,7 @@ use Stancl\Tenancy\Controllers\TenantAssetController; use Stancl\Tenancy\Events\TenancyEnded; use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Overrides\TenancyUrlGenerator; +use function Stancl\Tenancy\Tests\pest; beforeEach(function () { config(['tenancy.bootstrappers' => [ @@ -65,7 +66,7 @@ test('asset can be accessed using the url returned by the tenant asset helper', test('asset helper returns a link to tenant asset controller when asset url is null', function () { config(['app.asset_url' => null]); - config(['tenancy.filesystem.asset_helper_tenancy' => true]); + config(['tenancy.filesystem.asset_helper_override' => true]); $tenant = Tenant::create(); tenancy()->initialize($tenant); @@ -78,7 +79,7 @@ test('asset helper returns a link to tenant asset controller when asset url is n test('asset helper returns a link to an external url when asset url is not null', function () { config(['app.asset_url' => 'https://an-s3-bucket']); - config(['tenancy.filesystem.asset_helper_tenancy' => true]); + config(['tenancy.filesystem.asset_helper_override' => true]); $tenant = Tenant::create(); tenancy()->initialize($tenant); @@ -93,7 +94,7 @@ test('asset helper works correctly with path identification', function (bool $ke TenancyUrlGenerator::$prefixRouteNames = true; TenancyUrlGenerator::$passTenantParameterToRoutes = true; - config(['tenancy.filesystem.asset_helper_tenancy' => true]); + config(['tenancy.filesystem.asset_helper_override' => true]); config(['tenancy.identification.default_middleware' => InitializeTenancyByPath::class]); config(['tenancy.bootstrappers' => array_merge([UrlGeneratorBootstrapper::class], config('tenancy.bootstrappers'))]); @@ -165,7 +166,7 @@ test('asset helper tenancy can be disabled', function () { config([ 'app.asset_url' => null, - 'tenancy.filesystem.asset_helper_tenancy' => false, + 'tenancy.filesystem.asset_helper_override' => false, ]); $tenant = Tenant::create(); diff --git a/tests/TenantAwareCommandTest.php b/tests/TenantAwareCommandTest.php index fe49685e..764e5a9b 100644 --- a/tests/TenantAwareCommandTest.php +++ b/tests/TenantAwareCommandTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use Stancl\Tenancy\Tests\Etc\Tenant; +use function Stancl\Tenancy\Tests\pest; test('commands run globally are tenant aware and return valid exit code', function () { $tenant1 = Tenant::create(); diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 0b5376d9..c41ea35a 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -28,6 +28,7 @@ use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMySQLData use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLSchemaManager; use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLDatabaseManager; use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMicrosoftSQLServerDatabaseManager; +use function Stancl\Tenancy\Tests\pest; beforeEach(function () { SQLiteDatabaseManager::$path = null; @@ -405,6 +406,42 @@ test('tenant database can be created by using the username and password from ten expect($manager->databaseExists($name))->toBeTrue(); }); +test('decrypted password can be used to connect to a tenant db while the password is saved as encrypted', function (string|null $tenantDbPassword) { + config([ + 'tenancy.database.managers.mysql' => PermissionControlledMySQLDatabaseManager::class, + 'tenancy.database.template_tenant_connection' => 'mysql', + ]); + + Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener()); + + // Create a tenant, either with a specific password, or with a password generated by the DB manager + $tenant = TenantWithEncryptedPassword::create([ + 'tenancy_db_name' => $name = 'foo' . Str::random(8), + 'tenancy_db_username' => 'user' . Str::random(4), + 'tenancy_db_password' => $tenantDbPassword, + ]); + + $decryptedPassword = $tenant->tenancy_db_password; + $encryptedPassword = $tenant->getAttributes()['tenancy_db_password']; // Password encrypted using the TenantWithEncryptedPassword model's encrypted cast + expect($decryptedPassword)->not()->toBe($encryptedPassword); + + $passwordSavedInDatabase = json_decode(DB::select('SELECT data FROM tenants LIMIT 1')[0]->data)->tenancy_db_password; + expect($encryptedPassword)->toBe($passwordSavedInDatabase); + + app(DatabaseManager::class)->connectToTenant($tenant); + + // Check if we got connected to the tenant DB + expect(config('database.default'))->toBe('tenant'); + expect(config('database.connections.tenant.database'))->toBe($name); + // Check if the decrypted password is used to connect to the tenant DB + expect(config('database.connections.tenant.password'))->toBe($decryptedPassword); +})->with([ + 'decrypted' . Str::random(8), // Use this password as the tenant DB password + null, // Let the DB manager generate the tenant DB password +]); + test('path used by sqlite manager can be customized', function () { Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { return $event->tenant; @@ -529,3 +566,13 @@ function createUsersTable() $table->timestamps(); }); } + +class TenantWithEncryptedPassword extends Tenant +{ + protected function casts(): array + { + return [ + 'tenancy_db_password' => 'encrypted', + ]; + } +} diff --git a/tests/TenantModelTest.php b/tests/TenantModelTest.php index ca3c4902..4c6e77e1 100644 --- a/tests/TenantModelTest.php +++ b/tests/TenantModelTest.php @@ -20,7 +20,16 @@ use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\UniqueIdentifierGenerators\UUIDGenerator; use Stancl\Tenancy\Exceptions\TenancyNotInitializedException; use Stancl\Tenancy\UniqueIdentifierGenerators\RandomHexGenerator; +use Stancl\Tenancy\UniqueIdentifierGenerators\RandomIntGenerator; use Stancl\Tenancy\UniqueIdentifierGenerators\RandomStringGenerator; +use Stancl\Tenancy\UniqueIdentifierGenerators\ULIDGenerator; + +use function Stancl\Tenancy\Tests\pest; + +afterEach(function () { + RandomIntGenerator::$min = 0; + RandomIntGenerator::$max = PHP_INT_MAX; +}); test('created event is dispatched', function () { Event::fake([TenantCreated::class]); @@ -71,6 +80,20 @@ test('autoincrement ids are supported', function () { expect($tenant2->id)->toBe(2); }); +test('ulid ids are supported', function () { + app()->bind(UniqueIdentifierGenerator::class, ULIDGenerator::class); + + $tenant1 = Tenant::create(); + expect($tenant1->id)->toBeString(); + expect(strlen($tenant1->id))->toBe(26); + + $tenant2 = Tenant::create(); + expect($tenant2->id)->toBeString(); + expect(strlen($tenant2->id))->toBe(26); + + expect($tenant2->id > $tenant1->id)->toBeTrue(); +}); + test('hex ids are supported', function () { app()->bind(UniqueIdentifierGenerator::class, RandomHexGenerator::class); @@ -87,6 +110,16 @@ test('hex ids are supported', function () { RandomHexGenerator::$bytes = 6; // reset }); +test('random ints are supported', function () { + app()->bind(UniqueIdentifierGenerator::class, RandomIntGenerator::class); + RandomIntGenerator::$min = 200; + RandomIntGenerator::$max = 1000; + + $tenant1 = Tenant::create(); + expect($tenant1->id >= 200)->toBeTrue(); + expect($tenant1->id <= 1000)->toBeTrue(); +}); + test('random string ids are supported', function () { app()->bind(UniqueIdentifierGenerator::class, RandomStringGenerator::class); diff --git a/tests/TenantUserImpersonationTest.php b/tests/TenantUserImpersonationTest.php index 1e72c604..8c9c4124 100644 --- a/tests/TenantUserImpersonationTest.php +++ b/tests/TenantUserImpersonationTest.php @@ -25,6 +25,7 @@ use Stancl\Tenancy\Middleware\InitializeTenancyByPath; use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Exceptions\StatefulGuardRequiredException; +use function Stancl\Tenancy\Tests\pest; beforeEach(function () { pest()->artisan('migrate', [ @@ -88,7 +89,7 @@ test('tenant user can be impersonated on a tenant domain', function () { expect(session('tenancy_impersonating'))->toBeTrue(); // Leave impersonation - UserImpersonation::leave(); + UserImpersonation::stopImpersonating(); expect(UserImpersonation::isImpersonating())->toBeFalse(); expect(session('tenancy_impersonating'))->toBeNull(); @@ -134,7 +135,7 @@ test('tenant user can be impersonated on a tenant path', function () { expect(session('tenancy_impersonating'))->toBeTrue(); // Leave impersonation - UserImpersonation::leave(); + UserImpersonation::stopImpersonating(); expect(UserImpersonation::isImpersonating())->toBeFalse(); expect(session('tenancy_impersonating'))->toBeNull(); diff --git a/tests/TestCase.php b/tests/TestCase.php index f9a2d357..6a167c46 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -22,6 +22,8 @@ use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper; use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper; use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper; +use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; +use function Stancl\Tenancy\Tests\pest; abstract class TestCase extends \Orchestra\Testbench\TestCase { @@ -85,11 +87,8 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase '--realpath' => true, ]); - // Laravel 6.x support todo@refactor clean up - $testResponse = class_exists('Illuminate\Testing\TestResponse') ? 'Illuminate\Testing\TestResponse' : 'Illuminate\Foundation\Testing\TestResponse'; - $testResponse::macro('assertContent', function ($content) { - $assertClass = class_exists('Illuminate\Testing\Assert') ? 'Illuminate\Testing\Assert' : 'Illuminate\Foundation\Testing\Assert'; - $assertClass::assertSame($content, $this->baseResponse->getContent()); + \Illuminate\Testing\TestResponse::macro('assertContent', function ($content) { + \Illuminate\Testing\Assert::assertSame($content, $this->baseResponse->getContent()); return $this; }); @@ -175,18 +174,25 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase 'tenancy.models.tenant' => Tenant::class, // Use test tenant w/ DBs & domains ]); - $app->singleton(RedisTenancyBootstrapper::class); // todo@samuel use proper approach eg config for singleton registration - $app->singleton(CacheTenancyBootstrapper::class); // todo@samuel use proper approach eg config for singleton registration + // Since we run the TSP with no bootstrappers enabled, we need + // to manually register bootstrappers as singletons here. + $app->singleton(RedisTenancyBootstrapper::class); + $app->singleton(CacheTenancyBootstrapper::class); $app->singleton(BroadcastingConfigBootstrapper::class); $app->singleton(BroadcastChannelPrefixBootstrapper::class); $app->singleton(PostgresRLSBootstrapper::class); $app->singleton(MailConfigBootstrapper::class); $app->singleton(RootUrlBootstrapper::class); $app->singleton(UrlGeneratorBootstrapper::class); + $app->singleton(FilesystemTenancyBootstrapper::class); } protected function getPackageProviders($app) { + TenancyServiceProvider::$configure = function () { + config(['tenancy.bootstrappers' => []]); + }; + return [ TenancyServiceProvider::class, ]; diff --git a/tests/UniversalRouteTest.php b/tests/UniversalRouteTest.php index 528d46bf..c8df0ab0 100644 --- a/tests/UniversalRouteTest.php +++ b/tests/UniversalRouteTest.php @@ -2,8 +2,6 @@ declare(strict_types=1); -use Stancl\Tenancy\Tenancy; -use Illuminate\Http\Request; use Stancl\Tenancy\Enums\RouteMode; use Stancl\Tenancy\Tests\Etc\Tenant; use Illuminate\Contracts\Http\Kernel; @@ -11,16 +9,14 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver; use Illuminate\Routing\Controller as BaseController; use Illuminate\Support\Facades\Route as RouteFacade; use Stancl\Tenancy\Tests\Etc\HasMiddlewareController; -use Stancl\Tenancy\Middleware\IdentificationMiddleware; -use Stancl\Tenancy\Resolvers\RequestDataTenantResolver; use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; -use Stancl\Tenancy\Concerns\UsableWithEarlyIdentification; use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain; use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData; use Stancl\Tenancy\Middleware\PreventAccessFromUnwantedDomains; use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByRequestDataException; +use function Stancl\Tenancy\Tests\pest; test('a route can be universal using domain identification', function (array $routeMiddleware, array $globalMiddleware) { foreach ($globalMiddleware as $middleware) {