1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2025-12-12 08:04:03 +00:00

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 <phpcsfixer@example.com>
This commit is contained in:
Samuel Štancl 2024-04-09 20:40:27 +02:00 committed by GitHub
parent 943b960718
commit eecf6f21c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1856 additions and 1177 deletions

19
.gitattributes vendored
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string, string> */
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);
}
}

View file

@ -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),

View file

@ -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);
}
}
}

View file

@ -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 */

View file

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

View file

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

View file

@ -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);

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -117,6 +117,16 @@ class Tenancy
return array_map('app', $resolve($this->tenant));
}
/**
* Check if a bootstrapper is being used.
*
* @param class-string<TenancyBootstrapper> $bootstrapper
*/
public function usingBootstrapper(string $bootstrapper): bool
{
return in_array($bootstrapper, static::getBootstrappers(), true);
}
public static function query(): Builder
{
return static::model()->query();

View file

@ -1,916 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Str;
use Stancl\Tenancy\Enums\Context;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\URL;
use Stancl\JobPipeline\JobPipeline;
use Illuminate\Support\Facades\File;
use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Jobs\CreateDatabase;
use Stancl\Tenancy\Events\TenantCreated;
use Stancl\Tenancy\Events\TenantDeleted;
use Illuminate\Database\Schema\Blueprint;
use Stancl\Tenancy\Events\DeletingTenant;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Broadcasting\BroadcastManager;
use Stancl\Tenancy\Events\TenancyInitialized;
use Illuminate\Contracts\Routing\UrlGenerator;
use Stancl\Tenancy\Jobs\CreateStorageSymlinks;
use Stancl\Tenancy\Jobs\RemoveStorageSymlinks;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Resolvers\PathTenantResolver;
use Stancl\Tenancy\Tests\Etc\TestingBroadcaster;
use Stancl\Tenancy\Listeners\DeleteTenantStorage;
use Stancl\Tenancy\Overrides\TenancyUrlGenerator;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Bootstrappers\RootUrlBootstrapper;
use Stancl\Tenancy\Overrides\TenancyBroadcastManager;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\Bootstrappers\CacheTagsBootstrapper;
use Illuminate\Routing\Exceptions\UrlGenerationException;
use Stancl\Tenancy\Bootstrappers\MailConfigBootstrapper;
use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper;
use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper;
use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper;
use Stancl\Tenancy\Bootstrappers\Integrations\FortifyRouteBootstrapper;
// todo refactor this file -- too much stuff happening here
beforeEach(function () {
$this->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;
}

View file

@ -0,0 +1,248 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\DB;
use Stancl\JobPipeline\JobPipeline;
use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Jobs\CreateDatabase;
use Stancl\Tenancy\Events\TenantCreated;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Filesystem\FilesystemAdapter;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Bootstrappers\CacheTagsBootstrapper;
use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper;
beforeEach(function () {
$this->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;
}

View file

@ -0,0 +1,142 @@
<?php
use Illuminate\Broadcasting\BroadcastManager;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Schema;
use Stancl\JobPipeline\JobPipeline;
use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Events\TenantCreated;
use Stancl\Tenancy\Jobs\CreateDatabase;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Tests\Etc\TestingBroadcaster;
beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
Event::listen(
TenantCreated::class,
JobPipeline::make([CreateDatabase::class])->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);
});

View file

@ -0,0 +1,105 @@
<?php
use Illuminate\Broadcasting\BroadcastManager;
use Illuminate\Support\Facades\Event;
use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Overrides\TenancyBroadcastManager;
use Stancl\Tenancy\Tests\Etc\TestingBroadcaster;
beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
BroadcastingConfigBootstrapper::$credentialsMap = [];
TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably'];
});
afterEach(function () {
BroadcastingConfigBootstrapper::$credentialsMap = [];
TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably'];
});
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);
});

View file

@ -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);

View file

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

View file

@ -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);

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Support\Facades\Event;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
});
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]);

View file

@ -0,0 +1,202 @@
<?php
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Stancl\JobPipeline\JobPipeline;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use Stancl\Tenancy\Events\DeletingTenant;
use Stancl\Tenancy\Events\TenantCreated;
use Stancl\Tenancy\Events\TenantDeleted;
use Stancl\Tenancy\Jobs\CreateStorageSymlinks;
use Stancl\Tenancy\Jobs\RemoveStorageSymlinks;
use Stancl\Tenancy\Listeners\DeleteTenantStorage;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
});
test('local storage public urls are generated correctly', 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(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();
});

View file

@ -0,0 +1,76 @@
<?php
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Bootstrappers\Integrations\FortifyRouteBootstrapper;
use Stancl\Tenancy\Enums\Context;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
});
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"]);
});

View file

@ -0,0 +1,62 @@
<?php
use Illuminate\Support\Facades\Event;
use Stancl\Tenancy\Bootstrappers\MailConfigBootstrapper;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
});
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);
});

View file

@ -0,0 +1,67 @@
<?php
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\URL;
use Stancl\Tenancy\Bootstrappers\RootUrlBootstrapper;
use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
RootUrlBootstrapper::$rootUrlOverride = null;
});
afterEach(function () {
RootUrlBootstrapper::$rootUrlOverride = null;
});
test('root 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);
});

View file

@ -0,0 +1,157 @@
<?php
use Illuminate\Routing\Exceptions\UrlGenerationException;
use Illuminate\Routing\UrlGenerator;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData;
use Stancl\Tenancy\Overrides\TenancyUrlGenerator;
use Stancl\Tenancy\Resolvers\PathTenantResolver;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
TenancyUrlGenerator::$prefixRouteNames = false;
TenancyUrlGenerator::$passTenantParameterToRoutes = true;
});
afterEach(function () {
TenancyUrlGenerator::$prefixRouteNames = false;
TenancyUrlGenerator::$passTenantParameterToRoutes = true;
});
test('url generator 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);
});

View file

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

View file

@ -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']);

View file

@ -10,7 +10,7 @@ beforeEach(function () {
InitializeTenancyByOriginHeader::$onFail = null;
config([
'tenancy.central_domains' => [
'tenancy.identification.central_domains' => [
'localhost',
],
]);

View file

@ -9,7 +9,7 @@ use Stancl\Tenancy\Tests\Etc\Tenant;
beforeEach(function () {
config([
'tenancy.central_domains' => [
'tenancy.identification.central_domains' => [
'localhost',
],
]);

View file

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

View file

@ -0,0 +1,247 @@
<?php
use Illuminate\Session\Middleware\StartSession;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Route;
use Stancl\JobPipeline\JobPipeline;
use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\DatabaseSessionBootstrapper;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Events\TenantCreated;
use Stancl\Tenancy\Jobs\CreateDatabase;
use Stancl\Tenancy\Jobs\MigrateDatabase;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\Middleware\PreventAccessFromUnwantedDomains;
use Stancl\Tenancy\Tests\Etc\Tenant;
beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
// Middleware priority logic
$tenancyMiddleware = array_merge([PreventAccessFromUnwantedDomains::class], config('tenancy.identification.middleware'));
foreach (array_reverse($tenancyMiddleware) as $middleware) {
app()->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],
]);

View file

@ -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'
]]);

View file

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