From eecf6f21c8a5118e29e5edc0fb7585b157ff6c1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 9 Apr 2024 20:40:27 +0200 Subject: [PATCH] Cache prefixing logic rewrite, session scoping improvements, tests refactor (#43) * Run cache tests on all supported drivers * update ci healthcheck for memcached * remove memcached healthcheck * fix typos in test comments, expand internal.md [ci skip] * add empty line [ci skip] * switch to using $store->setPrefix() * add dynamodb * refactor try-finally to try-catch * remove unnecessary clearResolvedInstances() call * add dual Cache:: and cache() assertions * add apc * Flush APCu cache in test setup * Revert "add dual Cache:: and cache() assertions" This reverts commit a0bab162fbe2dd0d25e7056ceca4fb7ce54efc77. * phpstan fix * Add logic for scoping 'file' disks to FilesystemTenancyBootstrapper * minor changes, add todos * refactor how the session.connection is used in the DB session bootstrapper * add session forgery prevention logic to the db session bootstrapper * only use the fs bootstrapper for file disk in 'cache data is separated' dataset * minor session scoping test changes * Add session scoping logic to FilesystemTenancyBootstrapper, correctly update disk roots even with storage_path_tenancy disabled * Fix code style (php-cs-fixer) * update docblock * make not-null check more explicit * separate bootstrapper tests, fix swapped test names for two tests * refactor cache bootstrapper tests * resolve global cache todo * expand tests: session separation tests, more filesystem separation assertions; change prefix_base-type config keys to templates/formats * add apc session scoping test, various session separation bugfixes * phpstan + minor logic fixes * prefix_format -> prefix * fix database session separation test * revert composer.json changes, update laravel dependencies to expected next release * only run session scoping logic in cache bootstrapper for redis, memcached, dynamodb, apc; update gitattributes * tenancy.central_domains -> tenancy.identification.central_domains * db session separation test: add datasets --------- Co-authored-by: PHP CS Fixer --- .gitattributes | 19 +- .github/workflows/ci.yml | 12 + CONTRIBUTING.md | 2 + Dockerfile | 22 +- INTERNAL.md | 7 +- assets/config.php | 43 +- assets/tenant_routes.stub.php | 8 +- composer.json | 7 +- docker-compose.yml | 46 +- .../CacheTenancyBootstrapper.php | 144 ++- .../DatabaseSessionBootstrapper.php | 29 +- .../FilesystemTenancyBootstrapper.php | 81 +- .../RedisTenancyBootstrapper.php | 2 +- src/Middleware/InitializeTenancyByDomain.php | 2 +- .../InitializeTenancyByDomainOrSubdomain.php | 2 +- .../InitializeTenancyBySubdomain.php | 4 +- .../PreventAccessFromUnwantedDomains.php | 8 +- src/Middleware/ScopeSessions.php | 7 +- src/Tenancy.php | 10 + tests/BootstrapperTest.php | 916 ------------------ tests/Bootstrappers/BootstrapperTest.php | 248 +++++ ...BroadcastChannelPrefixBootstrapperTest.php | 142 +++ .../BroadcastingConfigBootstrapperTest.php | 105 ++ .../CacheTagsBootstrapperTest.php | 5 +- .../CacheTenancyBootstrapperTest.php | 213 ++-- .../DatabaseSessionBootstrapperTest.php | 6 +- .../DatabaseTenancyBootstrapper.php | 32 + .../FilesystemTenancyBootstrapperTest.php | 202 ++++ .../FortifyRouteBootstrapperTest.php | 76 ++ .../Bootstrappers/MailTenancyBootstrapper.php | 62 ++ .../Bootstrappers/RootUrlBootstrapperTest.php | 67 ++ .../UrlGeneratorBootstrapperTest.php | 157 +++ ...edDomainAndSubdomainIdentificationTest.php | 4 +- tests/GlobalCacheTest.php | 8 + tests/OriginHeaderIdentificationTest.php | 2 +- tests/RequestDataIdentificationTest.php | 2 +- tests/ScopeSessionsTest.php | 25 +- tests/SessionSeparationTest.php | 247 +++++ tests/SubdomainTest.php | 2 +- tests/TestCase.php | 57 +- 40 files changed, 1856 insertions(+), 1177 deletions(-) delete mode 100644 tests/BootstrapperTest.php create mode 100644 tests/Bootstrappers/BootstrapperTest.php create mode 100644 tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php create mode 100644 tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php rename tests/{ => Bootstrappers}/CacheTagsBootstrapperTest.php (93%) rename tests/{ => Bootstrappers}/CacheTenancyBootstrapperTest.php (69%) rename tests/{ => Bootstrappers}/DatabaseSessionBootstrapperTest.php (96%) create mode 100644 tests/Bootstrappers/DatabaseTenancyBootstrapper.php create mode 100644 tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php create mode 100644 tests/Bootstrappers/FortifyRouteBootstrapperTest.php create mode 100644 tests/Bootstrappers/MailTenancyBootstrapper.php create mode 100644 tests/Bootstrappers/RootUrlBootstrapperTest.php create mode 100644 tests/Bootstrappers/UrlGeneratorBootstrapperTest.php create mode 100644 tests/SessionSeparationTest.php diff --git a/.gitattributes b/.gitattributes index 7d68b812..13a38ca6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,15 +3,22 @@ # Ignore all test and documentation with "export-ignore". /.github export-ignore -/art export-ignore -/tests export-ignore /.gitattributes export-ignore /.gitignore export-ignore /.styleci.yml export-ignore -/docker-compose.yml export-ignore -/Dockerfile export-ignore -/test export-ignore -/phpunit.xml export-ignore /.editorconfig export-ignore /.coverage.xml export-ignore +/art export-ignore /coverage export-ignore +/CONTRIBUTING.md export-ignore +/INTERNAL.md export-ignore +/SUPPORT.md export-ignore +/docker-compose.yml export-ignore +/docker-compose-override.yml export-ignore +/docker-compose-m1.override.yml export-ignore +/Dockerfile export-ignore +/doctum export-ignore +/phpunit.xml export-ignore +/t export-ignore +/test export-ignore +/tests export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d0788ee..e220bd99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,6 +89,18 @@ jobs: - 6379/tcp options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3 + memcached: + image: memcached + ports: + - 11211/tcp + # options: --health-cmd="/bin/nc -z 127.0.0.1 11211" --health-interval=10s --health-timeout=5s --health-retries=3 # TODO: Add a working health check + + dynamodb: + image: amazon/dynamodb-local:latest + ports: + - 8000/tcp + # options: --health-cmd="/bin/nc -z 127.0.0.1 8000" --health-interval=10s --health-timeout=5s --health-retries=3 # TODO: Add a working health check + php-cs-fixer: name: Code style (php-cs-fixer) runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3de3a1b9..498534f7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,6 +22,8 @@ If you're developing some feature and you encounter `SQLSTATE[23000]: Integrity To fix this, simply delete the database memory by shutting down containers and starting them again: `composer docker-down && composer docker-up`. +Same thing for `SQLSTATE[HY000]: General error: 1615 Prepared statement needs to be re-prepared`. + ### Docker on M1 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. diff --git a/Dockerfile b/Dockerfile index 6b1fc440..f4554872 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,19 +29,17 @@ RUN update-alternatives --set php /usr/bin/php$PHP_VERSION \ && update-alternatives --set phpize /usr/bin/phpize$PHP_VERSION \ && update-alternatives --set php-config /usr/bin/php-config$PHP_VERSION -RUN apt-get update \ - && apt-get install -y --no-install-recommends libhiredis0.14 libjemalloc2 liblua5.1-0 lua-bitop lua-cjson redis redis-server redis-tools - -RUN pecl install redis-5.3.7 sqlsrv pdo_sqlsrv pcov \ - && printf "; priority=20\nextension=redis.so\n" > /etc/php/$PHP_VERSION/mods-available/redis.ini \ - && printf "; priority=20\nextension=sqlsrv.so\n" > /etc/php/$PHP_VERSION/mods-available/sqlsrv.ini \ - && printf "; priority=30\nextension=pdo_sqlsrv.so\n" > /etc/php/$PHP_VERSION/mods-available/pdo_sqlsrv.ini \ - && printf "; priority=40\nextension=pcov.so\n" > /etc/php/$PHP_VERSION/mods-available/pcov.ini \ - && phpenmod -v $PHP_VERSION redis sqlsrv pdo_sqlsrv pcov - # install composer COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer # set the system timezone -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \ - && echo $TZ > /etc/timezone +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# install PHP extensions +RUN pecl install redis && printf "; priority=20\nextension=redis.so\n" > /etc/php/$PHP_VERSION/mods-available/redis.ini && phpenmod -v $PHP_VERSION redis +RUN pecl install pdo_sqlsrv && printf "; priority=30\nextension=pdo_sqlsrv.so\n" > /etc/php/$PHP_VERSION/mods-available/pdo_sqlsrv.ini && phpenmod -v $PHP_VERSION pdo_sqlsrv +RUN pecl install pcov && printf "; priority=40\nextension=pcov.so\n" > /etc/php/$PHP_VERSION/mods-available/pcov.ini && phpenmod -v $PHP_VERSION pcov + +RUN apt-get install -y --no-install-recommends libmemcached-dev zlib1g-dev +RUN pecl install memcached && printf "; priority=50\nextension=memcached.so\n" > /etc/php/$PHP_VERSION/mods-available/memcached.ini && phpenmod -v $PHP_VERSION memcached +RUN pecl install apcu && printf "; priority=60\nextension=apcu.so\napc.enable_cli=1\n" > /etc/php/$PHP_VERSION/mods-available/apcu.ini && phpenmod -v $PHP_VERSION apcu diff --git a/INTERNAL.md b/INTERNAL.md index 3da9ceed..bb65c6fb 100644 --- a/INTERNAL.md +++ b/INTERNAL.md @@ -2,8 +2,11 @@ ## Updating the docker image used by the GH action -1. Login in to Docker Hub: `docker login -u archtechx -p` -1. Build the image (probably shut down docker-compose containers first): `DOCKER_DEFAULT_PLATFORM=linux/amd64 docker-compose build --no-cache` +1. Login in to Docker Hub: `docker login -u archtechx` +1. Shut down containers: `composer docker-down` +1. Build the image: `DOCKER_DEFAULT_PLATFORM=linux/amd64 docker-compose build --no-cache` +1. Start containers again, using the amd64 image for the `test` service: `composer docker-up` 1. Verify that tests pass on the new image: `composer test` 1. Tag a new image: `docker tag tenancy-test archtechx/tenancy:latest` 1. Push the image: `docker push archtechx/tenancy:latest` +1. Optional: Rebuild the image again locally for arm64: `composer docker-rebuild` diff --git a/assets/config.php b/assets/config.php index 070b2471..ed034bab 100644 --- a/assets/config.php +++ b/assets/config.php @@ -38,16 +38,16 @@ return [ 'id_generator' => UniqueIdentifierGenerators\UUIDGenerator::class, ], - /** - * The list of domains hosting your central app. - * - * Only relevant if you're using the domain or subdomain identification middleware. - */ - 'central_domains' => [ - str(env('APP_URL'))->after('://')->before('/')->toString(), - ], - 'identification' => [ + /** + * The list of domains hosting your central app. + * + * Only relevant if you're using the domain or subdomain identification middleware. + */ + 'central_domains' => [ // todo@docs this was moved into the identification section + str(env('APP_URL'))->after('://')->before('/')->toString(), + ], + /** * The default middleware used for tenant identification. * @@ -222,11 +222,18 @@ return [ * You can clear cache selectively by specifying the tag. */ 'cache' => [ - 'prefix_base' => 'tenant', // This prefix_base, followed by the tenant_id, will form a cache prefix that will be used for every cache key. + 'prefix' => 'tenant_%tenant%_', // This format, with the %tenant% replaced by the tenant key, and prepended by the original store prefix, will form a cache prefix that will be used for every cache key. 'stores' => [ env('CACHE_STORE'), ], + /* + * Should sessions be tenant-aware (only used when your session driver is cache-based). + * + * Note: This will implicitly add your configured session store to the list of prefixed stores above. + */ + 'scope_sessions' => true, + 'tag_base' => 'tenant', // This tag_base, followed by the tenant_id, will form a tag that will be applied on each cache call. ], @@ -273,6 +280,20 @@ return [ 'public' => 'public-%tenant%', ], + /* + * Should the `file` cache driver be tenant-aware. + * + * When this is enabled, cache files will be stored in storage/{tenant}/framework/cache. + */ + 'scope_cache' => true, + + /* + * Should the `file` session driver be tenant-aware. + * + * When this is enabled, session files will be stored in storage/{tenant}/framework/sessions. + */ + 'scope_sessions' => true, + /** * Should storage_path() be suffixed. * @@ -304,7 +325,7 @@ return [ * either using the Redis facade or by injecting it as a dependency. */ 'redis' => [ - 'prefix_base' => 'tenant', // Each key in Redis will be prepended by this prefix_base, followed by the tenant id. + 'prefix' => 'tenant_%tenant%_', // Each key in Redis will be prepended by this prefix format, with %tenant% replaced by the tenant key. 'prefixed_connections' => [ // Redis connections whose keys are prefixed, to separate one tenant's keys from another. 'default', // 'cache', // Enable this if you want to scope cache using RedisTenancyBootstrapper diff --git a/assets/tenant_routes.stub.php b/assets/tenant_routes.stub.php index 399b6735..e3b8cb92 100644 --- a/assets/tenant_routes.stub.php +++ b/assets/tenant_routes.stub.php @@ -3,8 +3,7 @@ declare(strict_types=1); use Illuminate\Support\Facades\Route; -use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; -use Stancl\Tenancy\Middleware\PreventAccessFromUnwantedDomains; +use Stancl\Tenancy\Middleware; /* |-------------------------------------------------------------------------- @@ -20,8 +19,9 @@ use Stancl\Tenancy\Middleware\PreventAccessFromUnwantedDomains; Route::middleware([ 'web', - InitializeTenancyByDomain::class, - PreventAccessFromUnwantedDomains::class, + Middleware\InitializeTenancyByDomain::class, + Middleware\PreventAccessFromUnwantedDomains::class, + Middleware\ScopeSessions::class, ])->group(function () { Route::get('/', function () { return 'This is your multi-tenant application. The id of the current tenant is ' . tenant('id'); diff --git a/composer.json b/composer.json index 8bc4c204..794787e0 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,7 @@ "require": { "php": "^8.2", "ext-json": "*", - "illuminate/support": "^10.1|^11.0", + "illuminate/support": "^10.1|^11.3", "laravel/tinker": "^2.0", "facade/ignition-contracts": "^1.0.2", "spatie/ignition": "^1.4", @@ -28,14 +28,15 @@ "laravel/prompts": "^0.1.9" }, "require-dev": { - "laravel/framework": "^10.1|^11.2", + "laravel/framework": "^10.1|^11.3", "orchestra/testbench": "^8.0|^9.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": "^2.4", - "spatie/invade": "^1.1" + "spatie/invade": "^1.1", + "aws/aws-sdk-php-laravel": "~3.0" }, "autoload": { "psr-4": { diff --git a/docker-compose.yml b/docker-compose.yml index e7d14a80..9c6daef2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,12 +8,18 @@ services: depends_on: mysql: condition: service_healthy + mysql2: + condition: service_healthy postgres: condition: service_healthy redis: condition: service_healthy - # mssql: - # condition: service_healthy + mssql: + condition: service_healthy + memcached: + condition: service_healthy + dynamodb: + condition: service_healthy volumes: - .:/var/www/html:delegated environment: @@ -68,22 +74,34 @@ services: retries: 5 tmpfs: - /var/lib/postgresql/data + mssql: + image: mcr.microsoft.com/mssql/server:2019-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' + interval: 10s + timeout: 10s + retries: 10 redis: image: redis:alpine healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 1s timeout: 3s - retries: 30 - mssql: - image: mcr.microsoft.com/mssql/server:2019-latest - ports: - - 1433:1433 - environment: - - ACCEPT_EULA=Y - - SA_PASSWORD=P@ssword # todo reuse values from env above + retries: 20 + memcached: + image: memcached:alpine healthcheck: - test: /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P P@ssword -Q "SELECT 1" -b -o /dev/null - interval: 10s - timeout: 10s - retries: 10 + test: ["CMD-SHELL", "echo version | nc localhost 11211 | grep -q VERSION"] + interval: 1s + timeout: 3s + retries: 20 + dynamodb: + image: amazon/dynamodb-local:latest + healthcheck: + test: ["CMD-SHELL", "cat < /dev/null > /dev/tcp/127.0.0.1/8000"] + interval: 1s + timeout: 3s + retries: 20 diff --git a/src/Bootstrappers/CacheTenancyBootstrapper.php b/src/Bootstrappers/CacheTenancyBootstrapper.php index 35e65010..a66aa9f8 100644 --- a/src/Bootstrappers/CacheTenancyBootstrapper.php +++ b/src/Bootstrappers/CacheTenancyBootstrapper.php @@ -5,10 +5,12 @@ declare(strict_types=1); namespace Stancl\Tenancy\Bootstrappers; use Closure; +use Exception; use Illuminate\Cache\CacheManager; -use Illuminate\Cache\Repository; +use Illuminate\Contracts\Cache\Store; use Illuminate\Contracts\Config\Repository as ConfigRepository; -use Illuminate\Support\Facades\Cache; +use Illuminate\Session\CacheBasedSessionHandler; +use Illuminate\Session\SessionManager; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; @@ -17,70 +19,136 @@ use Stancl\Tenancy\Contracts\Tenant; */ class CacheTenancyBootstrapper implements TenancyBootstrapper { + /** @var Closure(Tenant, string): string */ public static Closure|null $prefixGenerator = null; - protected string|null $originalPrefix = null; + /** @var array */ + protected array $originalPrefixes = []; public function __construct( protected ConfigRepository $config, - protected CacheManager $cacheManager, + protected CacheManager $cache, + protected SessionManager $session, ) {} public function bootstrap(Tenant $tenant): void { - $this->originalPrefix = $this->config->get('cache.prefix'); + foreach ($this->getCacheStores() as $name) { + $store = $this->cache->driver($name)->getStore(); - $prefix = $this->generatePrefix($tenant); + $this->originalPrefixes[$name] = $store->getPrefix(); + $this->setCachePrefix($store, $this->generatePrefix($tenant, $name)); + } - foreach ($this->config->get('tenancy.cache.stores') as $store) { - $this->setCachePrefix($store, $prefix); + if ($this->shouldScopeSessions()) { + $name = $this->getSessionCacheStoreName(); + $handler = $this->session->driver()->getHandler(); - // Now that the store uses the passed prefix - // Set the configured prefix back to the default one - $this->config->set('cache.prefix', $this->originalPrefix); + if ($handler instanceof CacheBasedSessionHandler) { + // The CacheBasedSessionHandler is constructed with a *clone* of + // an existing cache store, so we need to set the prefix separately. + $store = $handler->getCache()->getStore(); + + // We also don't need to set the original prefix, since the cache store + // is implicitly added to the configured cache stores when session scoping + // is enabled. + + $this->setCachePrefix($store, $this->generatePrefix($tenant, $name)); + } } } public function revert(): void { - foreach ($this->config->get('tenancy.cache.stores') as $store) { - $this->setCachePrefix($store, $this->originalPrefix); + foreach ($this->getCacheStores() as $name) { + $store = $this->cache->driver($name)->getStore(); + + $this->setCachePrefix($store, $this->originalPrefixes[$name]); + } + + if ($this->shouldScopeSessions()) { + $name = $this->getSessionCacheStoreName(); + $handler = $this->session->driver()->getHandler(); + + if ($handler instanceof CacheBasedSessionHandler) { + $store = $handler->getCache()->getStore(); + + $this->setCachePrefix($store, $this->originalPrefixes[$name]); + } } } - protected function setCachePrefix(string $driver, string|null $prefix): void + protected function getSessionCacheStoreName(): string { - $this->config->set('cache.prefix', $prefix); - - // Refresh driver's store to make the driver use the current prefix - $this->refreshStore($driver); - - // It is needed when a call to the facade has been made before bootstrapping tenancy - // The facade has its own cache, separate from the container - Cache::clearResolvedInstances(); + return $this->config->get('session.store') ?? $this->config->get('session.driver'); } - public function generatePrefix(Tenant $tenant): string + protected function shouldScopeSessions(): bool { - $defaultPrefix = $this->originalPrefix . $this->config->get('tenancy.cache.prefix_base') . $tenant->getTenantKey(); - - return static::$prefixGenerator ? (static::$prefixGenerator)($tenant) : $defaultPrefix; + // We don't want to scope sessions if: + // 1. The user has disabled session scoping via this bootstrapper, AND + // 2. The session driver hasn't been instantiated yet (if this is the case, + // it will be instantiated later by cloning an existing cache store + // that will have already been prefixed in this bootstrapper). + return $this->config->get('tenancy.cache.scope_sessions', true) + && count($this->session->getDrivers()) !== 0; } + /** @return string[] */ + protected function getCacheStores(): array + { + $names = $this->config->get('tenancy.cache.stores'); + + if ( + $this->config->get('tenancy.cache.scope_sessions', true) && + in_array($this->config->get('session.driver'), ['redis', 'memcached', 'dynamodb', 'apc'], true) + ) { + $names[] = $this->getSessionCacheStoreName(); + } + + $names = array_unique($names); + + return array_filter($names, function ($name) { + $store = $this->config->get("cache.stores.{$name}"); + + if ($store === null || $store['driver'] === 'file') { + return false; + } + + if ($store['driver'] === 'array') { + throw new Exception('Cache store [' . $name . '] is not supported by this bootstrapper.'); + } + + return true; + }); + } + + protected function setCachePrefix(Store $store, string|null $prefix): void + { + if (! method_exists($store, 'setPrefix')) { + throw new Exception('Cache store [' . get_class($store) . '] does not support setting a prefix.'); + } + + $store->setPrefix($prefix); + } + + public function generatePrefix(Tenant $tenant, string $store): string + { + return static::$prefixGenerator + ? (static::$prefixGenerator)($tenant, $store) + : $this->originalPrefixes[$store] . str($this->config->get('tenancy.cache.prefix')) + ->replace('%tenant%', (string) $tenant->getTenantKey())->toString(); + } + + /** + * Set a custom prefix generator. + * + * The first argument is the tenant, the second argument is the cache store name. + * + * @param Closure(Tenant, string): string $prefixGenerator + */ public static function generatePrefixUsing(Closure $prefixGenerator): void { static::$prefixGenerator = $prefixGenerator; } - - /** - * Refresh cache driver's store. - */ - protected function refreshStore(string $driver): void - { - $newStore = $this->cacheManager->resolve($driver)->getStore(); - /** @var Repository $repository */ - $repository = $this->cacheManager->driver($driver); - - $repository->setStore($newStore); - } } diff --git a/src/Bootstrappers/DatabaseSessionBootstrapper.php b/src/Bootstrappers/DatabaseSessionBootstrapper.php index 41f95536..aefb9f1d 100644 --- a/src/Bootstrappers/DatabaseSessionBootstrapper.php +++ b/src/Bootstrappers/DatabaseSessionBootstrapper.php @@ -14,6 +14,8 @@ use Stancl\Tenancy\Contracts\Tenant; /** * This resets the database connection used by the database session driver. * + * It also includes a mechanism to prevent session forgery when SESSION_CONNECTION is specified. + * * It runs each time tenancy is initialized or ended. * That way the session driver always uses the current DB connection. */ @@ -25,23 +27,37 @@ class DatabaseSessionBootstrapper implements TenancyBootstrapper protected SessionManager $session, ) {} + protected string|null $originalConnection = null; + public function bootstrap(Tenant $tenant): void { + $this->originalConnection = $this->config->get('session.connection'); + if ($this->config->get('session.driver') === 'database') { - $this->resetDatabaseHandler(); + // At first, this bootstrapper runs before the StartSession middleware, so + // changing the session.connection here will affect what connection the session + // driver will use. This is helpful to override the SESSION_CONNECTION that might + // otherwise allow for session forgery in the tenant context. + $this->config->set('session.connection', 'tenant'); + + $this->resetDatabaseHandler('tenant'); } } public function revert(): void { if ($this->config->get('session.driver') === 'database') { + $connection = $this->originalConnection ?? config('tenancy.database.central_connection'); + // When ending tenancy, this runs *before* the DatabaseTenancyBootstrapper, so DB tenancy // is still bootstrapped. For that reason, we have to explicitly use the central connection - $this->resetDatabaseHandler(config('tenancy.database.central_connection')); + // instead of null for the default connection. + $this->config->set('session.connection', $connection); + $this->resetDatabaseHandler($connection); } } - protected function resetDatabaseHandler(string $defaultConnection = null): void + protected function resetDatabaseHandler(string $connection): void { $sessionDrivers = $this->session->getDrivers(); @@ -49,15 +65,12 @@ class DatabaseSessionBootstrapper implements TenancyBootstrapper /** @var \Illuminate\Session\Store $databaseDriver */ $databaseDriver = $sessionDrivers['database']; - $databaseDriver->setHandler($this->createDatabaseHandler($defaultConnection)); + $databaseDriver->setHandler($this->createDatabaseHandler($connection)); } } - protected function createDatabaseHandler(string $defaultConnection = null): DatabaseSessionHandler + protected function createDatabaseHandler(string $connection): DatabaseSessionHandler { - // Typically returns null, so this falls back to the default DB connection - $connection = $this->config->get('session.connection') ?? $defaultConnection; - // Based on SessionManager::createDatabaseDriver return new DatabaseSessionHandler( $this->container->make('db')->connection($connection), diff --git a/src/Bootstrappers/FilesystemTenancyBootstrapper.php b/src/Bootstrappers/FilesystemTenancyBootstrapper.php index 07f8f859..e66bc654 100644 --- a/src/Bootstrappers/FilesystemTenancyBootstrapper.php +++ b/src/Bootstrappers/FilesystemTenancyBootstrapper.php @@ -6,6 +6,7 @@ namespace Stancl\Tenancy\Bootstrappers; use Illuminate\Foundation\Application; use Illuminate\Routing\UrlGenerator; +use Illuminate\Session\FileSessionHandler; use Illuminate\Support\Facades\Storage; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; @@ -37,6 +38,9 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper $this->storagePath($suffix); $this->assetHelper($suffix); $this->forgetDisks(); + $this->scopeCache($suffix); + $this->scopeSessions($suffix); + // todo@docs update fs docs foreach ($this->app['config']['tenancy.filesystem.disks'] as $disk) { $this->diskRoot($disk, $tenant); @@ -55,6 +59,8 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper $this->storagePath(false); $this->assetHelper(false); $this->forgetDisks(); + $this->scopeCache(false); + $this->scopeSessions(false); foreach ($this->app['config']['tenancy.filesystem.disks'] as $disk) { $this->diskRoot($disk, false); @@ -76,10 +82,15 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper if ($suffix === false) { $this->app->useStoragePath($this->originalStoragePath); } else { - $this->app->useStoragePath($this->originalStoragePath . "/{$suffix}"); + $this->app->useStoragePath($this->tenantStoragePath($suffix)); } } + protected function tenantStoragePath(string $suffix): string + { + return $this->originalStoragePath . "/{$suffix}"; + } + protected function assetHelper(string|false $suffix): void { if (! $this->app['config']['tenancy.filesystem.asset_helper_tenancy']) { @@ -125,7 +136,7 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper // This is executed if the disk is in tenancy.filesystem.disks AND has a root_override // This behavior is used for local disks. $newRoot = str($override) - ->replace('%storage_path%', storage_path()) + ->replace('%storage_path%', $this->tenantStoragePath($suffix)) ->replace('%original_storage_path%', $this->originalStoragePath) ->replace('%tenant%', (string) $tenant->getTenantKey()) ->toString(); @@ -156,4 +167,70 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper $this->app['config']["filesystems.disks.{$disk}.url"] = url($override); } } + + public function scopeCache(string|false $suffix): void + { + if (! $this->app['config']['tenancy.filesystem.scope_cache']) { + return; + } + + $storagePath = $suffix + ? $this->tenantStoragePath($suffix) + : $this->originalStoragePath; + + $stores = array_filter($this->app['config']['tenancy.cache.stores'], function ($name) { + $store = $this->app['config']["cache.stores.{$name}"]; + + if ($store === null) { + return false; + } + + return $store['driver'] === 'file'; + }); + + foreach ($stores as $name) { + $path = $storagePath . '/framework/cache/data'; + $this->app['config']["cache.stores.{$name}.path"] = $path; + $this->app['config']["cache.stores.{$name}.lock_path"] = $path; + + /** @var \Illuminate\Cache\FileStore $store */ + $store = $this->app['cache']->store($name)->getStore(); + $store->setDirectory($path); + $store->setLockDirectory($path); + } + } + + public function scopeSessions(string|false $suffix): void + { + if (! $this->app['config']['tenancy.filesystem.scope_sessions']) { + return; + } + + $path = $suffix + ? $this->tenantStoragePath($suffix) . '/framework/sessions' + : $this->originalStoragePath . '/framework/sessions'; + + if (! is_dir($path)) { + // Create tenant framework/sessions directory if it does not exist + mkdir($path, 0755, true); + } + + $this->app['config']['session.files'] = $path; + + /** @var \Illuminate\Session\SessionManager $sessionManager */ + $sessionManager = $this->app['session']; + + // Since this bootstrapper runs much earlier than the StartSession middleware, this doesn't execute + // on the average tenant request. It only executes when the context is switched *after* original + // middleware initialization. + if (isset($sessionManager->getDrivers()['file'])) { + $handler = new FileSessionHandler( + $this->app->make('files'), + $path, + $this->app['config']->get('session.lifetime'), + ); + + $sessionManager->getDrivers()['file']->setHandler($handler); + } + } } diff --git a/src/Bootstrappers/RedisTenancyBootstrapper.php b/src/Bootstrappers/RedisTenancyBootstrapper.php index 975a37d5..8d4c9cc5 100644 --- a/src/Bootstrappers/RedisTenancyBootstrapper.php +++ b/src/Bootstrappers/RedisTenancyBootstrapper.php @@ -25,7 +25,7 @@ class RedisTenancyBootstrapper implements TenancyBootstrapper public function bootstrap(Tenant $tenant): void { foreach ($this->prefixedConnections() as $connection) { - $prefix = $this->config['tenancy.redis.prefix_base'] . $tenant->getTenantKey(); + $prefix = str($this->config['tenancy.redis.prefix'])->replace('%tenant%', (string) $tenant->getTenantKey())->toString(); $client = Redis::connection($connection)->client(); /** @var string $originalPrefix */ diff --git a/src/Middleware/InitializeTenancyByDomain.php b/src/Middleware/InitializeTenancyByDomain.php index 4f6cd7aa..4b6f2195 100644 --- a/src/Middleware/InitializeTenancyByDomain.php +++ b/src/Middleware/InitializeTenancyByDomain.php @@ -45,7 +45,7 @@ class InitializeTenancyByDomain extends IdentificationMiddleware implements Usab */ public function requestHasTenant(Request $request): bool { - return ! in_array($this->getDomain($request), config('tenancy.central_domains')); + return ! in_array($this->getDomain($request), config('tenancy.identification.central_domains')); } public function getDomain(Request $request): string diff --git a/src/Middleware/InitializeTenancyByDomainOrSubdomain.php b/src/Middleware/InitializeTenancyByDomainOrSubdomain.php index 704826d0..3085da41 100644 --- a/src/Middleware/InitializeTenancyByDomainOrSubdomain.php +++ b/src/Middleware/InitializeTenancyByDomainOrSubdomain.php @@ -50,6 +50,6 @@ class InitializeTenancyByDomainOrSubdomain extends InitializeTenancyBySubdomain protected function isSubdomain(string $hostname): bool { - return Str::endsWith($hostname, config('tenancy.central_domains')); + return Str::endsWith($hostname, config('tenancy.identification.central_domains')); } } diff --git a/src/Middleware/InitializeTenancyBySubdomain.php b/src/Middleware/InitializeTenancyBySubdomain.php index a08cf116..07a9c68d 100644 --- a/src/Middleware/InitializeTenancyBySubdomain.php +++ b/src/Middleware/InitializeTenancyBySubdomain.php @@ -66,9 +66,9 @@ class InitializeTenancyBySubdomain extends InitializeTenancyByDomain $isIpAddress = count(array_filter($parts, 'is_numeric')) === count($parts); // If we're on localhost or an IP address, then we're not visiting a subdomain. - $isACentralDomain = in_array($hostname, config('tenancy.central_domains'), true); + $isACentralDomain = in_array($hostname, config('tenancy.identification.central_domains'), true); $notADomain = $isLocalhost || $isIpAddress; - $thirdPartyDomain = ! Str::endsWith($hostname, config('tenancy.central_domains')); + $thirdPartyDomain = ! Str::endsWith($hostname, config('tenancy.identification.central_domains')); if ($isACentralDomain || $notADomain || $thirdPartyDomain) { return new NotASubdomainException($hostname); diff --git a/src/Middleware/PreventAccessFromUnwantedDomains.php b/src/Middleware/PreventAccessFromUnwantedDomains.php index 06dd89fb..a54bfba7 100644 --- a/src/Middleware/PreventAccessFromUnwantedDomains.php +++ b/src/Middleware/PreventAccessFromUnwantedDomains.php @@ -51,20 +51,20 @@ class PreventAccessFromUnwantedDomains protected function accessingTenantRouteFromCentralDomain(Request $request, Route $route): bool { return tenancy()->getRouteMode($route) === RouteMode::TENANT // Current route's middleware context is tenant - && $this->isCentralDomain($request); // The request comes from a domain that IS present in the configured `tenancy.central_domains` + && $this->isCentralDomain($request); // The request comes from a domain that IS present in the configured `tenancy.identification.central_domains` } protected function accessingCentralRouteFromTenantDomain(Request $request, Route $route): bool { return tenancy()->getRouteMode($route) === RouteMode::CENTRAL // Current route's middleware context is central - && ! $this->isCentralDomain($request); // The request comes from a domain that ISN'T present in the configured `tenancy.central_domains` + && ! $this->isCentralDomain($request); // The request comes from a domain that ISN'T present in the configured `tenancy.identification.central_domains` } /** - * Check if the request's host name is present in the configured `tenancy.central_domains`. + * Check if the request's host name is present in the configured `tenancy.identification.central_domains`. */ protected function isCentralDomain(Request $request): bool { - return in_array($request->getHost(), config('tenancy.central_domains'), true); + return in_array($request->getHost(), config('tenancy.identification.central_domains'), true); } } diff --git a/src/Middleware/ScopeSessions.php b/src/Middleware/ScopeSessions.php index dc302ee5..46bd5dc4 100644 --- a/src/Middleware/ScopeSessions.php +++ b/src/Middleware/ScopeSessions.php @@ -12,6 +12,9 @@ class ScopeSessions { public static string $tenantIdKey = '_tenant_id'; + /** @var Closure(Request): mixed */ + public static Closure|null $onFail = null; + /** @return \Illuminate\Http\Response|mixed */ public function handle(Request $request, Closure $next): mixed { @@ -23,7 +26,9 @@ class ScopeSessions $request->session()->put(static::$tenantIdKey, tenant()->getTenantKey()); } else { if ($request->session()->get(static::$tenantIdKey) !== tenant()->getTenantKey()) { - abort(403); + return static::$onFail !== null + ? (static::$onFail)($request) + : abort(403); } } diff --git a/src/Tenancy.php b/src/Tenancy.php index f15e548c..e30c490f 100644 --- a/src/Tenancy.php +++ b/src/Tenancy.php @@ -117,6 +117,16 @@ class Tenancy return array_map('app', $resolve($this->tenant)); } + /** + * Check if a bootstrapper is being used. + * + * @param class-string $bootstrapper + */ + public function usingBootstrapper(string $bootstrapper): bool + { + return in_array($bootstrapper, static::getBootstrappers(), true); + } + public static function query(): Builder { return static::model()->query(); diff --git a/tests/BootstrapperTest.php b/tests/BootstrapperTest.php deleted file mode 100644 index 6367e6cb..00000000 --- a/tests/BootstrapperTest.php +++ /dev/null @@ -1,916 +0,0 @@ -mockConsoleOutput = false; - - config([ - 'cache.default' => 'redis', - 'tenancy.cache.stores' => ['redis'], - ]); - // Reset static properties of classes used in this test file to their default values - BroadcastingConfigBootstrapper::$credentialsMap = []; - TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably']; - RootUrlBootstrapper::$rootUrlOverride = null; - - Event::listen( - TenantCreated::class, - JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { - return $event->tenant; - })->toListener() - ); - - Event::listen(TenancyInitialized::class, BootstrapTenancy::class); - Event::listen(TenancyEnded::class, RevertToCentralContext::class); -}); - -afterEach(function () { - // Reset static properties of classes used in this test file to their default values - RootUrlBootstrapper::$rootUrlOverride = null; - TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably']; - BroadcastingConfigBootstrapper::$credentialsMap = []; - TenancyUrlGenerator::$prefixRouteNames = false; - TenancyUrlGenerator::$passTenantParameterToRoutes = true; -}); - -test('database data is separated', function () { - config(['tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class]]); - - $tenant1 = Tenant::create(); - $tenant2 = Tenant::create(); - - pest()->artisan('tenants:migrate'); - - tenancy()->initialize($tenant1); - - // Create Foo user - DB::table('users')->insert(['name' => 'Foo', 'email' => 'foo@bar.com', 'password' => 'secret']); - expect(DB::table('users')->get())->toHaveCount(1); - - tenancy()->initialize($tenant2); - - // Assert Foo user is not in this DB - expect(DB::table('users')->get())->toHaveCount(0); - // Create Bar user - DB::table('users')->insert(['name' => 'Bar', 'email' => 'bar@bar.com', 'password' => 'secret']); - expect(DB::table('users')->get())->toHaveCount(1); - - tenancy()->initialize($tenant1); - - // Assert Bar user is not in this DB - expect(DB::table('users')->get())->toHaveCount(1); - expect(DB::table('users')->first()->name)->toBe('Foo'); -}); - -test('cache data is separated', function (string $bootstrapper) { - config([ - 'tenancy.bootstrappers' => [$bootstrapper], - ]); - - $tenant1 = Tenant::create(); - $tenant2 = Tenant::create(); - - cache()->set('foo', 'central'); - expect(Cache::get('foo'))->toBe('central'); - - tenancy()->initialize($tenant1); - - // Assert central cache doesn't leak to tenant context - expect(Cache::has('foo'))->toBeFalse(); - - cache()->set('foo', 'bar'); - expect(Cache::get('foo'))->toBe('bar'); - - tenancy()->initialize($tenant2); - - // Assert one tenant's data doesn't leak to another tenant - expect(Cache::has('foo'))->toBeFalse(); - - cache()->set('foo', 'xyz'); - expect(Cache::get('foo'))->toBe('xyz'); - - tenancy()->initialize($tenant1); - - // Asset data didn't leak to original tenant - expect(Cache::get('foo'))->toBe('bar'); - - tenancy()->end(); - - // Asset central is still the same - expect(Cache::get('foo'))->toBe('central'); -})->with([ - CacheTagsBootstrapper::class, - CacheTenancyBootstrapper::class, -]); - -test('redis data is separated', function () { - config(['tenancy.bootstrappers' => [ - RedisTenancyBootstrapper::class, - ]]); - - $tenant1 = Tenant::create(); - $tenant2 = Tenant::create(); - - tenancy()->initialize($tenant1); - Redis::set('foo', 'bar'); - expect(Redis::get('foo'))->toBe('bar'); - - tenancy()->initialize($tenant2); - expect(Redis::get('foo'))->toBe(null); - Redis::set('foo', 'xyz'); - Redis::set('abc', 'def'); - expect(Redis::get('foo'))->toBe('xyz'); - expect(Redis::get('abc'))->toBe('def'); - - tenancy()->initialize($tenant1); - expect(Redis::get('foo'))->toBe('bar'); - expect(Redis::get('abc'))->toBe(null); - - $tenant3 = Tenant::create(); - tenancy()->initialize($tenant3); - expect(Redis::get('foo'))->toBe(null); - expect(Redis::get('abc'))->toBe(null); -}); - -test('filesystem data is separated', function () { - config(['tenancy.bootstrappers' => [ - FilesystemTenancyBootstrapper::class, - ]]); - - $old_storage_path = storage_path(); - $old_storage_facade_roots = []; - foreach (config('tenancy.filesystem.disks') as $disk) { - $old_storage_facade_roots[$disk] = config("filesystems.disks.{$disk}.root"); - } - - $tenant1 = Tenant::create(); - $tenant2 = Tenant::create(); - - tenancy()->initialize($tenant1); - - Storage::disk('public')->put('foo', 'bar'); - expect(Storage::disk('public')->get('foo'))->toBe('bar'); - - tenancy()->initialize($tenant2); - expect(Storage::disk('public')->exists('foo'))->toBeFalse(); - Storage::disk('public')->put('foo', 'xyz'); - Storage::disk('public')->put('abc', 'def'); - expect(Storage::disk('public')->get('foo'))->toBe('xyz'); - expect(Storage::disk('public')->get('abc'))->toBe('def'); - - tenancy()->initialize($tenant1); - expect(Storage::disk('public')->get('foo'))->toBe('bar'); - expect(Storage::disk('public')->exists('abc'))->toBeFalse(); - - $tenant3 = Tenant::create(); - tenancy()->initialize($tenant3); - expect(Storage::disk('public')->exists('foo'))->toBeFalse(); - expect(Storage::disk('public')->exists('abc'))->toBeFalse(); - - $expected_storage_path = $old_storage_path . '/tenant' . tenant('id'); // /tenant = suffix base - - // Check that disk prefixes respect the root_override logic - expect(getDiskPrefix('local'))->toBe($expected_storage_path . '/app/'); - expect(getDiskPrefix('public'))->toBe($expected_storage_path . '/app/public/'); - pest()->assertSame('tenant' . tenant('id') . '/', getDiskPrefix('s3'), '/'); - - // Check suffixing logic - $new_storage_path = storage_path(); - expect($new_storage_path)->toEqual($expected_storage_path); -}); - -test('tenant storage can get deleted after the tenant when DeletingTenant listens to DeleteTenantStorage', function () { - config([ - 'tenancy.bootstrappers' => [ - FilesystemTenancyBootstrapper::class, - ], - 'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/', - 'tenancy.filesystem.url_override.public' => 'public-%tenant%' - ]); - - $tenant1 = Tenant::create(); - $tenant2 = Tenant::create(); - $tenant1StorageUrl = 'http://localhost/public-' . $tenant1->getKey().'/'; - $tenant2StorageUrl = 'http://localhost/public-' . $tenant2->getKey().'/'; - - tenancy()->initialize($tenant1); - - $this->assertEquals( - $tenant1StorageUrl, - Storage::disk('public')->url('') - ); - - Storage::disk('public')->put($tenant1FileName = 'tenant1.txt', 'text'); - - $this->assertEquals( - $tenant1StorageUrl . $tenant1FileName, - Storage::disk('public')->url($tenant1FileName) - ); - - tenancy()->initialize($tenant2); - - $this->assertEquals( - $tenant2StorageUrl, - Storage::disk('public')->url('') - ); - - Storage::disk('public')->put($tenant2FileName = 'tenant2.txt', 'text'); - - $this->assertEquals( - $tenant2StorageUrl . $tenant2FileName, - Storage::disk('public')->url($tenant2FileName) - ); -}); - -test('files can get fetched using the storage url', function() { - config([ - 'tenancy.bootstrappers' => [ - FilesystemTenancyBootstrapper::class, - ], - 'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/', - 'tenancy.filesystem.url_override.public' => 'public-%tenant%' - ]); - - $tenant1 = Tenant::create(); - $tenant2 = Tenant::create(); - - pest()->artisan('tenants:link'); - - // First tenant - tenancy()->initialize($tenant1); - Storage::disk('public')->put($tenantFileName = 'tenant1.txt', $tenantKey = $tenant1->getTenantKey()); - - $url = Storage::disk('public')->url($tenantFileName); - $tenantDiskName = Str::of(config('tenancy.filesystem.url_override.public'))->replace('%tenant%', $tenantKey); - $hostname = Str::of($url)->before($tenantDiskName); - $parsedUrl = Str::of($url)->after($hostname); - - expect(file_get_contents(public_path($parsedUrl)))->toBe($tenantKey); - - // Second tenant - tenancy()->initialize($tenant2); - Storage::disk('public')->put($tenantFileName = 'tenant2.txt', $tenantKey = $tenant2->getTenantKey()); - - $url = Storage::disk('public')->url($tenantFileName); - $tenantDiskName = Str::of(config('tenancy.filesystem.url_override.public'))->replace('%tenant%', $tenantKey); - $hostname = Str::of($url)->before($tenantDiskName); - $parsedUrl = Str::of($url)->after($hostname); - - expect(file_get_contents(public_path($parsedUrl)))->toBe($tenantKey); - - // Central - tenancy()->end(); - Storage::disk('public')->put($centralFileName = 'central.txt', $centralFileContent = 'central'); - - pest()->artisan('storage:link'); - $url = Storage::disk('public')->url($centralFileName); - - expect(file_get_contents(public_path($url)))->toBe($centralFileContent); -}); - -test('storage_path helper does not change if suffix_storage_path is off', function() { - $originalStoragePath = storage_path(); - - // todo@tests https://github.com/tenancy-for-laravel/v4/pull/44#issue-2228530362 - - config([ - 'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class], - 'tenancy.filesystem.suffix_storage_path' => false, - ]); - - tenancy()->initialize(Tenant::create()); - - $this->assertEquals($originalStoragePath, storage_path()); -}); - -test('links to storage disks with a configured root are suffixed if not overridden', function() { - config([ - 'filesystems.disks.public.root' => 'http://sample-s3-url.com/my-app', - 'tenancy.bootstrappers' => [ - FilesystemTenancyBootstrapper::class, - ], - 'tenancy.filesystem.root_override.public' => null, - 'tenancy.filesystem.url_override.public' => null, - ]); - - $tenant = Tenant::create(); - - $expectedStoragePath = storage_path() . '/tenant' . $tenant->getTenantKey(); // /tenant = suffix base - - tenancy()->initialize($tenant); - - // Check suffixing logic - expect(storage_path())->toEqual($expectedStoragePath); -}); - -test('create and delete storage symlinks jobs work', function() { - Event::listen( - TenantCreated::class, - JobPipeline::make([CreateStorageSymlinks::class])->send(function (TenantCreated $event) { - return $event->tenant; - })->toListener() - ); - - Event::listen( - TenantDeleted::class, - JobPipeline::make([RemoveStorageSymlinks::class])->send(function (TenantDeleted $event) { - return $event->tenant; - })->toListener() - ); - - 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(); - - tenancy()->initialize($tenant); - - $tenantKey = $tenant->getTenantKey(); - - $this->assertDirectoryExists(storage_path("app/public")); - $this->assertEquals(storage_path("app/public/"), readlink(public_path("public-$tenantKey"))); - - $tenant->delete(); - - $this->assertDirectoryDoesNotExist(public_path("public-$tenantKey")); -}); - -test('local storage public urls are generated correctly', function() { - Event::listen(DeletingTenant::class, DeleteTenantStorage::class); - - tenancy()->initialize(Tenant::create()); - $tenantStoragePath = storage_path(); - - Storage::fake('test'); - - expect(File::isDirectory($tenantStoragePath))->toBeTrue(); - - Storage::put('test.txt', 'testing file'); - - tenant()->delete(); - - expect(File::isDirectory($tenantStoragePath))->toBeFalse(); -}); - -test('BroadcastingConfigBootstrapper binds TenancyBroadcastManager to BroadcastManager and reverts the binding when tenancy is ended', function() { - config(['tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class]]); - - expect(app(BroadcastManager::class))->toBeInstanceOf(BroadcastManager::class); - - tenancy()->initialize(Tenant::create()); - - expect(app(BroadcastManager::class))->toBeInstanceOf(TenancyBroadcastManager::class); - - tenancy()->end(); - - expect(app(BroadcastManager::class))->toBeInstanceOf(BroadcastManager::class); -}); - -test('BroadcastingConfigBootstrapper maps tenant broadcaster credentials to config as specified in the $credentialsMap property and reverts the config after ending tenancy', function() { - config([ - 'broadcasting.connections.testing.driver' => 'testing', - 'broadcasting.connections.testing.message' => $defaultMessage = 'default', - 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], - ]); - - BroadcastingConfigBootstrapper::$credentialsMap = [ - 'broadcasting.connections.testing.message' => 'testing_broadcaster_message', - ]; - - $tenant = Tenant::create(['testing_broadcaster_message' => $tenantMessage = 'first testing']); - $tenant2 = Tenant::create(['testing_broadcaster_message' => $secondTenantMessage = 'second testing']); - - tenancy()->initialize($tenant); - - expect(array_key_exists('testing_broadcaster_message', tenant()->getAttributes()))->toBeTrue(); - expect(config('broadcasting.connections.testing.message'))->toBe($tenantMessage); - - tenancy()->initialize($tenant2); - - expect(config('broadcasting.connections.testing.message'))->toBe($secondTenantMessage); - - tenancy()->end(); - - expect(config('broadcasting.connections.testing.message'))->toBe($defaultMessage); -}); - -test('BroadcastingConfigBootstrapper makes the app use broadcasters with the correct credentials', function() { - config([ - 'broadcasting.default' => 'testing', - 'broadcasting.connections.testing.driver' => 'testing', - 'broadcasting.connections.testing.message' => $defaultMessage = 'default', - 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], - ]); - - TenancyBroadcastManager::$tenantBroadcasters[] = 'testing'; - BroadcastingConfigBootstrapper::$credentialsMap = [ - 'broadcasting.connections.testing.message' => 'testing_broadcaster_message', - ]; - - $registerTestingBroadcaster = fn() => app(BroadcastManager::class)->extend('testing', fn($app, $config) => new TestingBroadcaster($config['message'])); - - $registerTestingBroadcaster(); - - expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($defaultMessage); - - $tenant = Tenant::create(['testing_broadcaster_message' => $tenantMessage = 'first testing']); - $tenant2 = Tenant::create(['testing_broadcaster_message' => $secondTenantMessage = 'second testing']); - - tenancy()->initialize($tenant); - $registerTestingBroadcaster(); - - expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($tenantMessage); - - tenancy()->initialize($tenant2); - $registerTestingBroadcaster(); - - expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($secondTenantMessage); - - tenancy()->end(); - $registerTestingBroadcaster(); - - expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($defaultMessage); -}); - -test('MailTenancyBootstrapper maps tenant mail credentials to config as specified in the $credentialsMap property and makes the mailer use tenant credentials', function() { - MailConfigBootstrapper::$credentialsMap = [ - 'mail.mailers.smtp.username' => 'smtp_username', - 'mail.mailers.smtp.password' => 'smtp_password' - ]; - - config([ - 'mail.default' => 'smtp', - 'mail.mailers.smtp.username' => $defaultUsername = 'default username', - 'mail.mailers.smtp.password' => 'no password', - 'tenancy.bootstrappers' => [MailConfigBootstrapper::class], - ]); - - $tenant = Tenant::create(['smtp_password' => $password = 'testing password']); - - tenancy()->initialize($tenant); - - expect(array_key_exists('smtp_password', tenant()->getAttributes()))->toBeTrue(); - expect(array_key_exists('smtp_host', tenant()->getAttributes()))->toBeFalse(); - expect(config('mail.mailers.smtp.username'))->toBe($defaultUsername); - expect(config('mail.mailers.smtp.password'))->toBe(tenant()->smtp_password); - - // Assert that the current mailer uses tenant's smtp_password - assertMailerTransportUsesPassword($password); -}); - -test('MailTenancyBootstrapper reverts the config and mailer credentials to default when tenancy ends', function() { - MailConfigBootstrapper::$credentialsMap = ['mail.mailers.smtp.password' => 'smtp_password']; - config([ - 'mail.default' => 'smtp', - 'mail.mailers.smtp.password' => $defaultPassword = 'no password', - 'tenancy.bootstrappers' => [MailConfigBootstrapper::class], - ]); - - tenancy()->initialize(Tenant::create(['smtp_password' => $tenantPassword = 'testing password'])); - - expect(config('mail.mailers.smtp.password'))->toBe($tenantPassword); - - assertMailerTransportUsesPassword($tenantPassword); - - tenancy()->end(); - - expect(config('mail.mailers.smtp.password'))->toBe($defaultPassword); - - // Assert that the current mailer uses the default SMTP password - assertMailerTransportUsesPassword($defaultPassword); -}); - -test('url bootstrapper overrides the root url when tenancy gets initialized and reverts the url to the central one after tenancy ends', function() { - config(['tenancy.bootstrappers' => [RootUrlBootstrapper::class]]); - - Route::group([ - 'middleware' => InitializeTenancyBySubdomain::class, - ], function () { - Route::get('/', function () { - return true; - })->name('home'); - }); - - $baseUrl = url(route('home')); - config(['app.url' => $baseUrl]); - - $rootUrlOverride = function (Tenant $tenant) use ($baseUrl) { - $scheme = str($baseUrl)->before('://'); - $hostname = str($baseUrl)->after($scheme . '://'); - - return $scheme . '://' . $tenant->getTenantKey() . '.' . $hostname; - }; - - RootUrlBootstrapper::$rootUrlOverride = $rootUrlOverride; - - $tenant = Tenant::create(); - $tenantUrl = $rootUrlOverride($tenant); - - expect($tenantUrl)->not()->toBe($baseUrl); - - expect(url(route('home')))->toBe($baseUrl); - expect(URL::to('/'))->toBe($baseUrl); - expect(config('app.url'))->toBe($baseUrl); - - tenancy()->initialize($tenant); - - expect(url(route('home')))->toBe($tenantUrl); - expect(URL::to('/'))->toBe($tenantUrl); - expect(config('app.url'))->toBe($tenantUrl); - - tenancy()->end(); - - expect(url(route('home')))->toBe($baseUrl); - expect(URL::to('/'))->toBe($baseUrl); - expect(config('app.url'))->toBe($baseUrl); -}); - -test('url binding tenancy bootstrapper swaps the url generator instance correctly', function() { - config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]); - - tenancy()->initialize(Tenant::create()); - expect(app('url'))->toBeInstanceOf(TenancyUrlGenerator::class); - expect(url())->toBeInstanceOf(TenancyUrlGenerator::class); - - tenancy()->end(); - expect(app('url'))->toBeInstanceOf(UrlGenerator::class) - ->not()->toBeInstanceOf(TenancyUrlGenerator::class); - expect(url())->toBeInstanceOf(UrlGenerator::class) - ->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]); - - $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); - - - 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); - - // Ending tenancy reverts route() behavior changes - tenancy()->end(); - - expect(route('home'))->toBe($centralRouteUrl); -}); - -test('both the name prefixing and the tenant parameter logic gets skipped when bypass parameter is used', function () { - $tenantParameterName = PathTenantResolver::tenantParameterName(); - - 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]); - - $tenant = Tenant::create(); - $centralRouteUrl = route('home'); - $tenantRouteUrl = route('tenant.home', ['tenant' => $tenant->getTenantKey()]); - config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]); - - TenancyUrlGenerator::$prefixRouteNames = true; - TenancyUrlGenerator::$bypassParameter = 'bypassParameter'; - - tenancy()->initialize($tenant); - - // The $bypassParameter parameter ('central' by default) can bypass the route name prefixing - // When the bypass parameter is true, the generated route URL points to the route named 'home' - expect(route('home', ['bypassParameter' => true]))->toBe($centralRouteUrl) - // Bypass parameter prevents passing the tenant parameter directly - ->not()->toContain($tenantParameterName . '=') - // Bypass parameter gets removed from the generated URL automatically - ->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) - ->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); -}); - -test('fortify route tenancy bootstrapper updates fortify config correctly', function() { - config(['tenancy.bootstrappers' => [FortifyRouteBootstrapper::class]]); - - $originalFortifyHome = config('fortify.home'); - $originalFortifyRedirects = config('fortify.redirects'); - - Route::get('/home', function () { - 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; - - // Make login redirect to the central welcome route - FortifyRouteBootstrapper::$fortifyRedirectMap['login'] = [ - 'route_name' => $welcomeRouteName, - 'context' => Context::CENTRAL, - ]; - - 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']); - - 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; - - 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; - - 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"]); -}); - -test('database tenancy bootstrapper throws an exception if DATABASE_URL is set', function (string|null $databaseUrl) { - if ($databaseUrl) { - config(['database.connections.central.url' => $databaseUrl]); - - pest()->expectException(Exception::class); - } - - config(['tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class]]); - - $tenant1 = Tenant::create(); - - pest()->artisan('tenants:migrate'); - - tenancy()->initialize($tenant1); - - expect(true)->toBe(true); -})->with(['abc.us-east-1.rds.amazonaws.com', null]); - -test('BroadcastChannelPrefixBootstrapper prefixes the channels events are broadcast on while tenancy is initialized', function() { - config([ - 'broadcasting.default' => $driver = 'testing', - 'broadcasting.connections.testing.driver' => $driver, - ]); - - // Use custom broadcaster - app(BroadcastManager::class)->extend($driver, fn () => new TestingBroadcaster('original broadcaster')); - - config(['tenancy.bootstrappers' => [BroadcastChannelPrefixBootstrapper::class, DatabaseTenancyBootstrapper::class]]); - - Schema::create('users', function (Blueprint $table) { - $table->increments('id'); - $table->string('name'); - $table->string('email')->unique(); - $table->string('password'); - $table->rememberToken(); - $table->timestamps(); - }); - - universal_channel('users.{userId}', function ($user, $userId) { - return User::find($userId)->is($user); - }); - - $broadcaster = app(BroadcastManager::class)->driver(); - - $tenant = Tenant::create(); - $tenant2 = Tenant::create(); - - pest()->artisan('tenants:migrate'); - - // Set up the 'testing' broadcaster override - // Identical to the default Pusher override (BroadcastChannelPrefixBootstrapper::pusher()) - // Except for the parent class (TestingBroadcaster instead of PusherBroadcaster) - BroadcastChannelPrefixBootstrapper::$broadcasterOverrides['testing'] = function (BroadcastManager $broadcastManager) { - $broadcastManager->extend('testing', function ($app, $config) { - return new class('tenant broadcaster') extends TestingBroadcaster { - protected function formatChannels(array $channels) - { - $formatChannel = function (string $channel) { - $prefixes = ['private-', 'presence-']; - $defaultPrefix = ''; - - foreach ($prefixes as $prefix) { - if (str($channel)->startsWith($prefix)) { - $defaultPrefix = $prefix; - break; - } - } - - // Skip prefixing channels flagged with the global channel prefix - if (! str($channel)->startsWith('global__')) { - $channel = str($channel)->after($defaultPrefix)->prepend($defaultPrefix . tenant()->getTenantKey() . '.'); - } - - return (string) $channel; - }; - - return array_map($formatChannel, parent::formatChannels($channels)); - } - }; - }); - }; - - auth()->login($user = User::create(['name' => 'central', 'email' => 'test@central.cz', 'password' => 'test'])); - - // The channel names used for testing the formatChannels() method (not real channels) - $channelNames = [ - 'channel', - 'global__channel', // Channels prefixed with 'global__' shouldn't get prefixed with the tenant key - 'private-user.' . $user->id, - ]; - - // formatChannels doesn't prefix the channel names until tenancy is initialized - expect(invade(app(BroadcastManager::class)->driver())->formatChannels($channelNames))->toEqual($channelNames); - - tenancy()->initialize($tenant); - - $tenantBroadcaster = app(BroadcastManager::class)->driver(); - - auth()->login($tenantUser = User::create(['name' => 'tenant', 'email' => 'test@tenant.cz', 'password' => 'test'])); - - // The current (tenant) broadcaster isn't the same as the central one - expect($tenantBroadcaster->message)->not()->toBe($broadcaster->message); - // Tenant broadcaster has the same channels as the central broadcaster - expect($tenantBroadcaster->getChannels())->toEqualCanonicalizing($broadcaster->getChannels()); - // formatChannels prefixes the channel names now - expect(invade($tenantBroadcaster)->formatChannels($channelNames))->toEqualCanonicalizing([ - 'global__channel', - $tenant->getTenantKey() . '.channel', - 'private-' . $tenant->getTenantKey() . '.user.' . $tenantUser->id, - ]); - - // Initialize another tenant - tenancy()->initialize($tenant2); - - auth()->login($tenantUser = User::create(['name' => 'tenant', 'email' => 'test2@tenant.cz', 'password' => 'test'])); - - // formatChannels prefixes channels with the second tenant's key now - expect(invade(app(BroadcastManager::class)->driver())->formatChannels($channelNames))->toEqualCanonicalizing([ - 'global__channel', - $tenant2->getTenantKey() . '.channel', - 'private-' . $tenant2->getTenantKey() . '.user.' . $tenantUser->id, - ]); - - // The bootstrapper reverts to the tenant context – the channel names won't be prefixed anymore - tenancy()->end(); - - // The current broadcaster is the same as the central one again - expect(app(BroadcastManager::class)->driver())->toBe($broadcaster); - expect(invade(app(BroadcastManager::class)->driver())->formatChannels($channelNames))->toEqual($channelNames); -}); - -function getDiskPrefix(string $disk): string -{ - /** @var FilesystemAdapter $disk */ - $disk = Storage::disk($disk); - $adapter = $disk->getAdapter(); - $prefix = invade(invade($adapter)->prefixer)->prefix; - - return $prefix; -} diff --git a/tests/Bootstrappers/BootstrapperTest.php b/tests/Bootstrappers/BootstrapperTest.php new file mode 100644 index 00000000..10120f85 --- /dev/null +++ b/tests/Bootstrappers/BootstrapperTest.php @@ -0,0 +1,248 @@ +mockConsoleOutput = false; + + config([ + 'cache.default' => 'redis', + 'tenancy.cache.stores' => ['redis'], + ]); + + Event::listen( + TenantCreated::class, + JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener() + ); + + Event::listen(TenancyInitialized::class, BootstrapTenancy::class); + Event::listen(TenancyEnded::class, RevertToCentralContext::class); +}); + +test('database data is separated', function () { + config(['tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class]]); + + $tenant1 = Tenant::create(); + $tenant2 = Tenant::create(); + + pest()->artisan('tenants:migrate'); + + tenancy()->initialize($tenant1); + + // Create Foo user + DB::table('users')->insert(['name' => 'Foo', 'email' => 'foo@bar.com', 'password' => 'secret']); + expect(DB::table('users')->get())->toHaveCount(1); + + tenancy()->initialize($tenant2); + + // Assert Foo user is not in this DB + expect(DB::table('users')->get())->toHaveCount(0); + // Create Bar user + DB::table('users')->insert(['name' => 'Bar', 'email' => 'bar@bar.com', 'password' => 'secret']); + expect(DB::table('users')->get())->toHaveCount(1); + + tenancy()->initialize($tenant1); + + // Assert Bar user is not in this DB + expect(DB::table('users')->get())->toHaveCount(1); + expect(DB::table('users')->first()->name)->toBe('Foo'); +}); + +test('cache data is separated', function (string $store, string $bootstrapper) { + config([ + 'tenancy.bootstrappers' => [$bootstrapper], + 'tenancy.cache.stores' => [$store], + 'cache.default' => $store, + ]); + + if ($store === 'database') { + config([ + 'cache.stores.database.connection' => 'central', + 'cache.stores.database.lock_connection' => 'central', + ]); + + Schema::create('cache', function (Blueprint $table) { + $table->string('key')->primary(); + $table->mediumText('value'); + $table->integer('expiration'); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->integer('expiration'); + }); + } + + $tenant1 = Tenant::create(); + $tenant2 = Tenant::create(); + + cache()->set('foo', 'central'); + expect(Cache::get('foo'))->toBe('central'); + + tenancy()->initialize($tenant1); + + // Assert central cache doesn't leak to tenant context + expect(Cache::has('foo'))->toBeFalse(); + + cache()->set('foo', 'bar'); + expect(Cache::get('foo'))->toBe('bar'); + + tenancy()->initialize($tenant2); + + // Assert one tenant's data doesn't leak to another tenant + expect(Cache::has('foo'))->toBeFalse(); + + cache()->set('foo', 'xyz'); + expect(Cache::get('foo'))->toBe('xyz'); + + tenancy()->initialize($tenant1); + + // Assert data didn't leak to original tenant + expect(Cache::get('foo'))->toBe('bar'); + + tenancy()->end(); + + // Assert central is still the same + expect(Cache::get('foo'))->toBe('central'); +})->with([ + ['redis', CacheTagsBootstrapper::class], + ['memcached', CacheTagsBootstrapper::class], + + ['file', FilesystemTenancyBootstrapper::class], + + ['redis', CacheTenancyBootstrapper::class], + ['apc', CacheTenancyBootstrapper::class], + ['memcached', CacheTenancyBootstrapper::class], + ['database', CacheTenancyBootstrapper::class], + ['dynamodb', CacheTenancyBootstrapper::class], +]); + +test('redis data is separated', function () { + config(['tenancy.bootstrappers' => [ + RedisTenancyBootstrapper::class, + ]]); + + $tenant1 = Tenant::create(); + $tenant2 = Tenant::create(); + + tenancy()->initialize($tenant1); + Redis::set('foo', 'bar'); + expect(Redis::get('foo'))->toBe('bar'); + + tenancy()->initialize($tenant2); + expect(Redis::get('foo'))->toBe(null); + Redis::set('foo', 'xyz'); + Redis::set('abc', 'def'); + expect(Redis::get('foo'))->toBe('xyz'); + expect(Redis::get('abc'))->toBe('def'); + + tenancy()->initialize($tenant1); + expect(Redis::get('foo'))->toBe('bar'); + expect(Redis::get('abc'))->toBe(null); + + $tenant3 = Tenant::create(); + tenancy()->initialize($tenant3); + expect(Redis::get('foo'))->toBe(null); + expect(Redis::get('abc'))->toBe(null); +}); + +test('filesystem data is separated', function () { + config([ + 'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class], + 'session.driver' => 'file', + 'cache.default' => 'file', + 'tenancy.cache.stores' => ['file'], + ]); + + $old_storage_path = storage_path(); + $old_storage_facade_roots = []; + foreach (config('tenancy.filesystem.disks') as $disk) { + $old_storage_facade_roots[$disk] = config("filesystems.disks.{$disk}.root"); + } + + $tenant1 = Tenant::create(); + $tenant2 = Tenant::create(); + + tenancy()->initialize($tenant1); + + Storage::disk('public')->put('foo', 'bar'); + expect(Storage::disk('public')->get('foo'))->toBe('bar'); + + tenancy()->initialize($tenant2); + expect(Storage::disk('public')->exists('foo'))->toBeFalse(); + Storage::disk('public')->put('foo', 'xyz'); + Storage::disk('public')->put('abc', 'def'); + expect(Storage::disk('public')->get('foo'))->toBe('xyz'); + expect(Storage::disk('public')->get('abc'))->toBe('def'); + + tenancy()->initialize($tenant1); + expect(Storage::disk('public')->get('foo'))->toBe('bar'); + expect(Storage::disk('public')->exists('abc'))->toBeFalse(); + + $tenant3 = Tenant::create(); + tenancy()->initialize($tenant3); + expect(Storage::disk('public')->exists('foo'))->toBeFalse(); + expect(Storage::disk('public')->exists('abc'))->toBeFalse(); + + $expected_storage_path = $old_storage_path . '/tenant' . tenant('id'); // /tenant = suffix base + + // Check that disk prefixes respect the root_override logic + expect(getDiskPrefix('local'))->toBe($expected_storage_path . '/app/'); + expect(getDiskPrefix('public'))->toBe($expected_storage_path . '/app/public/'); + pest()->assertSame('tenant' . tenant('id') . '/', getDiskPrefix('s3'), '/'); + + // Check suffixing logic + $new_storage_path = storage_path(); + expect($new_storage_path)->toEqual($expected_storage_path); + + // Check cache path + $cachePath = cache()->store()->getStore()->getDirectory(); + expect($cachePath) + ->toBe(config('cache.stores.file.path')) + ->toBe(storage_path('framework/cache/data')); + expect($cachePath)->toContain(tenant('id')); + + // Check session path + $sessionPath = invade(app('session')->driver()->getHandler())->path; + expect($sessionPath) + ->toBe(config('session.files')) + ->toBe(storage_path('framework/sessions')); + expect($sessionPath)->toContain(tenant('id')); + + // URL generation is tested separately in FilesystemTenancyBootstrapperTest +}); + +function getDiskPrefix(string $disk): string +{ + /** @var FilesystemAdapter $disk */ + $disk = Storage::disk($disk); + $adapter = $disk->getAdapter(); + $prefix = invade(invade($adapter)->prefixer)->prefix; + + return $prefix; +} diff --git a/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php b/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php new file mode 100644 index 00000000..c10aa70f --- /dev/null +++ b/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php @@ -0,0 +1,142 @@ +send(function (TenantCreated $event) { + return $event->tenant; + })->toListener() + ); +}); + +test('BroadcastChannelPrefixBootstrapper prefixes the channels events are broadcast on while tenancy is initialized', function() { + config([ + 'broadcasting.default' => $driver = 'testing', + 'broadcasting.connections.testing.driver' => $driver, + ]); + + // Use custom broadcaster + app(BroadcastManager::class)->extend($driver, fn () => new TestingBroadcaster('original broadcaster')); + + config(['tenancy.bootstrappers' => [BroadcastChannelPrefixBootstrapper::class, DatabaseTenancyBootstrapper::class]]); + + Schema::create('users', function (Blueprint $table) { + $table->increments('id'); + $table->string('name'); + $table->string('email')->unique(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + + universal_channel('users.{userId}', function ($user, $userId) { + return User::find($userId)->is($user); + }); + + $broadcaster = app(BroadcastManager::class)->driver(); + + $tenant = Tenant::create(); + $tenant2 = Tenant::create(); + + pest()->artisan('tenants:migrate'); + + // Set up the 'testing' broadcaster override + // Identical to the default Pusher override (BroadcastChannelPrefixBootstrapper::pusher()) + // Except for the parent class (TestingBroadcaster instead of PusherBroadcaster) + BroadcastChannelPrefixBootstrapper::$broadcasterOverrides['testing'] = function (BroadcastManager $broadcastManager) { + $broadcastManager->extend('testing', function ($app, $config) { + return new class('tenant broadcaster') extends TestingBroadcaster { + protected function formatChannels(array $channels) + { + $formatChannel = function (string $channel) { + $prefixes = ['private-', 'presence-']; + $defaultPrefix = ''; + + foreach ($prefixes as $prefix) { + if (str($channel)->startsWith($prefix)) { + $defaultPrefix = $prefix; + break; + } + } + + // Skip prefixing channels flagged with the global channel prefix + if (! str($channel)->startsWith('global__')) { + $channel = str($channel)->after($defaultPrefix)->prepend($defaultPrefix . tenant()->getTenantKey() . '.'); + } + + return (string) $channel; + }; + + return array_map($formatChannel, parent::formatChannels($channels)); + } + }; + }); + }; + + auth()->login($user = User::create(['name' => 'central', 'email' => 'test@central.cz', 'password' => 'test'])); + + // The channel names used for testing the formatChannels() method (not real channels) + $channelNames = [ + 'channel', + 'global__channel', // Channels prefixed with 'global__' shouldn't get prefixed with the tenant key + 'private-user.' . $user->id, + ]; + + // formatChannels doesn't prefix the channel names until tenancy is initialized + expect(invade(app(BroadcastManager::class)->driver())->formatChannels($channelNames))->toEqual($channelNames); + + tenancy()->initialize($tenant); + + $tenantBroadcaster = app(BroadcastManager::class)->driver(); + + auth()->login($tenantUser = User::create(['name' => 'tenant', 'email' => 'test@tenant.cz', 'password' => 'test'])); + + // The current (tenant) broadcaster isn't the same as the central one + expect($tenantBroadcaster->message)->not()->toBe($broadcaster->message); + // Tenant broadcaster has the same channels as the central broadcaster + expect($tenantBroadcaster->getChannels())->toEqualCanonicalizing($broadcaster->getChannels()); + // formatChannels prefixes the channel names now + expect(invade($tenantBroadcaster)->formatChannels($channelNames))->toEqualCanonicalizing([ + 'global__channel', + $tenant->getTenantKey() . '.channel', + 'private-' . $tenant->getTenantKey() . '.user.' . $tenantUser->id, + ]); + + // Initialize another tenant + tenancy()->initialize($tenant2); + + auth()->login($tenantUser = User::create(['name' => 'tenant', 'email' => 'test2@tenant.cz', 'password' => 'test'])); + + // formatChannels prefixes channels with the second tenant's key now + expect(invade(app(BroadcastManager::class)->driver())->formatChannels($channelNames))->toEqualCanonicalizing([ + 'global__channel', + $tenant2->getTenantKey() . '.channel', + 'private-' . $tenant2->getTenantKey() . '.user.' . $tenantUser->id, + ]); + + // The bootstrapper reverts to the tenant context – the channel names won't be prefixed anymore + tenancy()->end(); + + // The current broadcaster is the same as the central one again + expect(app(BroadcastManager::class)->driver())->toBe($broadcaster); + expect(invade(app(BroadcastManager::class)->driver())->formatChannels($channelNames))->toEqual($channelNames); +}); + diff --git a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php new file mode 100644 index 00000000..23efac3c --- /dev/null +++ b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php @@ -0,0 +1,105 @@ + [BroadcastingConfigBootstrapper::class]]); + + expect(app(BroadcastManager::class))->toBeInstanceOf(BroadcastManager::class); + + tenancy()->initialize(Tenant::create()); + + expect(app(BroadcastManager::class))->toBeInstanceOf(TenancyBroadcastManager::class); + + tenancy()->end(); + + expect(app(BroadcastManager::class))->toBeInstanceOf(BroadcastManager::class); +}); + +test('BroadcastingConfigBootstrapper maps tenant broadcaster credentials to config as specified in the $credentialsMap property and reverts the config after ending tenancy', function() { + config([ + 'broadcasting.connections.testing.driver' => 'testing', + 'broadcasting.connections.testing.message' => $defaultMessage = 'default', + 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], + ]); + + BroadcastingConfigBootstrapper::$credentialsMap = [ + 'broadcasting.connections.testing.message' => 'testing_broadcaster_message', + ]; + + $tenant = Tenant::create(['testing_broadcaster_message' => $tenantMessage = 'first testing']); + $tenant2 = Tenant::create(['testing_broadcaster_message' => $secondTenantMessage = 'second testing']); + + tenancy()->initialize($tenant); + + expect(array_key_exists('testing_broadcaster_message', tenant()->getAttributes()))->toBeTrue(); + expect(config('broadcasting.connections.testing.message'))->toBe($tenantMessage); + + tenancy()->initialize($tenant2); + + expect(config('broadcasting.connections.testing.message'))->toBe($secondTenantMessage); + + tenancy()->end(); + + expect(config('broadcasting.connections.testing.message'))->toBe($defaultMessage); +}); + +test('BroadcastingConfigBootstrapper makes the app use broadcasters with the correct credentials', function() { + config([ + 'broadcasting.default' => 'testing', + 'broadcasting.connections.testing.driver' => 'testing', + 'broadcasting.connections.testing.message' => $defaultMessage = 'default', + 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], + ]); + + TenancyBroadcastManager::$tenantBroadcasters[] = 'testing'; + BroadcastingConfigBootstrapper::$credentialsMap = [ + 'broadcasting.connections.testing.message' => 'testing_broadcaster_message', + ]; + + $registerTestingBroadcaster = fn() => app(BroadcastManager::class)->extend('testing', fn ($app, $config) => new TestingBroadcaster($config['message'])); + + $registerTestingBroadcaster(); + + expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($defaultMessage); + + $tenant = Tenant::create(['testing_broadcaster_message' => $tenantMessage = 'first testing']); + $tenant2 = Tenant::create(['testing_broadcaster_message' => $secondTenantMessage = 'second testing']); + + tenancy()->initialize($tenant); + $registerTestingBroadcaster(); + + expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($tenantMessage); + + tenancy()->initialize($tenant2); + $registerTestingBroadcaster(); + + expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($secondTenantMessage); + + tenancy()->end(); + $registerTestingBroadcaster(); + + expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($defaultMessage); +}); + diff --git a/tests/CacheTagsBootstrapperTest.php b/tests/Bootstrappers/CacheTagsBootstrapperTest.php similarity index 93% rename from tests/CacheTagsBootstrapperTest.php rename to tests/Bootstrappers/CacheTagsBootstrapperTest.php index 744d81d2..fa63fc6c 100644 --- a/tests/CacheTagsBootstrapperTest.php +++ b/tests/Bootstrappers/CacheTagsBootstrapperTest.php @@ -7,11 +7,14 @@ use Illuminate\Support\Facades\Event; use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Bootstrappers\CacheTagsBootstrapper; +use Stancl\Tenancy\Events\TenancyEnded; +use Stancl\Tenancy\Listeners\RevertToCentralContext; beforeEach(function () { config(['tenancy.bootstrappers' => [CacheTagsBootstrapper::class]]); Event::listen(TenancyInitialized::class, BootstrapTenancy::class); + Event::listen(TenancyEnded::class, RevertToCentralContext::class); }); test('default tag is automatically applied', function () { @@ -48,7 +51,7 @@ test('exception is thrown when more than one argument is passed to tags method', cache()->tags(1, 2); }); -test('tags separate cache well enough', function () { +test('tags separate cache properly', function () { $tenant1 = Tenant::create(); tenancy()->initialize($tenant1); diff --git a/tests/CacheTenancyBootstrapperTest.php b/tests/Bootstrappers/CacheTenancyBootstrapperTest.php similarity index 69% rename from tests/CacheTenancyBootstrapperTest.php rename to tests/Bootstrappers/CacheTenancyBootstrapperTest.php index 6f1e1bcc..1f28e20b 100644 --- a/tests/CacheTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/CacheTenancyBootstrapperTest.php @@ -17,9 +17,9 @@ beforeEach(function () { 'tenancy.bootstrappers' => [ CacheTenancyBootstrapper::class ], - 'cache.default' => $cacheDriver = 'redis', - 'cache.stores.' . $secondCacheDriver = 'redis2' => config('cache.stores.redis'), - 'tenancy.cache.stores' => [$cacheDriver, $secondCacheDriver], + 'cache.default' => 'redis', + 'cache.stores.redis2' => config('cache.stores.redis'), + 'tenancy.cache.stores' => ['redis', 'redis2'], ]); CacheTenancyBootstrapper::$prefixGenerator = null; @@ -34,8 +34,8 @@ afterEach(function () { test('correct cache prefix is used in all contexts', function () { $originalPrefix = config('cache.prefix'); - $prefixBase = config('tenancy.cache.prefix_base'); - $getDefaultPrefixForTenant = fn (Tenant $tenant) => $originalPrefix . $prefixBase . $tenant->getTenantKey(); + $prefixFormat = config('tenancy.cache.prefix'); + $getDefaultPrefixForTenant = fn (Tenant $tenant) => $originalPrefix . str($prefixFormat)->replace('%tenant%', $tenant->getTenantKey())->toString(); $bootstrapper = app(CacheTenancyBootstrapper::class); $expectCachePrefixToBe = function (string $prefix) { @@ -43,7 +43,7 @@ test('correct cache prefix is used in all contexts', function () { ->toBe(app('cache')->getPrefix()) ->toBe(app('cache.store')->getPrefix()) ->toBe(cache()->getPrefix()) - ->toBe(cache()->store('redis2')->getPrefix()); // Non-default cache stores specified in $tenantCacheStores are prefixed too + ->toBe(cache()->store('redis2')->getPrefix()); }; $expectCachePrefixToBe($originalPrefix); @@ -55,13 +55,13 @@ test('correct cache prefix is used in all contexts', function () { cache()->set('key', 'tenantone-value'); $tenantOnePrefix = $getDefaultPrefixForTenant($tenant1); $expectCachePrefixToBe($tenantOnePrefix); - expect($bootstrapper->generatePrefix($tenant1))->toBe($tenantOnePrefix); + expect($bootstrapper->generatePrefix($tenant1, 'redis'))->toBe($tenantOnePrefix); tenancy()->initialize($tenant2); cache()->set('key', 'tenanttwo-value'); $tenantTwoPrefix = $getDefaultPrefixForTenant($tenant2); $expectCachePrefixToBe($tenantTwoPrefix); - expect($bootstrapper->generatePrefix($tenant2))->toBe($tenantTwoPrefix); + expect($bootstrapper->generatePrefix($tenant2, 'redis'))->toBe($tenantTwoPrefix); // Prefix gets reverted to default after ending tenancy tenancy()->end(); @@ -132,9 +132,68 @@ test('central cache is persisted', function () { expect(cache()->get('key2'))->toBeNull(); }); +test('only the stores specified in the config get prefixed', function () { + // Make sure the currently used store ('redis') is the only store in the config + // This means that the 'redis2' store won't be prefixed + config(['tenancy.cache.stores' => ['redis']]); + + cache()->store('redis')->put('key', 'central'); + expect(cache()->store('redis')->get('key'))->toBe('central'); + // same values -- the stores use the same connection, with the same prefix here + expect(cache()->store('redis2')->get('key'))->toBe('central'); + + $tenant = Tenant::create(); + tenancy()->initialize($tenant); + + // now the 'redis' store is prefixed, but 'redis2' isn't + expect(cache()->store('redis2')->get('key'))->toBe('central'); + expect(cache()->store('redis')->get('key'))->toBe(null); // central value not leaked to tenant context + + cache()->store('redis')->put('key', 'tenant'); // change the value of the prefixed store + expect(cache()->store('redis')->get('key'))->toBe('tenant'); // prefixed store + + tenancy()->end(); + // still central + expect(cache()->store('redis2')->get('key'))->toBe('central'); + expect(cache()->store('redis')->get('key'))->toBe('central'); + + tenancy()->initialize($tenant); + expect(cache()->store('redis2')->get('key'))->toBe('central'); // still central + expect(cache()->store('redis')->get('key'))->toBe('tenant'); + cache()->store('redis2')->put('key', 'foo'); // override non-prefixed store value + cache()->store('redis')->put('key', 'tenant'); // the connection with the prefix still retains the tenant value + + tenancy()->end(); + // both redis2 and redis should now be 'foo' since they got overridden previously + expect(cache()->store('redis2')->get('key'))->toBe('foo'); + expect(cache()->store('redis')->get('key'))->toBe('foo'); +}); + +test('non default stores get prefixed too when specified in the config', function () { + config([ + 'cache.default' => 'redis', + 'tenancy.cache.stores' => ['redis', 'redis2'], + ]); + + $tenant = Tenant::create(); + $defaultPrefix = cache()->store()->getPrefix(); + $bootstrapper = app(CacheTenancyBootstrapper::class); + + expect(cache()->store('redis')->getPrefix())->toBe($defaultPrefix); + expect(cache()->store('redis2')->getPrefix())->toBe($defaultPrefix); + + tenancy()->initialize($tenant); + + expect($bootstrapper->generatePrefix($tenant, 'redis2')) + ->toBe(cache()->getPrefix()) + ->toBe(cache()->store('redis2')->getPrefix()); // Non-default store + + tenancy()->end(); +}); + test('cache base prefix is customizable', function () { config([ - 'tenancy.cache.prefix_base' => $prefixBase = 'custom_' + 'tenancy.cache.prefix' => 'custom_%tenant%_' ]); $originalPrefix = config('cache.prefix'); @@ -142,13 +201,35 @@ test('cache base prefix is customizable', function () { tenancy()->initialize($tenant1); - expect($originalPrefix . $prefixBase . $tenant1->getTenantKey()) + expect($originalPrefix . 'custom_' . $tenant1->getTenantKey() . '_') ->toBe(cache()->getPrefix()) - ->toBe(cache()->store('redis2')->getPrefix()) // Non-default store gets prefixed correctly too + ->toBe(cache()->store('redis2')->getPrefix()) ->toBe(app('cache')->getPrefix()) ->toBe(app('cache.store')->getPrefix()); }); +test('cache store prefix generation can be customized', function() { + // Use custom prefix generator + CacheTenancyBootstrapper::generatePrefixUsing($customPrefixGenerator = function (Tenant $tenant) { + return 'redis_tenant_cache_' . $tenant->getTenantKey(); + }); + + expect(CacheTenancyBootstrapper::$prefixGenerator)->toBe($customPrefixGenerator); + expect(app(CacheTenancyBootstrapper::class)->generatePrefix($tenant = Tenant::create(), 'redis')) + ->toBe($customPrefixGenerator($tenant)); + + tenancy()->initialize($tenant = Tenant::create()); + + // Expect the 'redis' store to use the prefix generated by the custom generator + expect($customPrefixGenerator($tenant)) + ->toBe(cache()->getPrefix()) + ->toBe(cache()->store('redis2')->getPrefix()) + ->toBe(app('cache')->getPrefix()) + ->toBe(app('cache.store')->getPrefix()); + + tenancy()->end(); +}); + test('cache is prefixed correctly when using a repository injected in a singleton', function () { $this->app->singleton(CacheService::class); @@ -183,7 +264,7 @@ test('specific central cache store can be used inside a service', function () { // Name of the non-default, central cache store that we'll use using cache()->store($cacheStore) $cacheStore = 'redis2'; - // Service uses the 'redis2' store which is central/not prefixed (not present in PrefixCacheTenancyBootstrapper::$tenantCacheStores) + // Service uses the 'redis2' store which is central/not prefixed (not present in tenancy.cache.stores config) // The service's handle() method sets the value of the cache key 'key' to the current tenant key // Or to 'central-value' if tenancy isn't initialized $this->app->singleton(SpecificCacheStoreService::class, function() use ($cacheStore) { @@ -213,111 +294,3 @@ test('specific central cache store can be used inside a service', function () { // We last executed handle() in tenant2's context, so the value should persist as tenant2's id expect(cache()->store($cacheStore)->get('key'))->toBe($tenant2->getTenantKey()); }); - -test('only the stores specified in tenantCacheStores get prefixed', function () { - // Make sure the currently used store ('redis') is the only store in $tenantCacheStores - config(['tenancy.cache.stores' => [$prefixedStore = 'redis']]); - - $centralValue = 'central-value'; - $assertStoreIsNotPrefixed = function (string $unprefixedStore) use ($prefixedStore, $centralValue) { - // Switch to the unprefixed store - config(['cache.default' => $unprefixedStore]); - expect(cache('key'))->toBe($centralValue); - // Switch back to the prefixed store - config(['cache.default' => $prefixedStore]); - }; - - $this->app->singleton(CacheService::class); - - $this->app->make(CacheService::class)->handle(); - expect(cache('key'))->toBe($centralValue); - - $tenant1 = Tenant::create(); - $tenant2 = Tenant::create(); - - tenancy()->initialize($tenant1); - - expect(cache('key'))->toBeNull(); - $this->app->make(CacheService::class)->handle(); - expect(cache('key'))->toBe($tenant1->getTenantKey()); - - $assertStoreIsNotPrefixed('redis2'); - - tenancy()->initialize($tenant2); - - expect(cache('key'))->toBeNull(); - $this->app->make(CacheService::class)->handle(); - expect(cache('key'))->toBe($tenant2->getTenantKey()); - - $assertStoreIsNotPrefixed('redis2'); - - tenancy()->end(); - expect(cache('key'))->toBe($centralValue); - - $this->app->make(CacheService::class)->handle(); - expect(cache('key'))->toBe($centralValue); -}); - -test('non default stores get prefixed too when specified in tenantCacheStores', function () { - // In beforeEach, we set $tenantCacheStores to ['redis', 'redis2'] - // Make 'redis' the default cache driver - config(['cache.default' => 'redis']); - - $tenant = Tenant::create(); - $defaultPrefix = cache()->store()->getPrefix(); - $bootstrapper = app(CacheTenancyBootstrapper::class); - - // The prefix is the same for both drivers in the central context - expect(cache()->store('redis')->getPrefix())->toBe($defaultPrefix); - expect(cache()->store('redis2')->getPrefix())->toBe($defaultPrefix); - - tenancy()->initialize($tenant); - - // We didn't add a prefix generator for our 'redis2' driver, so we expect the prefix to be generated using the 'default' generator - expect($bootstrapper->generatePrefix($tenant)) - ->toBe(cache()->getPrefix()) - ->toBe(cache()->store('redis2')->getPrefix()); // Non-default store - - tenancy()->end(); -}); - -test('cache store prefix generation can be customized', function() { - // Use custom prefix generator - CacheTenancyBootstrapper::generatePrefixUsing($customPrefixGenerator = function (Tenant $tenant) { - return 'redis_tenant_cache_' . $tenant->getTenantKey(); - }); - - expect(CacheTenancyBootstrapper::$prefixGenerator)->toBe($customPrefixGenerator); - expect(app(CacheTenancyBootstrapper::class)->generatePrefix($tenant = Tenant::create())) - ->toBe($customPrefixGenerator($tenant)); - - tenancy()->initialize($tenant = Tenant::create()); - - // Expect the 'redis' store to use the prefix generated by the custom generator - expect($customPrefixGenerator($tenant)) - ->toBe(cache()->getPrefix()) - ->toBe(cache()->store('redis2')->getPrefix()) // Non-default cache stores specified in $tenantCacheStores are prefixed too - ->toBe(app('cache')->getPrefix()) - ->toBe(app('cache.store')->getPrefix()); - - tenancy()->end(); -}); - -test('stores get prefixed using the default way if no prefix generator is specified', function() { - $originalPrefix = config('cache.prefix'); - $prefixBase = config('tenancy.cache.prefix_base'); - $tenant = Tenant::create(); - $defaultPrefix = $originalPrefix . $prefixBase . $tenant->getTenantKey(); - - // Don't specify a prefix generator - // Let the prefix get created using the default approach - tenancy()->initialize($tenant); - - // All stores use the default way of generating the prefix when the prefix generator isn't specified - expect($defaultPrefix) - ->toBe(app(CacheTenancyBootstrapper::class)->generatePrefix($tenant)) - ->toBe(cache()->getPrefix()) // Get prefix of the default store ('redis') - ->toBe(cache()->store('redis2')->getPrefix()); - - tenancy()->end(); -}); diff --git a/tests/DatabaseSessionBootstrapperTest.php b/tests/Bootstrappers/DatabaseSessionBootstrapperTest.php similarity index 96% rename from tests/DatabaseSessionBootstrapperTest.php rename to tests/Bootstrappers/DatabaseSessionBootstrapperTest.php index 20cd19b6..baca93ac 100644 --- a/tests/DatabaseSessionBootstrapperTest.php +++ b/tests/Bootstrappers/DatabaseSessionBootstrapperTest.php @@ -39,7 +39,7 @@ use Stancl\Tenancy\Tests\Etc\Tenant; // Sessions table for central database pest()->artisan('migrate', [ - '--path' => __DIR__ . '/Etc/session_migrations', + '--path' => __DIR__ . '/../Etc/session_migrations', '--realpath' => true, ])->assertExitCode(0); }); @@ -58,7 +58,7 @@ test('central helper can be used in tenant requests', function (bool $enabled, b // run for tenants pest()->artisan('tenants:migrate', [ - '--path' => __DIR__ . '/Etc/session_migrations', + '--path' => __DIR__ . '/../Etc/session_migrations', '--realpath' => true, ])->assertExitCode(0); @@ -109,7 +109,7 @@ test('tenant run helper can be used on central requests', function (bool $enable // run for tenants pest()->artisan('tenants:migrate', [ - '--path' => __DIR__ . '/Etc/session_migrations', + '--path' => __DIR__ . '/../Etc/session_migrations', '--realpath' => true, ])->assertExitCode(0); diff --git a/tests/Bootstrappers/DatabaseTenancyBootstrapper.php b/tests/Bootstrappers/DatabaseTenancyBootstrapper.php new file mode 100644 index 00000000..8c8259cd --- /dev/null +++ b/tests/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -0,0 +1,32 @@ + $databaseUrl]); + + pest()->expectException(Exception::class); + } + + config(['tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class]]); + + $tenant1 = Tenant::create(); + + pest()->artisan('tenants:migrate'); + + tenancy()->initialize($tenant1); + + expect(true)->toBe(true); +})->with(['abc.us-east-1.rds.amazonaws.com', null]); + diff --git a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php new file mode 100644 index 00000000..1f9c018a --- /dev/null +++ b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php @@ -0,0 +1,202 @@ + [ + FilesystemTenancyBootstrapper::class, + ], + 'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/', + 'tenancy.filesystem.url_override.public' => 'public-%tenant%' + ]); + + $tenant1 = Tenant::create(); + $tenant2 = Tenant::create(); + $tenant1StorageUrl = 'http://localhost/public-' . $tenant1->getKey().'/'; + $tenant2StorageUrl = 'http://localhost/public-' . $tenant2->getKey().'/'; + + tenancy()->initialize($tenant1); + + $this->assertEquals( + $tenant1StorageUrl, + Storage::disk('public')->url('') + ); + + Storage::disk('public')->put($tenant1FileName = 'tenant1.txt', 'text'); + + $this->assertEquals( + $tenant1StorageUrl . $tenant1FileName, + Storage::disk('public')->url($tenant1FileName) + ); + + tenancy()->initialize($tenant2); + + $this->assertEquals( + $tenant2StorageUrl, + Storage::disk('public')->url('') + ); + + Storage::disk('public')->put($tenant2FileName = 'tenant2.txt', 'text'); + + $this->assertEquals( + $tenant2StorageUrl . $tenant2FileName, + Storage::disk('public')->url($tenant2FileName) + ); +}); + +test('files can get fetched using the storage url', function() { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/', + 'tenancy.filesystem.url_override.public' => 'public-%tenant%' + ]); + + $tenant1 = Tenant::create(); + $tenant2 = Tenant::create(); + + pest()->artisan('tenants:link'); + + // First tenant + tenancy()->initialize($tenant1); + Storage::disk('public')->put($tenantFileName = 'tenant1.txt', $tenantKey = $tenant1->getTenantKey()); + + $url = Storage::disk('public')->url($tenantFileName); + $tenantDiskName = str(config('tenancy.filesystem.url_override.public'))->replace('%tenant%', $tenantKey); + $hostname = str($url)->before($tenantDiskName); + $parsedUrl = str($url)->after($hostname); + + expect(file_get_contents(public_path($parsedUrl)))->toBe($tenantKey); + + // Second tenant + tenancy()->initialize($tenant2); + Storage::disk('public')->put($tenantFileName = 'tenant2.txt', $tenantKey = $tenant2->getTenantKey()); + + $url = Storage::disk('public')->url($tenantFileName); + $tenantDiskName = str(config('tenancy.filesystem.url_override.public'))->replace('%tenant%', $tenantKey); + $hostname = str($url)->before($tenantDiskName); + $parsedUrl = str($url)->after($hostname); + + expect(file_get_contents(public_path($parsedUrl)))->toBe($tenantKey); + + // Central + tenancy()->end(); + Storage::disk('public')->put($centralFileName = 'central.txt', $centralFileContent = 'central'); + + pest()->artisan('storage:link'); + $url = Storage::disk('public')->url($centralFileName); + + expect(file_get_contents(public_path($url)))->toBe($centralFileContent); +}); + +test('storage_path helper does not change if suffix_storage_path is off', function() { + $originalStoragePath = storage_path(); + + // todo@tests https://github.com/tenancy-for-laravel/v4/pull/44#issue-2228530362 + + config([ + 'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class], + 'tenancy.filesystem.suffix_storage_path' => false, + ]); + + tenancy()->initialize(Tenant::create()); + + $this->assertEquals($originalStoragePath, storage_path()); +}); + +test('links to storage disks with a configured root are suffixed if not overridden', function() { + config([ + 'filesystems.disks.public.root' => 'http://sample-s3-url.com/my-app', + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'tenancy.filesystem.root_override.public' => null, + 'tenancy.filesystem.url_override.public' => null, + ]); + + $tenant = Tenant::create(); + + $expectedStoragePath = storage_path() . '/tenant' . $tenant->getTenantKey(); // /tenant = suffix base + + tenancy()->initialize($tenant); + + // Check suffixing logic + expect(storage_path())->toEqual($expectedStoragePath); +}); + +test('create and delete storage symlinks jobs work', function() { + Event::listen( + TenantCreated::class, + JobPipeline::make([CreateStorageSymlinks::class])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener() + ); + + Event::listen( + TenantDeleted::class, + JobPipeline::make([RemoveStorageSymlinks::class])->send(function (TenantDeleted $event) { + return $event->tenant; + })->toListener() + ); + + 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(); + + tenancy()->initialize($tenant); + + $tenantKey = $tenant->getTenantKey(); + + $this->assertDirectoryExists(storage_path("app/public")); + $this->assertEquals(storage_path("app/public/"), readlink(public_path("public-$tenantKey"))); + + $tenant->delete(); + + $this->assertDirectoryDoesNotExist(public_path("public-$tenantKey")); +}); + +test('tenant storage can get deleted after the tenant when DeletingTenant listens to DeleteTenantStorage', function() { + Event::listen(DeletingTenant::class, DeleteTenantStorage::class); + + tenancy()->initialize(Tenant::create()); + $tenantStoragePath = storage_path(); + + Storage::fake('test'); + + expect(File::isDirectory($tenantStoragePath))->toBeTrue(); + + Storage::put('test.txt', 'testing file'); + + tenant()->delete(); + + expect(File::isDirectory($tenantStoragePath))->toBeFalse(); +}); diff --git a/tests/Bootstrappers/FortifyRouteBootstrapperTest.php b/tests/Bootstrappers/FortifyRouteBootstrapperTest.php new file mode 100644 index 00000000..d924ddbc --- /dev/null +++ b/tests/Bootstrappers/FortifyRouteBootstrapperTest.php @@ -0,0 +1,76 @@ + [FortifyRouteBootstrapper::class]]); + + $originalFortifyHome = config('fortify.home'); + $originalFortifyRedirects = config('fortify.redirects'); + + Route::get('/home', function () { + 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; + + // Make login redirect to the central welcome route + FortifyRouteBootstrapper::$fortifyRedirectMap['login'] = [ + 'route_name' => $welcomeRouteName, + 'context' => Context::CENTRAL, + ]; + + 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']); + + 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; + + 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; + + 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"]); +}); diff --git a/tests/Bootstrappers/MailTenancyBootstrapper.php b/tests/Bootstrappers/MailTenancyBootstrapper.php new file mode 100644 index 00000000..ad19c2b4 --- /dev/null +++ b/tests/Bootstrappers/MailTenancyBootstrapper.php @@ -0,0 +1,62 @@ + 'smtp_username', + 'mail.mailers.smtp.password' => 'smtp_password' + ]; + + config([ + 'mail.default' => 'smtp', + 'mail.mailers.smtp.username' => $defaultUsername = 'default username', + 'mail.mailers.smtp.password' => 'no password', + 'tenancy.bootstrappers' => [MailConfigBootstrapper::class], + ]); + + $tenant = Tenant::create(['smtp_password' => $password = 'testing password']); + + tenancy()->initialize($tenant); + + expect(array_key_exists('smtp_password', tenant()->getAttributes()))->toBeTrue(); + expect(array_key_exists('smtp_host', tenant()->getAttributes()))->toBeFalse(); + expect(config('mail.mailers.smtp.username'))->toBe($defaultUsername); + expect(config('mail.mailers.smtp.password'))->toBe(tenant()->smtp_password); + + // Assert that the current mailer uses tenant's smtp_password + assertMailerTransportUsesPassword($password); +}); + +test('MailTenancyBootstrapper reverts the config and mailer credentials to default when tenancy ends', function() { + MailConfigBootstrapper::$credentialsMap = ['mail.mailers.smtp.password' => 'smtp_password']; + config([ + 'mail.default' => 'smtp', + 'mail.mailers.smtp.password' => $defaultPassword = 'no password', + 'tenancy.bootstrappers' => [MailConfigBootstrapper::class], + ]); + + tenancy()->initialize(Tenant::create(['smtp_password' => $tenantPassword = 'testing password'])); + + expect(config('mail.mailers.smtp.password'))->toBe($tenantPassword); + + assertMailerTransportUsesPassword($tenantPassword); + + tenancy()->end(); + + expect(config('mail.mailers.smtp.password'))->toBe($defaultPassword); + + // Assert that the current mailer uses the default SMTP password + assertMailerTransportUsesPassword($defaultPassword); +}); + diff --git a/tests/Bootstrappers/RootUrlBootstrapperTest.php b/tests/Bootstrappers/RootUrlBootstrapperTest.php new file mode 100644 index 00000000..24eb441a --- /dev/null +++ b/tests/Bootstrappers/RootUrlBootstrapperTest.php @@ -0,0 +1,67 @@ + [RootUrlBootstrapper::class]]); + + Route::group([ + 'middleware' => InitializeTenancyBySubdomain::class, + ], function () { + Route::get('/', function () { + return true; + })->name('home'); + }); + + $baseUrl = url(route('home')); + config(['app.url' => $baseUrl]); + + $rootUrlOverride = function (Tenant $tenant) use ($baseUrl) { + $scheme = str($baseUrl)->before('://'); + $hostname = str($baseUrl)->after($scheme . '://'); + + return $scheme . '://' . $tenant->getTenantKey() . '.' . $hostname; + }; + + RootUrlBootstrapper::$rootUrlOverride = $rootUrlOverride; + + $tenant = Tenant::create(); + $tenantUrl = $rootUrlOverride($tenant); + + expect($tenantUrl)->not()->toBe($baseUrl); + + expect(url(route('home')))->toBe($baseUrl); + expect(URL::to('/'))->toBe($baseUrl); + expect(config('app.url'))->toBe($baseUrl); + + tenancy()->initialize($tenant); + + expect(url(route('home')))->toBe($tenantUrl); + expect(URL::to('/'))->toBe($tenantUrl); + expect(config('app.url'))->toBe($tenantUrl); + + tenancy()->end(); + + expect(url(route('home')))->toBe($baseUrl); + expect(URL::to('/'))->toBe($baseUrl); + expect(config('app.url'))->toBe($baseUrl); +}); + diff --git a/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php b/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php new file mode 100644 index 00000000..4ef8dce7 --- /dev/null +++ b/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php @@ -0,0 +1,157 @@ + [UrlGeneratorBootstrapper::class]]); + + tenancy()->initialize(Tenant::create()); + expect(app('url'))->toBeInstanceOf(TenancyUrlGenerator::class); + expect(url())->toBeInstanceOf(TenancyUrlGenerator::class); + + tenancy()->end(); + expect(app('url'))->toBeInstanceOf(UrlGenerator::class) + ->not()->toBeInstanceOf(TenancyUrlGenerator::class); + expect(url())->toBeInstanceOf(UrlGenerator::class) + ->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]); + + $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); + + + 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); + + // Ending tenancy reverts route() behavior changes + tenancy()->end(); + + expect(route('home'))->toBe($centralRouteUrl); +}); + +test('both the name prefixing and the tenant parameter logic gets skipped when bypass parameter is used', function () { + $tenantParameterName = PathTenantResolver::tenantParameterName(); + + 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]); + + $tenant = Tenant::create(); + $centralRouteUrl = route('home'); + $tenantRouteUrl = route('tenant.home', ['tenant' => $tenant->getTenantKey()]); + config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]); + + TenancyUrlGenerator::$prefixRouteNames = true; + TenancyUrlGenerator::$bypassParameter = 'bypassParameter'; + + tenancy()->initialize($tenant); + + // The $bypassParameter parameter ('central' by default) can bypass the route name prefixing + // When the bypass parameter is true, the generated route URL points to the route named 'home' + expect(route('home', ['bypassParameter' => true]))->toBe($centralRouteUrl) + // Bypass parameter prevents passing the tenant parameter directly + ->not()->toContain($tenantParameterName . '=') + // Bypass parameter gets removed from the generated URL automatically + ->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) + ->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/CombinedDomainAndSubdomainIdentificationTest.php b/tests/CombinedDomainAndSubdomainIdentificationTest.php index 8d613875..f7201b3a 100644 --- a/tests/CombinedDomainAndSubdomainIdentificationTest.php +++ b/tests/CombinedDomainAndSubdomainIdentificationTest.php @@ -20,7 +20,7 @@ beforeEach(function () { }); test('tenant can be identified by subdomain', function () { - config(['tenancy.central_domains' => ['localhost']]); + config(['tenancy.identification.central_domains' => ['localhost']]); $tenant = CombinedTenant::create([ 'id' => 'acme', @@ -41,7 +41,7 @@ test('tenant can be identified by subdomain', function () { }); test('tenant can be identified by domain', function () { - config(['tenancy.central_domains' => []]); + config(['tenancy.identification.central_domains' => []]); $tenant = CombinedTenant::create([ 'id' => 'acme', diff --git a/tests/GlobalCacheTest.php b/tests/GlobalCacheTest.php index 15156827..72ba5ebd 100644 --- a/tests/GlobalCacheTest.php +++ b/tests/GlobalCacheTest.php @@ -37,6 +37,10 @@ test('global cache manager stores data in global cache', function (string $boots cache(['def' => 'ghi'], 10); expect(cache('def'))->toBe('ghi'); + // different stores, same underlying connection. the prefix is set ON THE STORE + expect(cache()->store()->getStore() === GlobalCache::store()->getStore())->toBeFalse(); + expect(cache()->store()->getStore()->connection() === GlobalCache::store()->getStore()->connection())->toBeTrue(); + tenancy()->end(); expect(GlobalCache::get('abc'))->toBe('xyz'); expect(GlobalCache::get('foo'))->toBe('bar'); @@ -63,6 +67,10 @@ test('the global_cache helper supports the same syntax as the cache helper', fun $tenant = Tenant::create(); $tenant->enter(); + // different stores, same underlying connection. the prefix is set ON THE STORE + expect(cache()->store()->getStore() === global_cache()->store()->getStore())->toBeFalse(); + expect(cache()->store()->getStore()->connection() === global_cache()->store()->getStore()->connection())->toBeTrue(); + expect(cache('foo'))->toBe(null); // tenant cache is empty global_cache(['foo' => 'bar']); diff --git a/tests/OriginHeaderIdentificationTest.php b/tests/OriginHeaderIdentificationTest.php index dd5b683b..a32777da 100644 --- a/tests/OriginHeaderIdentificationTest.php +++ b/tests/OriginHeaderIdentificationTest.php @@ -10,7 +10,7 @@ beforeEach(function () { InitializeTenancyByOriginHeader::$onFail = null; config([ - 'tenancy.central_domains' => [ + 'tenancy.identification.central_domains' => [ 'localhost', ], ]); diff --git a/tests/RequestDataIdentificationTest.php b/tests/RequestDataIdentificationTest.php index 104beb7c..70792adb 100644 --- a/tests/RequestDataIdentificationTest.php +++ b/tests/RequestDataIdentificationTest.php @@ -9,7 +9,7 @@ use Stancl\Tenancy\Tests\Etc\Tenant; beforeEach(function () { config([ - 'tenancy.central_domains' => [ + 'tenancy.identification.central_domains' => [ 'localhost', ], ]); diff --git a/tests/ScopeSessionsTest.php b/tests/ScopeSessionsTest.php index 27fa911f..5a8a9e51 100644 --- a/tests/ScopeSessionsTest.php +++ b/tests/ScopeSessionsTest.php @@ -3,9 +3,7 @@ declare(strict_types=1); use Illuminate\Session\Middleware\StartSession; -use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Route; -use Stancl\Tenancy\Events\TenantCreated; use Stancl\Tenancy\Exceptions\TenancyNotInitializedException; use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain; use Stancl\Tenancy\Middleware\ScopeSessions; @@ -19,30 +17,21 @@ beforeEach(function () { return 'true'; }); }); - - Event::listen(TenantCreated::class, function (TenantCreated $event) { - $tenant = $event->tenant; - - /** @var Tenant $tenant */ - $tenant->domains()->create([ - 'domain' => $tenant->id, - ]); - }); }); test('tenant id is auto added to session if its missing', function () { - $tenant = Tenant::create([ + Tenant::create([ 'id' => 'acme', - ]); + ])->createDomain('acme'); pest()->get('http://acme.localhost/foo') ->assertSessionHas(ScopeSessions::$tenantIdKey, 'acme'); }); test('changing tenant id in session will abort the request', function () { - $tenant = Tenant::create([ + Tenant::create([ 'id' => 'acme', - ]); + ])->createDomain('acme'); pest()->get('http://acme.localhost/foo') ->assertSuccessful(); @@ -58,10 +47,10 @@ test('an exception is thrown when the middleware is executed before tenancy is i return true; })->middleware([StartSession::class, ScopeSessions::class]); - $tenant = Tenant::create([ + Tenant::create([ 'id' => 'acme', - ]); + ])->createDomain('acme'); pest()->expectException(TenancyNotInitializedException::class); - pest()->withoutExceptionHandling()->get('http://acme.localhost/bar'); + $this->withoutExceptionHandling()->get('http://acme.localhost/bar'); }); diff --git a/tests/SessionSeparationTest.php b/tests/SessionSeparationTest.php new file mode 100644 index 00000000..9ac22a7d --- /dev/null +++ b/tests/SessionSeparationTest.php @@ -0,0 +1,247 @@ +make(\Illuminate\Contracts\Http\Kernel::class)->prependToMiddlewarePriority($middleware); + } +}); + +test('file sessions are separated', function (bool $scopeSessions) { + config([ + 'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class], + 'tenancy.filesystem.suffix_storage_path' => false, + 'tenancy.filesystem.scope_sessions' => $scopeSessions, + 'session.driver' => 'file', + ]); + + $sessionPath = fn () => invade(app('session')->driver()->getHandler())->path; + + expect($sessionPath())->toBe(storage_path('framework/sessions')); + File::cleanDirectory(storage_path("framework/sessions")); // clean up the sessions dir from past test runs + + $tenant = Tenant::create(); + $tenant->enter(); + + if ($scopeSessions) { + expect($sessionPath())->toBe(storage_path('tenant' . $tenant->getTenantKey() . '/framework/sessions')); + } else { + expect($sessionPath())->toBe(storage_path('framework/sessions')); + } + + $tenant->leave(); + + Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar'); + + if ($scopeSessions) { + expect(File::files(storage_path("tenant{$tenant->id}/framework/sessions")))->toHaveCount(0); + } else { + expect(File::exists(storage_path("tenant{$tenant->id}/framework/sessions")))->toBeFalse(); + } + + pest()->get("/{$tenant->id}/foo"); + + if ($scopeSessions) { + expect(File::files(storage_path("tenant{$tenant->id}/framework/sessions")))->toHaveCount(1); + expect(File::files(storage_path("framework/sessions")))->toHaveCount(0); + } else { + expect(File::exists(storage_path("tenant{$tenant->id}/framework/sessions")))->toBeFalse(); + expect(File::files(storage_path("framework/sessions")))->toHaveCount(1); + } +})->with([true, false]); + +test('redis sessions are separated using the redis bootstrapper', function (bool $bootstrappedEnabled) { + config([ + 'tenancy.bootstrappers' => $bootstrappedEnabled ? [RedisTenancyBootstrapper::class] : [], + 'session.driver' => 'redis', + ]); + + $redisClient = app('session')->driver()->getHandler()->getCache()->getStore()->connection()->client(); + expect($redisClient->getOption($redisClient::OPT_PREFIX))->toBe('foo'); // default prefix configured in TestCase + + expect(Redis::keys('*'))->toHaveCount(0); + + $tenant = Tenant::create(); + Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar'); + pest()->get("/{$tenant->id}/foo"); + + expect($redisClient->getOption($redisClient::OPT_PREFIX) === "tenant_{$tenant->id}_")->toBe($bootstrappedEnabled); + + expect(array_filter(Redis::keys('*'), function (string $key) use ($tenant) { + return str($key)->startsWith("tenant_{$tenant->id}_laravel_cache_"); + }))->toHaveCount($bootstrappedEnabled ? 1 : 0); +})->with([true, false]); + +test('redis sessions are separated using the cache bootstrapper', function (bool $scopeSessions) { + config([ + 'tenancy.bootstrappers' => [CacheTenancyBootstrapper::class], + 'session.driver' => 'redis', + 'tenancy.cache.stores' => [], // will be implicitly filled + 'tenancy.cache.scope_sessions' => $scopeSessions, + ]); + + expect(Redis::keys('*'))->toHaveCount(0); + + $tenant = Tenant::create(); + Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar'); + pest()->get("/{$tenant->id}/foo"); + + expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === "laravel_cache_tenant_{$tenant->id}_")->toBe($scopeSessions); + + tenancy()->end(); + expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe('laravel_cache_'); + + expect(array_filter(Redis::keys('*'), function (string $key) use ($tenant) { + return str($key)->startsWith("foolaravel_cache_tenant_{$tenant->id}"); + }))->toHaveCount($scopeSessions ? 1 : 0); +})->with([true, false]); + +test('memcached sessions are separated using the cache bootstrapper', function (bool $scopeSessions) { + config([ + 'tenancy.bootstrappers' => [CacheTenancyBootstrapper::class], + 'session.driver' => 'memcached', + 'tenancy.cache.stores' => [], // will be implicitly filled + 'tenancy.cache.scope_sessions' => $scopeSessions, + ]); + + $allMemcachedKeys = fn () => cache()->store('memcached')->getStore()->getMemcached()->getAllKeys(); + + if (count($allMemcachedKeys()) !== 0) { + sleep(1); + } + + expect($allMemcachedKeys())->toHaveCount(0); + + $tenant = Tenant::create(); + Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar'); + pest()->get("/{$tenant->id}/foo"); + + expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === "laravel_cache_tenant_{$tenant->id}_")->toBe($scopeSessions); + + tenancy()->end(); + expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe('laravel_cache_'); + + sleep(1.1); // 1s+ sleep is necessary for getAllKeys() to work. if this causes race conditions or we want to avoid the delay, we can refactor this to some type of a mock + expect(array_filter($allMemcachedKeys(), function (string $key) use ($tenant) { + return str($key)->startsWith("laravel_cache_tenant_{$tenant->id}"); + }))->toHaveCount($scopeSessions ? 1 : 0); + + Artisan::call('cache:clear memcached'); +})->with([true, false]); + +test('dynamodb sessions are separated using the cache bootstrapper', function (bool $scopeSessions) { + config([ + 'tenancy.bootstrappers' => [CacheTenancyBootstrapper::class], + 'session.driver' => 'dynamodb', + 'tenancy.cache.stores' => [], // will be implicitly filled + 'tenancy.cache.scope_sessions' => $scopeSessions, + ]); + + $allDynamodbKeys = fn () => array_map(fn ($res) => $res['key']['S'], cache()->store('dynamodb')->getStore()->getClient()->scan(['TableName' => 'cache'])['Items']); + + expect($allDynamodbKeys())->toHaveCount(0); + + $tenant = Tenant::create(); + Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar'); + pest()->get("/{$tenant->id}/foo"); + + expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === "laravel_cache_tenant_{$tenant->id}_")->toBe($scopeSessions); + + tenancy()->end(); + expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe('laravel_cache_'); + + expect(array_filter($allDynamodbKeys(), function (string $key) use ($tenant) { + return str($key)->startsWith("laravel_cache_tenant_{$tenant->id}"); + }))->toHaveCount($scopeSessions ? 1 : 0); +})->with([true, false]); + +test('apc sessions are separated using the cache bootstrapper', function (bool $scopeSessions) { + config([ + 'tenancy.bootstrappers' => [CacheTenancyBootstrapper::class], + 'session.driver' => 'apc', + 'tenancy.cache.stores' => [], // will be implicitly filled + 'tenancy.cache.scope_sessions' => $scopeSessions, + ]); + + $allApcuKeys = fn () => array_column(apcu_cache_info()['cache_list'], 'info'); + + $tenant = Tenant::create(); + Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar'); + pest()->get("/{$tenant->id}/foo"); + + expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === "laravel_cache_tenant_{$tenant->id}_")->toBe($scopeSessions); + + tenancy()->end(); + expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe('laravel_cache_'); + + expect(array_filter($allApcuKeys(), function (string $key) use ($tenant) { + return str($key)->startsWith("laravel_cache_tenant_{$tenant->id}"); + }))->toHaveCount($scopeSessions ? 1 : 0); +})->with([true, false]); + +test('database sessions are separated regardless of whether the session bootstrapper is enabled', function (bool $sessionBootstrappedEnabled, bool $connectionSet) { + config([ + 'tenancy.bootstrappers' => $sessionBootstrappedEnabled + ? [DatabaseTenancyBootstrapper::class, DatabaseSessionBootstrapper::class] + : [DatabaseTenancyBootstrapper::class], + 'session.driver' => 'database', + 'session.connection' => $connectionSet ? 'central' : null, + 'tenancy.migration_parameters.--schema-path' => 'tests/Etc/session_migrations', + ]); + + Event::listen( + TenantCreated::class, + JobPipeline::make([CreateDatabase::class, MigrateDatabase::class])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener() + ); + + pest()->artisan('migrate', [ + '--path' => __DIR__ . '/Etc/session_migrations', + '--realpath' => true, + ])->assertExitCode(0); + + expect(DB::connection('central')->table('sessions')->count())->toBe(0); + + $tenant = Tenant::create(); + Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar'); + pest()->get("/{$tenant->id}/foo"); + + expect(invade(app('session')->driver()->getHandler())->connection->getName())->toBe('tenant'); + + expect(DB::connection('tenant')->table('sessions')->count())->toBe(1); + expect(DB::connection('central')->table('sessions')->count())->toBe(0); +})->with([ + [true, true], + [true, false], + // [false, true], // when the connection IS set, the session bootstrapper becomes necessary + [false, false], +]); diff --git a/tests/SubdomainTest.php b/tests/SubdomainTest.php index eefdc7ca..9ddc48ba 100644 --- a/tests/SubdomainTest.php +++ b/tests/SubdomainTest.php @@ -87,7 +87,7 @@ test('oninvalidsubdomain logic can be customized', function () { }); test('we cant use a subdomain that doesnt belong to our central domains', function () { - config(['tenancy.central_domains' => [ + config(['tenancy.identification.central_domains' => [ '127.0.0.1', // not 'localhost' ]]); diff --git a/tests/TestCase.php b/tests/TestCase.php index ed3b20cc..ba706d26 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,12 +4,13 @@ declare(strict_types=1); namespace Stancl\Tenancy\Tests; +use Aws\DynamoDb\DynamoDbClient; use PDO; use Dotenv\Dotenv; -use Stancl\Tenancy\Tenancy; use Stancl\Tenancy\Tests\Etc\Tenant; use Illuminate\Support\Facades\Redis; use Illuminate\Foundation\Application; +use Illuminate\Support\Facades\Artisan; use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper; use Stancl\Tenancy\Facades\GlobalCache; use Stancl\Tenancy\TenancyServiceProvider; @@ -34,6 +35,46 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase Redis::connection('default')->flushdb(); Redis::connection('cache')->flushdb(); + Artisan::call('cache:clear memcached'); // flush memcached + Artisan::call('cache:clear file'); // flush file cache + apcu_clear_cache(); // flush APCu cache + + // re-create dynamodb `cache` table + $dynamodb = new DynamoDbClient([ + 'region' => 'us-east-1', + 'version' => 'latest', + 'endpoint' => 'http://dynamodb:8000', + 'credentials' => [ + 'key' => env('TENANCY_TEST_DYNAMODB_KEY', 'DUMMYIDEXAMPLE'), + 'secret' => env('TENANCY_TEST_DYNAMODB_KEY', 'DUMMYEXAMPLEKEY'), + ], + ]); + + try { + $dynamodb->deleteTable([ + 'TableName' => 'cache', + ]); + } catch (\Throwable) {} + + $dynamodb->createTable([ + 'TableName' => 'cache', + 'KeySchema' => [ + [ + 'AttributeName' => 'key', // Partition key + 'KeyType' => 'HASH', + ] + ], + 'AttributeDefinitions' => [ + [ + 'AttributeName' => 'key', + 'AttributeType' => 'S', // String + ] + ], + 'ProvisionedThroughput' => [ + 'ReadCapacityUnits' => 100, + 'WriteCapacityUnits' => 100, + ], + ]); file_put_contents(database_path('central.sqlite'), ''); pest()->artisan('migrate:fresh', [ @@ -67,10 +108,18 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase $app['config']->set([ 'database.default' => 'central', 'cache.default' => 'redis', - 'database.redis.cache.host' => env('TENANCY_TEST_REDIS_HOST', '127.0.0.1'), - 'database.redis.default.host' => env('TENANCY_TEST_REDIS_HOST', '127.0.0.1'), + 'session.driver' => 'redis', + 'database.redis.cache.host' => env('TENANCY_TEST_REDIS_HOST', 'redis'), + 'database.redis.default.host' => env('TENANCY_TEST_REDIS_HOST', 'redis'), 'database.redis.options.prefix' => 'foo', 'database.redis.client' => 'predis', + 'cache.stores.memcached.servers.0.host' => env('TENANCY_TEST_MEMCACHED_HOST', 'memcached'), + 'cache.stores.dynamodb.key' => env('TENANCY_TEST_DYNAMODB_KEY', 'DUMMYIDEXAMPLE'), + 'cache.stores.dynamodb.secret' => env('TENANCY_TEST_DYNAMODB_SECRET', 'DUMMYEXAMPLEKEY'), + 'cache.stores.dynamodb.endpoint' => 'http://dynamodb:8000', + 'cache.stores.dynamodb.region' => 'us-east-1', + 'cache.stores.dynamodb.table' => 'cache', + 'cache.stores.apc' => ['driver' => 'apc'], 'database.connections.central' => [ 'driver' => 'mysql', 'url' => env('DATABASE_URL'), @@ -114,7 +163,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase '--realpath' => true, '--force' => true, ], - 'tenancy.central_domains' => ['localhost', '127.0.0.1'], + 'tenancy.identification.central_domains' => ['localhost', '127.0.0.1'], 'tenancy.bootstrappers' => [], 'queue.connections.central' => [ 'driver' => 'sync',