From e0990a438c985c6e7a6a366757bc2dbbaf92d460 Mon Sep 17 00:00:00 2001 From: Samuel Stancl Date: Tue, 4 Aug 2026 20:49:48 -0700 Subject: [PATCH 1/2] Laravel 13.24 support (fix #1474) (#1476) - We conditionally use either $signature + specifyParameters() in the newer versions or just $name in the older versions. There doesn't appear to be a single solution that'd work in both versions, likely having to do with the constructor override in the trait and how the specifyParameters() method behaves differently in this class between versions - Remove unnecessary options from the Run command (the trait adds those) - Not directly related: make HasTenantOptions accept ...$args - Unrelated: remove phpstan ignore in TenancyServiceProvider --- src/Commands/Run.php | 4 +--- src/Commands/Seed.php | 16 +++++++++++++--- src/Concerns/HasTenantOptions.php | 4 ++-- src/TenancyServiceProvider.php | 2 +- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/Commands/Run.php b/src/Commands/Run.php index d3435ca2..9b482682 100644 --- a/src/Commands/Run.php +++ b/src/Commands/Run.php @@ -16,9 +16,7 @@ class Run extends Command protected $description = 'Run a command for tenant(s)'; - protected $signature = 'tenants:run {commandname : The artisan command.} - {--tenants=* : The tenant(s) to run the command for. Default: all} - {--skip-tenants=* : The tenant(s) to skip}'; + protected $signature = 'tenants:run {commandname : The artisan command.}'; public function handle(): int { diff --git a/src/Commands/Seed.php b/src/Commands/Seed.php index 5cf468e9..5b9db3c4 100644 --- a/src/Commands/Seed.php +++ b/src/Commands/Seed.php @@ -16,11 +16,21 @@ class Seed extends SeedCommand protected $description = 'Seed tenant database(s).'; - protected $name = 'tenants:seed'; - public function __construct(ConnectionResolverInterface $resolver) { - parent::__construct($resolver); + // See https://github.com/archtechx/tenancy/issues/1474 + if (version_compare(app()->version(), '13.24.0', '>=')) { + $this->signature = 'tenants:seed + {class? : The class name of the root seeder} + {--class=Database\\Seeders\\DatabaseSeeder : The class name of the root seeder} + {--database= : The database connection to seed} + {--force : Force the operation to run when in production}'; + parent::__construct($resolver); + $this->specifyParameters(); + } else { + $this->name = 'tenants:seed'; + parent::__construct($resolver); + } } public function handle(): int diff --git a/src/Concerns/HasTenantOptions.php b/src/Concerns/HasTenantOptions.php index b10d7bd4..d89f2088 100644 --- a/src/Concerns/HasTenantOptions.php +++ b/src/Concerns/HasTenantOptions.php @@ -55,9 +55,9 @@ trait HasTenantOptions }); } - public function __construct() + public function __construct(mixed ...$args) { - parent::__construct(); + parent::__construct(...$args); $this->specifyParameters(); } diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index 23d1ffab..30baa5ce 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -103,7 +103,7 @@ class TenancyServiceProvider extends ServiceProvider $config['connection'] ??= $centralConnection; /** @var CacheManager $this */ - return $this->createDatabaseDriver($config); // @phpstan-ignore method.protected + return $this->createDatabaseDriver($config); }); // DatabaseCacheBootstrapper explicitly writes 'tenant' into each store's 'connection' From 52f97c1f6a266a5bf99e8c776b215b34412d2c73 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 8 Sep 2026 03:32:11 +0200 Subject: [PATCH 2/2] [MINOR BC] Fix FilesystemTenancyBootstrapper discarding configured cache and session paths (#1473) > The cache part of this is specific to `file`-driver stores that are listed in `tenancy.cache.stores`, while `tenancy.filesystem.scope_cache` is set to `true`. The session part applies to the `file` session driver, while `tenancy.filesystem.scope_sessions` is set to `true`. > > Ran into this while checking whether we could drop the separate 'parallel' cache store from our boilerplate's testing setup and instead just give the 'file' store a per-process path (`framework/cache/data_`), so each test process gets its own cache directory. Turns out `scopeCache()` discards configured paths entirely, so that has no effect (see below). Using a different directory for the `file` cache store by setting `cache.stores.file.path` (either using `config([...])`, or directly in `config/cache.php` -- doesn't matter) has no effect -- `FilesystemTenancyBootstrapper::scopeCache()` ignores `path`/`lock_path` entirely and rewrites both to a hardcoded `/framework/cache/data` path on every `tenancy()->initialize()`/`tenancy()->end()`: ```php // In `FilesystemTenancyBootstrapper::scopeCache()` (called both in `bootstrap()` and in `revert()`) 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; ... } ``` Specific issues with hardcoding the path like this: - a store with a configured (non-default) path gets scoped to use the default one (what I described above) - `lock_path` is always overwritten with `path`, so a store with a separate lock directory loses that separation - `revert()` runs the same code, so it doesn't restore what the store was configured with before tenancy initialized -- it just re-applies the same hardcoded default. Central cache ends up using the wrong path after ending tenancy. **`scopeSessions()` has the same bug.** It never reads `session.files`, it hardcodes `/framework/sessions` on both bootstrap and revert. So a configured session path gets discarded when tenancy initializes, and it doesn't get reverted back to what it was when tenancy ends. For example, with `session.files` set to `/tmp/foo-sessions`: ``` In tenant context: session.files = .../storage/tenant/framework/sessions After ending tenancy: session.files = .../storage/framework/sessions ``` So after ending tenancy, sessions don't go back to the configured `/tmp/foo-sessions`. They use the hardcoded `/framework/sessions` path, which was never configured anywhere. ### The fix In `bootstrap()`, `scopeCache()` captures the original configured paths and scopes those instead of using a hardcoded default. - If the configured path is under the central storage path, that central part gets swapped for the tenant's storage path, keeping everything after it the same (e.g. `storage/framework/cache/data` becomes `storage/tenant1/framework/cache/data`). - If the path isn't `storage_path()`-based, there's nothing to swap, so the tenant's suffix just gets appended to the end of the path instead. On `revert()`, `scopeCache(false)` puts the captured paths back into `cache.stores.{$name}.path`/`lock_path` and into the resolved store instance, so central cache uses the path it was configured with again. > The paths are captured just once, during `scopeCache()` at `bootstrap()`. If `bootstrap()` fails after `scopeCache()` (e.g. when `scopeSessions()` can't create its directory), `revert()` never runs and the config is left with the scoped paths, so capturing a second time would lose the central ones. Also, `revert()` iterates the stores whose paths were captured during bootstrap rather than `config('tenancy.cache.stores')`. Removing a store from that config in tenant context would otherwise make `revert()` skip it and leave it stuck with a tenant-scoped path, and adding one would make `tenancy()->end()` throw because its path was never captured (see the 'scopeCache ignores changes to tenancy.cache.stores made in tenant context' test). These are edge cases most users wouldn't notice, but still, worth mentioning. `lock_path` stays `null` when a store doesn't configure it, rather than us making it default to the scoped path -- `FileStore` already falls back to `path` for locks in that case, so we can just respect the store's original config. `scopeSessions()` does the same for `session.files`. It captures the configured path during `bootstrap()` (once, for the same reason as above), scopes it, and puts it back on `revert()` (the default `storage/framework/sessions` still ends up as `storage/tenant1/framework/sessions`, so nothing changes for the default config). Also added tests that cover each of the issues above (+ a test for handling paths that aren't `storage_path()`-based, and one for a custom `session.files` path). **POSSIBLE MINOR BC:** Someone with `'path' => '/var/cache/foobar'` currently gets tenant cache in `storage/tenant1/framework/cache/data`. After this fix, they get `/var/cache/foobar/tenant1`, so whatever is already cached in the old directory is orphaned. The same applies to a non-default `storage_path()`-based path, e.g. `'path' => storage_path('framework/cache/data_' . env('TEST_TOKEN', 'default'))`. The config is now respected while scoping. The same goes for sessions -- with a non-default `session.files`, tenant sessions move from `storage/tenant1/framework/sessions` to the configured path scoped for the tenant, so the sessions in the old directory are orphaned. ## Note about directory separators While implementing a method that centralizes scoping a path to the tenant (`tenantScopedPath()`), we looked into which code should use `DIRECTORY_SEPARATOR` instead of plain `/` (for Windows compatibility, overall correctness and consistency). In short, the separators only matter in code that compares paths -- there, both sides of the check have to use the same separators (it doesn't matter whether that's `DIRECTORY_SEPARATOR` or `/`). Strings that only get passed to the filesystem are fine with plain `/` -- the filesystem handles these just fine (for example, Laravel uses `storage_path('framework/cache/data')` as the default `file` cache store's path, and that works just fine on Windows). A thing related to this are the `rtrim()` calls. In `diskRoot()`'s "disk present in `tenancy.filesystem.disks`, but not in `tenancy.filesystem.root_override`" code branch, `rtrim()` only trimmed the `/` separator. So if a Windows user used a local disk like that, and configured that disk's path to use a trailing separator, the method would set the disk root to a path like `C:\app\uploads\/tenant1`. In practice, this shouldn't be an issue, but trimming both `/` and `\` prevents that code from setting the root to a weird path like that (so a very low impact change). The `tenantStoragePath()` method got the same treatment as `diskRoot()` mentioned above: the original storage path _could_ be configured with a trailing slash. Again, this was a non-issue, but only because the method's output didn't get compared to other strings in a way where this _could_ be an issue. The `rtrim` there is a _slight_ improvement, but primarily, this got changed for consistency with the change in `diskRoot()`. --------- Co-authored-by: Samuel Stancl --- .../FilesystemTenancyBootstrapper.php | 108 ++++-- .../FilesystemTenancyBootstrapperTest.php | 309 ++++++++++++++++++ tests/SessionSeparationTest.php | 51 +++ 3 files changed, 447 insertions(+), 21 deletions(-) diff --git a/src/Bootstrappers/FilesystemTenancyBootstrapper.php b/src/Bootstrappers/FilesystemTenancyBootstrapper.php index 0a864fbd..1574ea4b 100644 --- a/src/Bootstrappers/FilesystemTenancyBootstrapper.php +++ b/src/Bootstrappers/FilesystemTenancyBootstrapper.php @@ -8,12 +8,16 @@ use Exception; use Illuminate\Foundation\Application; use Illuminate\Session\FileSessionHandler; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; class FilesystemTenancyBootstrapper implements TenancyBootstrapper { public array $originalDisks = []; + protected array $originalCachePaths = []; + protected array $originalCacheLockPaths = []; + protected string|null $originalSessionPath = null; public string|null $originalAssetUrl; public string $originalStoragePath; @@ -94,7 +98,7 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper protected function tenantStoragePath(string $suffix): string { - return $this->originalStoragePath . "/{$suffix}"; + return rtrim($this->originalStoragePath, '/\\') . DIRECTORY_SEPARATOR . $suffix; } protected function assetHelper(string|false $suffix): void @@ -161,7 +165,7 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper // This is executed if the disk is in tenancy.filesystem.disks but does NOT have a root_override // This behavior is used for disks like S3. $newRoot = $originalRoot - ? rtrim($originalRoot, '/') . '/' . $suffix + ? rtrim($originalRoot, '/\\') . '/' . $suffix : $suffix; } @@ -191,29 +195,88 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper 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'; - }); + $stores = $suffix !== false + ? $this->app['config']['tenancy.cache.stores'] + : array_keys($this->originalCachePaths); foreach ($stores as $name) { - $path = $storagePath . '/framework/cache/data'; + $store = $this->app['config']["cache.stores.{$name}"]; + + // Only file stores have a path to scope. Skip stores that don't exist (null) or use another driver. + if ($store === null || $store['driver'] !== 'file') { + continue; + } + + if ($suffix !== false && ! isset($this->originalCachePaths[$name])) { + $this->originalCachePaths[$name] = $store['path']; + $this->originalCacheLockPaths[$name] = $store['lock_path'] ?? null; + } + + $path = $suffix ? $this->tenantScopedPath($this->originalCachePaths[$name], $suffix) : $this->originalCachePaths[$name]; + + // Unlike path, lock_path is optional -- if it's not set, FileStore::lock() falls back to path + // itself (see `$this->lockDirectory ?? $this->directory` in FileStore). Leave it null here rather + // than hardcoding it to $path ourselves, so a store that didn't configure a separate lock_path + // doesn't end up with one. + $lockPath = $this->originalCacheLockPaths[$name]; + if ($suffix && $lockPath !== null) { + $lockPath = $this->tenantScopedPath($lockPath, $suffix); + } + $this->app['config']["cache.stores.{$name}.path"] = $path; - $this->app['config']["cache.stores.{$name}.lock_path"] = $path; + $this->app['config']["cache.stores.{$name}.lock_path"] = $lockPath; /** @var \Illuminate\Cache\FileStore $store */ $store = $this->app['cache']->store($name)->getStore(); $store->setDirectory($path); - $store->setLockDirectory($path); + $store->setLockDirectory($lockPath); + } + } + + /** + * Scope a configured path (a cache store's path or lock_path, or the session path) + * to the tenant identified by $suffix. + */ + protected function tenantScopedPath(string $configuredPath, string $suffix): string + { + $configuredPath = $this->normalizePath($configuredPath); + $storagePath = $this->normalizePath($this->originalStoragePath); + + if (str_starts_with($configuredPath, $storagePath . DIRECTORY_SEPARATOR)) { + // Swap the central storage path prefix for the tenant's. + // For example, storage_path('framework/cache/data') becomes storage_path('tenant1/framework/cache/data'). + return str($configuredPath) + ->replaceFirst($storagePath, $this->tenantStoragePath($suffix)) + ->toString(); + } + + // Otherwise $configuredPath isn't necessarily storage_path()-based, so just append the + // suffix as a subdirectory, e.g. '/var/cache/foo' becomes '/var/cache/foo/tenant1'. + return $configuredPath . DIRECTORY_SEPARATOR . $suffix; + } + + /** + * Normalize the path to use the separator of the current OS. + * + * The separators are also deduplicated, with two exceptions: + * - if the path begins with \\ on Windows (i.e. a UNC path), the *leading* separators won't be deduplicated + * - if the path contains non-UTF-8 characters, the separators won't be deduplicated since Str::deduplicate() only supports UTF-8 strings) + */ + protected function normalizePath(string $path): string + { + $path = str_replace('/', DIRECTORY_SEPARATOR, $path); + + $uncPrefix = DIRECTORY_SEPARATOR === '\\' && str_starts_with($path, '\\\\') ? DIRECTORY_SEPARATOR : ''; + + if ($deduplicated = Str::deduplicate($path, DIRECTORY_SEPARATOR)) { + // On Windows, a path starting with two separators is a UNC path (e.g. '\\server\share'), + // so the leading separator that got collapsed by deduplicate() should be added back + // (only one \ will be kept, so we use one for the prefix). + return $uncPrefix . rtrim($deduplicated, DIRECTORY_SEPARATOR); + } else { + // Because deduplicate() only supports UTF-8 paths, paths with non-UTF-8 characters will not + // be deduplicated since deduplicate() returns an empty result with unsupported strings + return rtrim($path, DIRECTORY_SEPARATOR); } } @@ -223,12 +286,15 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper return; } + $originalPath = $this->originalSessionPath ?? $this->app['config']['session.files']; + $this->originalSessionPath = $originalPath; + $path = $suffix - ? $this->tenantStoragePath($suffix) . '/framework/sessions' - : $this->originalStoragePath . '/framework/sessions'; + ? $this->tenantScopedPath($originalPath, $suffix) + : $originalPath; if (! is_dir($path)) { - // Create tenant framework/sessions directory if it does not exist. + // Create tenant session directory if it does not exist. // We ignore errors due to TOCTOU race conditions, instead we check for success below. @mkdir($path, 0750, true); diff --git a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php index 4e834917..785ffbc3 100644 --- a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php @@ -1,6 +1,7 @@ toBe('central2'); expect(file_get_contents(storage_path() . "/tenant{$tenant->id}/app/public/scoped_disk_prefix/foo.txt"))->toBe('tenant'); }); + +test('file cache stores get their paths scoped on bootstrap and restored back on revert', function () { + $fooPath = storage_path('framework/cache/foo_file'); + $barPath = storage_path('framework/cache/bar_file'); + File::deleteDirectory($fooPath); + File::deleteDirectory($barPath); + + // Use separate 'foo_file'/'bar_file' stores rather than reconfiguring 'file'. + // TestCase::setUp() calls `cache:clear file`, which resolves the 'file' store and leaves it + // in the CacheManager with the default path. A later config() call can't mutate that, + // so in central context, cache would be written to 'storage/framework/cache/data' no matter how the config changes. + // The same applies to the other tests below that configure separate cache stores rather than using 'file'. + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'tenancy.cache.stores' => ['foo_file', 'bar_file'], + 'cache.stores.foo_file' => [ + 'driver' => 'file', + 'path' => $fooPath, + ], + 'cache.stores.bar_file' => [ + 'driver' => 'file', + 'path' => $barPath, + ], + ]); + + Cache::store('foo_file')->put('key', 'central foo'); + Cache::store('bar_file')->put('key', 'central bar'); + + tenancy()->initialize(Tenant::create()); + + Cache::store('foo_file')->put('key', 'tenant foo'); + Cache::store('bar_file')->put('key', 'tenant bar'); + + // Each store uses its own scoped path. + // The stores don't read or overwrite each other's entries. + expect(Cache::store('foo_file')->get('key'))->toBe('tenant foo'); + expect(Cache::store('bar_file')->get('key'))->toBe('tenant bar'); + + Cache::store('bar_file')->flush(); + + // Only bar_file was flushed + expect(Cache::store('foo_file')->get('key'))->toBe('tenant foo'); + expect(Cache::store('bar_file')->get('key'))->toBeNull(); + + tenancy()->end(); + + // revert() points each store back at its original configured path + expect(Cache::store('foo_file')->get('key'))->toBe('central foo'); + expect(Cache::store('bar_file')->get('key'))->toBe('central bar'); +}); + +test('only file driver cache stores get scoped', function () { + $centralStoragePath = storage_path(); + $fooPath = storage_path('framework/cache/foo_file'); + File::deleteDirectory($fooPath); + + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'cache.stores.foo_file' => [ + 'driver' => 'file', + 'path' => $fooPath, + ], + // Only stores that use the 'file' driver are scoped. + // The 'redis' store won't be scoped since it doesn't use the 'file' driver, + // and 'nonexistent_store' won't be scoped since it just doesn't exist. + 'tenancy.cache.stores' => ['foo_file', 'redis', 'nonexistent_store'], + ]); + + Cache::store('redis')->put('key', 'central'); + + tenancy()->initialize($tenant = Tenant::create()); + + // Only the file store's path gets scoped + expect(config('cache.stores.foo_file.path'))->toBe("{$centralStoragePath}/tenant{$tenant->id}/framework/cache/foo_file"); + expect(config('cache.stores.redis'))->not()->toHaveKey('path'); + expect(config('cache.stores.nonexistent_store'))->toBeNull(); + + // The 'redis' store doesn't use the file driver (no path to scope), + // so FilesystemTenancyBootstrapper skips it in scopeCache(). + // The central value is retained in the tenant context. + expect(Cache::store('redis')->get('key'))->toBe('central'); + + tenancy()->end(); +}); + +test('cache scoping can be toggled using the scope_cache config', function (bool $scopeCache) { + $fooPath = storage_path('framework/cache/foo_file'); + File::deleteDirectory($fooPath); + + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'tenancy.cache.stores' => ['foo_file'], + 'cache.stores.foo_file' => [ + 'driver' => 'file', + 'path' => $fooPath, + ], + 'tenancy.filesystem.scope_cache' => $scopeCache, + ]); + + Cache::store('foo_file')->put('key', 'central'); + + tenancy()->initialize(Tenant::create()); + + if ($scopeCache) { + expect(config('cache.stores.foo_file.path'))->not()->toBe($fooPath); + expect(Cache::store('foo_file')->get('key'))->toBe(null); + } else { + // The store keeps using its central path, so the cache is shared between contexts + expect(config('cache.stores.foo_file.path'))->toBe($fooPath); + expect(Cache::store('foo_file')->get('key'))->toBe('central'); + } + + Cache::store('foo_file')->put('key', 'written in tenant context'); + + tenancy()->end(); + + if ($scopeCache) { + expect(Cache::store('foo_file')->get('key'))->toBe('central'); + } else { + expect(Cache::store('foo_file')->get('key'))->toBe('written in tenant context'); + } +})->with([true, false]); + +test('scopeCache ignores changes to tenancy.cache.stores made in tenant context', function () { + $fooPath = storage_path('framework/cache/foo_file'); + $barPath = storage_path('framework/cache/bar_file'); + File::deleteDirectory($fooPath); + File::deleteDirectory($barPath); + + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'cache.stores.foo_file' => [ + 'driver' => 'file', + 'path' => $fooPath, + ], + 'cache.stores.bar_file' => [ + 'driver' => 'file', + 'path' => $barPath, + ], + // Only foo_file is scoped during bootstrap() + 'tenancy.cache.stores' => ['foo_file'], + ]); + + Cache::store('foo_file')->put('key', 'central'); + Cache::store('bar_file')->put('key', 'central'); + + tenancy()->initialize(Tenant::create()); + + Cache::store('foo_file')->put('key', 'tenant'); + + // Mutate tenancy.cache.stores in tenant context (remove 'foo_file', add 'bar_file') + config(['tenancy.cache.stores' => ['bar_file']]); + // 'bar_file' wasn't in tenancy.cache.stores during bootstrap. + // No original path is captured for that store, so it continues to use its central path. + expect(Cache::store('bar_file')->get('key'))->toBe('central'); + Cache::store('bar_file')->put('key', 'written in tenant context'); + + tenancy()->end(); + + // revert() restores the paths for the stores that were scoped during bootstrap + expect(Cache::store('foo_file')->get('key'))->toBe('central'); + // 'bar_file' was never scoped, it still reads from the same path it was writing to in tenant context + expect(Cache::store('bar_file')->get('key'))->toBe('written in tenant context'); +}); + +test('a configured lock_path is scoped separately from path', function () { + // A store can configure path and lock_path as two different directories + $centralStoragePath = storage_path(); + $path = "{$centralStoragePath}/framework/cache/foo"; + $lockPath = "{$centralStoragePath}/framework/cache/foo_locks"; + + // The paths are hardcoded, so delete the directories before running the assertions. + // Cache::store('foo_file')->lock(...) below defaults to a lock that never expires, + // and this test never releases it, so a stale lock left over from + // a previous run would make lock() fail forever. + File::deleteDirectory($path); + File::deleteDirectory($lockPath); + + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'tenancy.cache.stores' => ['foo_file'], + 'cache.stores.foo_file' => [ + 'driver' => 'file', + 'path' => $path, + 'lock_path' => $lockPath, + ], + ]); + + $tenant = Tenant::create(); + tenancy()->initialize($tenant); + + $tenantPath = "{$centralStoragePath}/tenant{$tenant->id}/framework/cache/foo"; + $tenantLockPath = "{$centralStoragePath}/tenant{$tenant->id}/framework/cache/foo_locks"; + + // lock('foo')->get() acquires the lock and creates the lock file at $tenantLockPath + expect(Cache::store('foo_file')->lock('foo')->get())->toBeTrue(); + + expect(File::isDirectory($tenantLockPath))->toBeTrue(); + // The lock file was created at $tenantLockPath, not $tenantPath + expect(File::isDirectory($tenantPath))->toBeFalse(); + + tenancy()->end(); + + // The lock was only acquired in the tenant context, so in the central context, + // it's free (and the lock file doesn't exist in the central context). + expect(File::isDirectory($lockPath))->toBeFalse(); + // Acquire the lock in the central context + expect(Cache::store('foo_file')->lock('foo')->get())->toBeTrue(); + // The lock file was created at the original (central) lock_path + expect(File::isDirectory($lockPath))->toBeTrue(); +}); + +test('a file cache store without a configured lock_path defaults to using its scoped path for locks', function () { + // 'lock_path' is optional in the file store config. + // FileStore falls back to using 'path' for locks when 'lock_path' is not specified in the config (or set to null). + // scopeCache() respects the original config and leaves lock_path unset/null. + $centralStoragePath = storage_path(); + $path = "{$centralStoragePath}/framework/cache/foo"; + + // $path is hardcoded here. The directory created at $path in a previous run can persist, + // so delete the directory at $path first. + File::deleteDirectory($path); + + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'tenancy.cache.stores' => ['foo_file'], + 'cache.stores.foo_file' => [ + 'driver' => 'file', + 'path' => $path, + // 'lock_path' not set (same behavior as if it were set to null) + ], + ]); + + $tenant = Tenant::create(); + tenancy()->initialize($tenant); + + $tenantPath = "{$centralStoragePath}/tenant{$tenant->id}/framework/cache/foo"; + + // Initializing tenancy scopes the store's 'path', but respects the configured + // (unset) 'lock_path' and leaves it alone. + expect(config('cache.stores.foo_file.path'))->toBe($tenantPath); + expect(config('cache.stores.foo_file.lock_path'))->toBeNull(); + + // With no 'lock_path' configured, the lock file will be created in the scoped 'path' directory + expect(File::isDirectory($tenantPath))->toBeFalse(); + expect(Cache::store('foo_file')->lock('foo')->get())->toBeTrue(); + expect(File::isDirectory($tenantPath))->toBeTrue(); + + // The lock is still held in the tenant context, so acquiring it again fails + expect(Cache::store('foo_file')->lock('foo')->get())->toBeFalse(); + + tenancy()->end(); + + // The same 'foo' lock is free in central context. + // revert() points the 'path' back to the original. + expect(File::isDirectory($path))->toBeFalse(); + expect(Cache::store('foo_file')->lock('foo')->get())->toBeTrue(); + expect(File::isDirectory($path))->toBeTrue(); +}); + +test('a cache store using a path not based on storage_path() is scoped to a tenant subdirectory', function () { + // tenantScopedPath() has no central storage path prefix to swap for the tenant's here, + // so it appends the tenant suffix to $path instead. + $path = '/tmp/tenancy-cache-test'; + File::deleteDirectory($path); + + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'tenancy.cache.stores' => ['foo_file'], + 'cache.stores.foo_file' => [ + 'driver' => 'file', + 'path' => $path, + ], + ]); + + $tenant1 = Tenant::create(); + $tenant2 = Tenant::create(); + + tenancy()->initialize($tenant1); + Cache::store('foo_file')->put('key', 'tenant1'); + + tenancy()->initialize($tenant2); + expect(Cache::store('foo_file')->get('key'))->toBeNull(); + + tenancy()->initialize($tenant1); + expect(Cache::store('foo_file')->get('key'))->toBe('tenant1'); + + // Tenant's 'foo_file' cache directory is created inside $path, + // not at the tenant-scoped storage_path(). + expect(File::isDirectory("{$path}/tenant{$tenant1->id}"))->toBeTrue(); + expect(File::isDirectory(storage_path('framework/cache/data')))->toBeFalse(); + + tenancy()->end(); +}); diff --git a/tests/SessionSeparationTest.php b/tests/SessionSeparationTest.php index 6c7a8aa1..cb823afd 100644 --- a/tests/SessionSeparationTest.php +++ b/tests/SessionSeparationTest.php @@ -83,6 +83,57 @@ test('file sessions are separated', function (bool $scopeSessions) { } })->with([true, false]); +test('file sessions are separated when a custom session path is configured', function () { + $centralStoragePath = storage_path(); + $configuredSessionPath = "{$centralStoragePath}/framework/foo_sessions"; + + config([ + 'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class], + 'session.driver' => 'file', + 'session.files' => $configuredSessionPath, + ]); + + $sessionPath = fn () => invade(app('session')->driver()->getHandler())->path; + + expect($sessionPath())->toBe($configuredSessionPath); + + File::cleanDirectory($configuredSessionPath); // clean up the configured sessions dir from past test runs + + $tenant = Tenant::create(); + $tenantSessionPath = "{$centralStoragePath}/tenant{$tenant->id}/framework/foo_sessions"; + + $tenant->enter(); + + // The configured path gets scoped to the tenant + expect($sessionPath())->toBe($tenantSessionPath); + // Initializing tenancy creates the tenant session dir + expect(is_dir($tenantSessionPath))->toBeTrue(); + + $tenant->leave(); + + // Session path reverts back to the original configured path + expect($sessionPath())->toBe($configuredSessionPath); + + // StartSession saves the session at the end of the request, so each request below creates a session file + Route::middleware([StartSession::class, InitializeTenancyByPath::class])->get('/{tenant}/foo', fn () => 'bar'); + Route::middleware(StartSession::class)->get('/central', fn () => 'bar'); + + expect(File::files($tenantSessionPath))->toHaveCount(0); + + // Visiting a tenant route should create the session file at the configured path scoped for the tenant + pest()->get("/{$tenant->id}/foo"); + + expect(File::files($tenantSessionPath))->toHaveCount(1); + expect(File::files($configuredSessionPath))->toHaveCount(0); + + // End tenancy to test the revert behavior (= the central session file gets created at the original configured path) + tenancy()->end(); + + pest()->get('/central'); + + expect(File::files($configuredSessionPath))->toHaveCount(1); +}); + test('redis sessions are separated using the redis bootstrapper', function (bool $bootstrappedEnabled) { config([ 'tenancy.bootstrappers' => $bootstrappedEnabled ? [RedisTenancyBootstrapper::class] : [],