diff --git a/assets/config.php b/assets/config.php index 9089974d..37306806 100644 --- a/assets/config.php +++ b/assets/config.php @@ -117,7 +117,7 @@ return [ 'cache_store' => null, // null = default ], Resolvers\PathTenantResolver::class => [ - 'tenant_parameter_name' => 'tenant', + 'tenant_parameter_name' => 'tenant', // todo0 test changing this 'tenant_model_column' => null, // null = tenant key 'tenant_route_name_prefix' => null, // null = 'tenant.' 'allowed_extra_model_columns' => [], // used with binding route fields @@ -127,6 +127,10 @@ return [ 'cache_store' => null, // null = default ], Resolvers\RequestDataTenantResolver::class => [ + 'header' => 'X-Tenant', + 'cookie' => 'tenant', // todo0 test in url generator + 'query_parameter' => 'tenant', + 'cache' => false, 'cache_ttl' => 3600, // seconds 'cache_store' => null, // null = default diff --git a/src/Bootstrappers/UrlGeneratorBootstrapper.php b/src/Bootstrappers/UrlGeneratorBootstrapper.php index b5289904..6ead7cfd 100644 --- a/src/Bootstrappers/UrlGeneratorBootstrapper.php +++ b/src/Bootstrappers/UrlGeneratorBootstrapper.php @@ -67,13 +67,15 @@ class UrlGeneratorBootstrapper implements TenancyBootstrapper $defaultParameters = $this->originalUrlGenerator->getDefaultParameters(); if (static::$addTenantParameterToDefaults) { - $defaultParameters = array_merge( - $defaultParameters, - [ - PathTenantResolver::tenantParameterName() => PathTenantResolver::tenantParameterValue($tenant), // path identification - 'tenant' => $tenant->getTenantKey(), // query string identification - ], - ); + $defaultParameters = array_merge($defaultParameters, [ + PathTenantResolver::tenantParameterName() => PathTenantResolver::tenantParameterValue($tenant), + ]); + + foreach (PathTenantResolver::allowedExtraModelColumns() as $column) { + // todo0 should this be tenantParameterName() concatenated to :$column? + // add tests + $defaultParameters["tenant:$column"] = $tenant->getAttribute($column); + } } $newGenerator->defaults($defaultParameters); diff --git a/src/Middleware/InitializeTenancyByRequestData.php b/src/Middleware/InitializeTenancyByRequestData.php index a4e8a9c2..d7a13e2c 100644 --- a/src/Middleware/InitializeTenancyByRequestData.php +++ b/src/Middleware/InitializeTenancyByRequestData.php @@ -18,9 +18,6 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware { use UsableWithEarlyIdentification; - public static string $header = 'X-Tenant'; - public static string $cookie = 'tenant'; - public static string $queryParameter = 'tenant'; public static ?Closure $onFail = null; public static bool $requireCookieEncryption = false; @@ -54,18 +51,19 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware protected function getPayload(Request $request): string|null { - if (static::$header && $request->hasHeader(static::$header)) { - $payload = $request->header(static::$header); - } elseif ( - static::$queryParameter && - $request->has(static::$queryParameter) - ) { - $payload = $request->get(static::$queryParameter); - } elseif (static::$cookie && $request->hasCookie(static::$cookie)) { - $payload = $request->cookie(static::$cookie); + $headerName = RequestDataTenantResolver::headerName(); + $queryParameterName = RequestDataTenantResolver::queryParameterName(); + $cookieName = RequestDataTenantResolver::cookieName(); + + if ($headerName && $request->hasHeader($headerName)) { + $payload = $request->header($headerName); + } elseif ($queryParameterName && $request->has($queryParameterName)) { + $payload = $request->get($queryParameterName); + } elseif ($cookieName && $request->hasCookie($cookieName)) { + $payload = $request->cookie($cookieName); if ($payload && is_string($payload)) { - $payload = $this->getTenantFromCookie($payload); + $payload = $this->getTenantFromCookie($cookieName, $payload); } } else { $payload = null; @@ -86,12 +84,12 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware return (bool) $this->getPayload($request); } - protected function getTenantFromCookie(string $cookie): string|null + protected function getTenantFromCookie(string $cookieName, string $cookieValue): string|null { // If the cookie looks like it's encrypted, we try decrypting it - if (str_starts_with($cookie, 'eyJpdiI')) { + if (str_starts_with($cookieValue, 'eyJpdiI')) { try { - $json = base64_decode($cookie); + $json = base64_decode($cookieValue); $data = json_decode($json, true); if ( @@ -100,9 +98,9 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware ) { // We can confidently assert that the cookie is encrypted. If this call were to fail, this method would just // return null and the cookie payload would get skipped. - $cookie = CookieValuePrefix::validate( - static::$cookie, - Crypt::decryptString($cookie), + $cookieValue = CookieValuePrefix::validate( + $cookieName, + Crypt::decryptString($cookieValue), Crypt::getAllKeys() ); } @@ -113,6 +111,6 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware return null; } - return $cookie; + return $cookieValue; } } diff --git a/src/Resolvers/RequestDataTenantResolver.php b/src/Resolvers/RequestDataTenantResolver.php index 7ebc90ab..ad63d6f3 100644 --- a/src/Resolvers/RequestDataTenantResolver.php +++ b/src/Resolvers/RequestDataTenantResolver.php @@ -33,4 +33,28 @@ class RequestDataTenantResolver extends Contracts\CachedTenantResolver $this->formatCacheKey($tenant->getTenantKey()), ]; } + + /** + * Returns the name of the header used for identification, or null if header identification is disabled. + */ + public static function headerName(): string|null + { + return config('tenancy.identification.resolvers.' . static::class . '.header'); + } + + /** + * Returns the name of the query parameter used for identification, or null if query parameter identification is disabled. + */ + public static function queryParameterName(): string|null + { + return config('tenancy.identification.resolvers.' . static::class . '.query_parameter'); + } + + /** + * Returns the name of the cookie used for identification, or null if cookie identification is disabled. + */ + public static function cookieName(): string|null + { + return config('tenancy.identification.resolvers.' . static::class . '.cookie'); + } } diff --git a/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php b/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php index 77d50073..eeedd05a 100644 --- a/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php +++ b/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php @@ -1,5 +1,6 @@ with([true, false]) // UrlGeneratorBootstrapper::$addTenantParameterToDefaults ->with([true, false]); // TenancyUrlGenerator::$passTenantParameterToRoutes +test('setting extra model columns sets additional URL defaults', function () { + Tenant::$extraCustomColumns = ['slug']; + TenancyUrlGenerator::$passTenantParameterToRoutes = false; + UrlGeneratorBootstrapper::$addTenantParameterToDefaults = true; + + config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]); + config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.allowed_extra_model_columns' => ['slug']]); + + Schema::table('tenants', function (Blueprint $table) { + $table->string('slug')->unique(); + }); + + $this->artisan('tenants:migrate'); + + Route::get('/{tenant}/foo/{user}', function (string $user) { + return tenant()->getTenantKey() . " $user"; + })->middleware([InitializeTenancyByPath::class, 'web'])->name('foo'); + + Route::get('/{tenant:slug}/fooslug/{user}', function (string $user) { + return tenant()->getTenantKey() . " $user"; + })->middleware([InitializeTenancyByPath::class, 'web'])->name('fooslug'); + + $tenant = Tenant::create(['slug' => 'acme']); + + // In central context, no URL defaults are applied + expect(route('foo', [$tenant->getTenantKey(), 'bar']))->toBe("http://localhost/{$tenant->getTenantKey()}/foo/bar"); + pest()->get(route('foo', [$tenant->getTenantKey(), 'bar']))->assertSee(tenant()->getTenantKey() . ' bar'); + tenancy()->end(); + + expect(route('fooslug', ['acme', 'bar']))->toBe('http://localhost/acme/fooslug/bar'); + pest()->get(route('fooslug', ['acme', 'bar']))->assertSee(tenant()->getTenantKey() . ' bar'); + tenancy()->end(); + + // In tenant context, URL defaults are applied + tenancy()->initialize($tenant); + expect(route('foo', ['bar']))->toBe("http://localhost/{$tenant->getTenantKey()}/foo/bar"); + pest()->get(route('foo', ['bar']))->assertSee(tenant()->getTenantKey() . ' bar'); + + expect(route('fooslug', ['bar']))->toBe('http://localhost/acme/fooslug/bar'); + pest()->get(route('fooslug', ['bar']))->assertSee(tenant()->getTenantKey() . ' bar'); +}); + +test('changing the tenant model column changes the default value for the tenant parameter', function () { + Tenant::$extraCustomColumns = ['slug']; + TenancyUrlGenerator::$passTenantParameterToRoutes = false; + UrlGeneratorBootstrapper::$addTenantParameterToDefaults = true; + + config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]); + config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_model_column' => 'slug']); + + Schema::table('tenants', function (Blueprint $table) { + $table->string('slug')->unique(); + }); + + $this->artisan('tenants:migrate'); + + Route::get('/{tenant}/foo/{user}', function (string $user) { + return tenant()->getTenantKey() . " $user"; + })->middleware([InitializeTenancyByPath::class, 'web'])->name('foo'); + + $tenant = Tenant::create(['slug' => 'acme']); + + // In central context, no URL defaults are applied + expect(route('foo', ['acme', 'bar']))->toBe("http://localhost/acme/foo/bar"); + pest()->get(route('foo', ['acme', 'bar']))->assertSee(tenant()->getTenantKey() . ' bar'); + tenancy()->end(); + + // In tenant context, URL defaults are applied + tenancy()->initialize($tenant); + expect(route('foo', ['bar']))->toBe("http://localhost/acme/foo/bar"); + pest()->get(route('foo', ['bar']))->assertSee(tenant()->getTenantKey() . ' bar'); +}); + test('url generator can override specific route names', function() { config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]); diff --git a/tests/RequestDataIdentificationTest.php b/tests/RequestDataIdentificationTest.php index f04d99a7..365b652c 100644 --- a/tests/RequestDataIdentificationTest.php +++ b/tests/RequestDataIdentificationTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); use Illuminate\Support\Facades\Route; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByRequestDataException; use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData; +use Stancl\Tenancy\Resolvers\RequestDataTenantResolver; use Stancl\Tenancy\Tests\Etc\Tenant; use function Stancl\Tenancy\Tests\pest; @@ -13,12 +14,11 @@ beforeEach(function () { 'tenancy.identification.central_domains' => [ 'localhost', ], + 'tenancy.identification.' . RequestDataTenantResolver::class . '.header' => 'X-Tenant', + 'tenancy.identification.' . RequestDataTenantResolver::class . '.query_parameter' => 'tenant', + 'tenancy.identification.' . RequestDataTenantResolver::class . '.cookie' => 'tenant', ]); - InitializeTenancyByRequestData::$header = 'X-Tenant'; - InitializeTenancyByRequestData::$cookie = 'X-Tenant'; - InitializeTenancyByRequestData::$queryParameter = 'tenant'; - Route::middleware(['tenant', InitializeTenancyByRequestData::class])->get('/test', function () { return 'Tenant id: ' . tenant('id'); }); @@ -48,11 +48,13 @@ test('cookie identification works', function () { $this ->withoutExceptionHandling() - ->withUnencryptedCookie('X-Tenant', $tenant->id) + ->withUnencryptedCookie('tenant', $tenant->id) ->get('test') ->assertSee($tenant->id); }); +// todo@tests encrypted cookie + test('middleware throws exception when tenant data is not provided in the request', function () { pest()->expectException(TenantCouldNotBeIdentifiedByRequestDataException::class); $this->withoutExceptionHandling()->get('test');