From ff46bcfe20d4a7ea84acc833f5b57b18fe1dc361 Mon Sep 17 00:00:00 2001 From: Abrar Ahmad Date: Sun, 20 Nov 2022 06:31:37 +0500 Subject: [PATCH 1/2] Early identification support (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip * Improve tests * rename class * wip * improve tests * introduce early identification middlewares * Update PreventAccessFromCentralDomains.php * method rename * method rename * Update UniversalRouteTest.php * Update UniversalRouteTest.php * Update EarlyIdentificationTest.php * remove early classes and add check in existing classes * MWs improvements * Update UniversalRouteTest.php * Fix code style (php-cs-fixer) * trigger ci * fix failing test after merge * add test for universal route in early identification * Update InitializeTenancyByDomain.php * Update UniversalRouteTest.php * remove `routeHasMiddleware` method from MW and use the UniversalRoutes class method * helper dockblock * add test * handle universal routes in early identification * remove UniversalRoute class because we are not using it anymore * rename class from PreventAccessFromCentralDomains to PreventAccessFromUnwantedDomains * improvements after self review * Update PreventAccessFromUnwantedDomains.php * remove inline class namespaces * remove DomainTenant class and use the Tenant class * update comment * removed custom expection and add method * Update tests/EarlyIdentificationTest.php Co-authored-by: Samuel Štancl * use ltrim and simplify the comment * remove comments and typhint * dataset and keys rename * rename $route parameter * removed helper functions * fix style * Update InitializeTenancyByPath.php * Update tests/EarlyIdentificationTest.php * code style * improve subdomain test * use TenancyInitialized event and remove DomainTenant alias * remove comment and move expectException below * code style * use TenancyInitialized event * improve test * improve datasets * Initialized -> Initializing * Update InitializeTenancyByPath.php * remove todo * Fix code style (php-cs-fixer) * refactor helper method * Update UniversalRouteTest.php * add note above test * remove after each hook * renamed universal_middleware to global_middleware * remove helper and improve url names * change check position * Revert "change check position" This reverts commit e4371d2f3aa8ad7ae5e5b7d15781b72a5f1be03c. * repositioned central check * add comment Co-authored-by: PHP CS Fixer Co-authored-by: Samuel Štancl --- assets/TenancyServiceProvider.stub.php | 2 +- assets/config.php | 1 - assets/tenant_routes.stub.php | 4 +- src/Features/UniversalRoutes.php | 64 ----------- src/Middleware/InitializeTenancyByDomain.php | 5 + src/Middleware/InitializeTenancyByPath.php | 26 ++++- .../InitializeTenancyBySubdomain.php | 5 + .../PreventAccessFromCentralDomains.php | 30 ----- .../PreventAccessFromUnwantedDomains.php | 58 ++++++++++ tests/CommandsTest.php | 6 +- tests/DeleteDomainsJobTest.php | 2 +- tests/DomainTest.php | 2 - tests/EarlyIdentificationTest.php | 104 +++++++++++++++++ .../AdditionalMiddleware.php | 16 +++ tests/Etc/EarlyIdentification/Controller.php | 19 ++++ tests/Etc/EarlyIdentification/Service.php | 15 +++ tests/SubdomainTest.php | 29 +---- tests/UniversalRouteTest.php | 105 ++++++++++++------ 18 files changed, 330 insertions(+), 163 deletions(-) delete mode 100644 src/Features/UniversalRoutes.php delete mode 100644 src/Middleware/PreventAccessFromCentralDomains.php create mode 100644 src/Middleware/PreventAccessFromUnwantedDomains.php create mode 100644 tests/EarlyIdentificationTest.php create mode 100644 tests/Etc/EarlyIdentification/AdditionalMiddleware.php create mode 100644 tests/Etc/EarlyIdentification/Controller.php create mode 100644 tests/Etc/EarlyIdentification/Service.php diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index a38aee42..3092c428 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -153,7 +153,7 @@ class TenancyServiceProvider extends ServiceProvider protected function makeTenancyMiddlewareHighestPriority() { // PreventAccessFromCentralDomains has even higher priority than the identification middleware - $tenancyMiddleware = array_merge([Middleware\PreventAccessFromCentralDomains::class], config('tenancy.identification.middleware')); + $tenancyMiddleware = array_merge([Middleware\PreventAccessFromUnwantedDomains::class], config('tenancy.identification.middleware')); foreach (array_reverse($tenancyMiddleware) as $middleware) { $this->app[\Illuminate\Contracts\Http\Kernel::class]->prependToMiddlewarePriority($middleware); diff --git a/assets/config.php b/assets/config.php index 3778e107..dd3a0dd5 100644 --- a/assets/config.php +++ b/assets/config.php @@ -281,7 +281,6 @@ return [ 'features' => [ // Stancl\Tenancy\Features\UserImpersonation::class, // Stancl\Tenancy\Features\TelescopeTags::class, - // Stancl\Tenancy\Features\UniversalRoutes::class, // Stancl\Tenancy\Features\TenantConfig::class, // https://tenancyforlaravel.com/docs/v3/features/tenant-config // Stancl\Tenancy\Features\CrossDomainRedirect::class, // https://tenancyforlaravel.com/docs/v3/features/cross-domain-redirect ], diff --git a/assets/tenant_routes.stub.php b/assets/tenant_routes.stub.php index 59d61ac8..399b6735 100644 --- a/assets/tenant_routes.stub.php +++ b/assets/tenant_routes.stub.php @@ -4,7 +4,7 @@ declare(strict_types=1); use Illuminate\Support\Facades\Route; use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; -use Stancl\Tenancy\Middleware\PreventAccessFromCentralDomains; +use Stancl\Tenancy\Middleware\PreventAccessFromUnwantedDomains; /* |-------------------------------------------------------------------------- @@ -21,7 +21,7 @@ use Stancl\Tenancy\Middleware\PreventAccessFromCentralDomains; Route::middleware([ 'web', InitializeTenancyByDomain::class, - PreventAccessFromCentralDomains::class, + PreventAccessFromUnwantedDomains::class, ])->group(function () { Route::get('/', function () { return 'This is your multi-tenant application. The id of the current tenant is ' . tenant('id'); diff --git a/src/Features/UniversalRoutes.php b/src/Features/UniversalRoutes.php deleted file mode 100644 index ad0433fc..00000000 --- a/src/Features/UniversalRoutes.php +++ /dev/null @@ -1,64 +0,0 @@ -> */ - public static array $identificationMiddlewares = [ - Middleware\InitializeTenancyByDomain::class, - Middleware\InitializeTenancyBySubdomain::class, - ]; - - public function bootstrap(Tenancy $tenancy): void - { - foreach (static::$identificationMiddlewares as $middleware) { - $originalOnFail = $middleware::$onFail; - - $middleware::$onFail = function ($exception, $request, $next) use ($originalOnFail) { - if (static::routeHasMiddleware($request->route(), static::$middlewareGroup)) { - return $next($request); - } - - if ($originalOnFail) { - return $originalOnFail($exception, $request, $next); - } - - throw $exception; - }; - } - } - - public static function routeHasMiddleware(Route $route, string $middleware): bool - { - /** @var array $routeMiddleware */ - $routeMiddleware = $route->middleware(); - - if (in_array($middleware, $routeMiddleware, true)) { - return true; - } - - // Loop one level deep and check if the route's middleware - // groups have the searched middleware group inside them - $middlewareGroups = Router::getMiddlewareGroups(); - foreach ($route->gatherMiddleware() as $inner) { - if (! $inner instanceof Closure && isset($middlewareGroups[$inner]) && in_array($middleware, $middlewareGroups[$inner], true)) { - return true; - } - } - - return false; - } -} diff --git a/src/Middleware/InitializeTenancyByDomain.php b/src/Middleware/InitializeTenancyByDomain.php index add5597d..be9b2f66 100644 --- a/src/Middleware/InitializeTenancyByDomain.php +++ b/src/Middleware/InitializeTenancyByDomain.php @@ -22,6 +22,11 @@ class InitializeTenancyByDomain extends IdentificationMiddleware /** @return \Illuminate\Http\Response|mixed */ public function handle(Request $request, Closure $next): mixed { + if (in_array($request->getHost(), config('tenancy.central_domains', []), true)) { + // Always bypass tenancy initialization when host is in central domains + return $next($request); + } + return $this->initializeTenancy( $request, $next, diff --git a/src/Middleware/InitializeTenancyByPath.php b/src/Middleware/InitializeTenancyByPath.php index e73605e3..4a9f25bc 100644 --- a/src/Middleware/InitializeTenancyByPath.php +++ b/src/Middleware/InitializeTenancyByPath.php @@ -28,14 +28,13 @@ class InitializeTenancyByPath extends IdentificationMiddleware /** @return \Illuminate\Http\Response|mixed */ public function handle(Request $request, Closure $next): mixed { - /** @var Route $route */ - $route = $request->route(); + $route = $this->route($request); // Only initialize tenancy if tenant is the first parameter // We don't want to initialize tenancy if the tenant is // simply injected into some route controller action. if ($route->parameterNames()[0] === PathTenantResolver::tenantParameterName()) { - $this->setDefaultTenantForRouteParametersWhenTenancyIsInitialized(); + $this->setDefaultTenantForRouteParametersWhenInitializingTenancy(); return $this->initializeTenancy( $request, @@ -47,7 +46,26 @@ class InitializeTenancyByPath extends IdentificationMiddleware } } - protected function setDefaultTenantForRouteParametersWhenTenancyIsInitialized(): void + protected function route(Request $request): Route + { + /** @var Route $route */ + $route = $request->route(); + + if (! $route) { + // Create a fake $route instance that has enough information for this middleware's needs + $route = new Route($request->method(), $request->getUri(), []); + /** + * getPathInfo() returns the path except the root domain. + * We fetch the first parameter because tenant parameter is *always* first. + */ + $route->parameters[PathTenantResolver::tenantParameterName()] = explode('/', ltrim($request->getPathInfo(), '/'))[0]; + $route->parameterNames[] = PathTenantResolver::tenantParameterName(); + } + + return $route; + } + + protected function setDefaultTenantForRouteParametersWhenInitializingTenancy(): void { Event::listen(InitializingTenancy::class, function (InitializingTenancy $event) { /** @var Tenant $tenant */ diff --git a/src/Middleware/InitializeTenancyBySubdomain.php b/src/Middleware/InitializeTenancyBySubdomain.php index 1bf083f3..3cf3e0d3 100644 --- a/src/Middleware/InitializeTenancyBySubdomain.php +++ b/src/Middleware/InitializeTenancyBySubdomain.php @@ -27,6 +27,11 @@ class InitializeTenancyBySubdomain extends InitializeTenancyByDomain /** @return Response|mixed */ public function handle(Request $request, Closure $next): mixed { + if (in_array($request->getHost(), config('tenancy.central_domains', []), true)) { + // Always bypass tenancy initialization when host is in central domains + return $next($request); + } + $subdomain = $this->makeSubdomain($request->getHost()); if (is_object($subdomain) && $subdomain instanceof Exception) { diff --git a/src/Middleware/PreventAccessFromCentralDomains.php b/src/Middleware/PreventAccessFromCentralDomains.php deleted file mode 100644 index 40718730..00000000 --- a/src/Middleware/PreventAccessFromCentralDomains.php +++ /dev/null @@ -1,30 +0,0 @@ -getHost(), config('tenancy.central_domains'))) { - $abortRequest = static::$abortRequest ?? function () { - abort(404); - }; - - return $abortRequest($request, $next); - } - - return $next($request); - } -} diff --git a/src/Middleware/PreventAccessFromUnwantedDomains.php b/src/Middleware/PreventAccessFromUnwantedDomains.php new file mode 100644 index 00000000..1c01cc9e --- /dev/null +++ b/src/Middleware/PreventAccessFromUnwantedDomains.php @@ -0,0 +1,58 @@ +routeHasMiddleware($request->route(), 'universal')) { + return $next($request); + } + + if (in_array($request->getHost(), config('tenancy.central_domains'), true)) { + $abortRequest = static::$abortRequest ?? function () { + abort(404); + }; + + return $abortRequest($request, $next); + } + + return $next($request); + } + + protected function routeHasMiddleware(Route $route, string $middleware): bool + { + /** @var array $routeMiddleware */ + $routeMiddleware = $route->middleware(); + + if (in_array($middleware, $routeMiddleware, true)) { + return true; + } + + // Loop one level deep and check if the route's middleware + // groups have the searched middleware group inside them + $middlewareGroups = Router::getMiddlewareGroups(); + foreach ($route->gatherMiddleware() as $inner) { + if (! $inner instanceof Closure && isset($middlewareGroups[$inner]) && in_array($middleware, $middlewareGroups[$inner], true)) { + return true; + } + } + + return false; + } +} diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index 95672753..0ce2a1f0 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -163,17 +163,17 @@ test('rollback command works', function () { expect(Schema::hasTable('users'))->toBeFalse(); }); -test('seed command works', function (){ +test('seed command works', function () { $tenant = Tenant::create(); Artisan::call('tenants:migrate'); - $tenant->run(function (){ + $tenant->run(function () { expect(DB::table('users')->count())->toBe(0); }); Artisan::call('tenants:seed', ['--class' => TestSeeder::class]); - $tenant->run(function (){ + $tenant->run(function () { $user = DB::table('users'); expect($user->count())->toBe(1) ->and($user->first()->email)->toBe('seeded@user'); diff --git a/tests/DeleteDomainsJobTest.php b/tests/DeleteDomainsJobTest.php index bd825b71..e109384e 100644 --- a/tests/DeleteDomainsJobTest.php +++ b/tests/DeleteDomainsJobTest.php @@ -9,7 +9,7 @@ beforeEach(function () { config(['tenancy.models.tenant' => DatabaseAndDomainTenant::class]); }); -test('job delete domains successfully', function (){ +test('job delete domains successfully', function () { $tenant = DatabaseAndDomainTenant::create(); $tenant->domains()->create([ diff --git a/tests/DomainTest.php b/tests/DomainTest.php index 2fc04b76..02459914 100644 --- a/tests/DomainTest.php +++ b/tests/DomainTest.php @@ -8,7 +8,6 @@ use Stancl\Tenancy\Database\Models; use Stancl\Tenancy\Database\Models\Domain; use Stancl\Tenancy\Exceptions\DomainOccupiedByOtherTenantException; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException; -use Stancl\Tenancy\Features\UniversalRoutes; use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; use Stancl\Tenancy\Resolvers\DomainTenantResolver; @@ -95,7 +94,6 @@ test('throw correct exception when onFail is null and universal routes are enabl // Enable UniversalRoute feature Route::middlewareGroup('universal', []); - config(['tenancy.features' => [UniversalRoutes::class]]); $this->withoutExceptionHandling()->get('http://foo.localhost/foo/abc/xyz'); })->throws(TenantCouldNotBeIdentifiedOnDomainException::class);; diff --git a/tests/EarlyIdentificationTest.php b/tests/EarlyIdentificationTest.php new file mode 100644 index 00000000..ddec56fe --- /dev/null +++ b/tests/EarlyIdentificationTest.php @@ -0,0 +1,104 @@ +set([ + 'tenancy.token' => 'central-abc123', + ]); + + Event::listen(TenancyInitialized::class, function (TenancyInitialized $event) { + config()->set([ + 'tenancy.token' => $event->tenancy->tenant->getTenantKey() . '-abc123', + ]); + }); +}); + +test('early identification works with path identification', function () { + app(Kernel::class)->pushMiddleware(InitializeTenancyByPath::class); + + Route::group([ + 'prefix' => '/{tenant}', + ], function () { + Route::get('/foo', [Controller::class, 'index'])->name('foo'); + }); + + Tenant::create([ + 'id' => 'acme', + ]); + + $response = pest()->get('/acme/foo')->assertOk(); + + assertTenancyInitializedInEarlyIdentificationRequest($response->getContent()); + + // check if default parameter feature is working fine by asserting that the route WITHOUT the tenant parameter + // matches the route WITH the tenant parameter + expect(route('foo'))->toBe(route('foo', ['tenant' => 'acme'])); +}); + +test('early identification works with request data identification', function (string $type) { + app(Kernel::class)->pushMiddleware(InitializeTenancyByRequestData::class); + + Route::get('/foo', [Controller::class, 'index'])->name('foo'); + + $tenant = Tenant::create([ + 'id' => 'acme', + ]); + + if ($type === 'header') { + $response = pest()->get('/foo', ['X-Tenant' => $tenant->id])->assertOk(); + } elseif ($type === 'queryParameter') { + $response = pest()->get("/foo?tenant=$tenant->id")->assertOk(); + } + + assertTenancyInitializedInEarlyIdentificationRequest($response->getContent()); +})->with([ + 'using request header parameter' => 'header', + 'using request query parameter' => 'queryParameter' +]); + +// The name of this test is suffixed by the dataset — domain / subdomain / domainOrSubdomain identification +test('early identification works', function (string $middleware, string $domain, string $url) { + app(Kernel::class)->pushMiddleware($middleware); + + config(['tenancy.tenant_model' => Tenant::class]); + + Route::get('/foo', [Controller::class, 'index']) + ->middleware(PreventAccessFromUnwantedDomains::class) + ->name('foo'); + + $tenant = Tenant::create(); + + $tenant->domains()->create([ + 'domain' => $domain, + ]); + + $response = pest()->get($url)->assertOk(); + + assertTenancyInitializedInEarlyIdentificationRequest($response->getContent()); +})->with([ + 'domain identification' => ['middleware' => InitializeTenancyByDomain::class, 'domain' => 'foo.test', 'url' => 'http://foo.test/foo'], + 'subdomain identification' => ['middleware' => InitializeTenancyBySubdomain::class, 'domain' => 'foo', 'url' => 'http://foo.localhost/foo'], + 'domainOrSubdomain identification using domain' => ['middleware' => InitializeTenancyByDomainOrSubdomain::class, 'domain' => 'foo.test', 'url' => 'http://foo.test/foo'], + 'domainOrSubdomain identification using subdomain' => ['middleware' => InitializeTenancyByDomainOrSubdomain::class, 'domain' => 'foo', 'url' => 'http://foo.localhost/foo'], +]); + +function assertTenancyInitializedInEarlyIdentificationRequest(string|false $string): void +{ + expect($string)->toBe(tenant()->getTenantKey() . '-abc123'); // Assert that the service class returns tenant value + expect(app()->make('additionalMiddlewareRunsInTenantContext'))->toBeTrue(); // Assert that middleware added in the controller constructor runs in tenant context + expect(app()->make('controllerRunsInTenantContext'))->toBeTrue(); // Assert that tenancy is initialized in the controller constructor +} diff --git a/tests/Etc/EarlyIdentification/AdditionalMiddleware.php b/tests/Etc/EarlyIdentification/AdditionalMiddleware.php new file mode 100644 index 00000000..b580c6f6 --- /dev/null +++ b/tests/Etc/EarlyIdentification/AdditionalMiddleware.php @@ -0,0 +1,16 @@ +instance('additionalMiddlewareRunsInTenantContext', tenancy()->initialized); + + return $next($request); + } +} diff --git a/tests/Etc/EarlyIdentification/Controller.php b/tests/Etc/EarlyIdentification/Controller.php new file mode 100644 index 00000000..69898593 --- /dev/null +++ b/tests/Etc/EarlyIdentification/Controller.php @@ -0,0 +1,19 @@ +instance('controllerRunsInTenantContext', tenancy()->initialized); + $this->middleware(AdditionalMiddleware::class); + } + + public function index(): string + { + return $this->service->token; + } +} diff --git a/tests/Etc/EarlyIdentification/Service.php b/tests/Etc/EarlyIdentification/Service.php new file mode 100644 index 00000000..29d9414c --- /dev/null +++ b/tests/Etc/EarlyIdentification/Service.php @@ -0,0 +1,15 @@ +token = config('tenancy.token'); + } +} diff --git a/tests/SubdomainTest.php b/tests/SubdomainTest.php index 365ecc47..eefdc7ca 100644 --- a/tests/SubdomainTest.php +++ b/tests/SubdomainTest.php @@ -52,12 +52,13 @@ test('onfail logic can be customized', function () { ->assertSee('foo'); }); -test('localhost is not a valid subdomain', function () { +test('archte.ch is not a valid subdomain', function () { pest()->expectException(NotASubdomainException::class); + // This gets routed to the app, but with a request domain of 'archte.ch' $this ->withoutExceptionHandling() - ->get('http://localhost/foo/abc/xyz'); + ->get('http://archte.ch/foo/abc/xyz'); }); test('ip address is not a valid subdomain', function () { @@ -65,7 +66,7 @@ test('ip address is not a valid subdomain', function () { $this ->withoutExceptionHandling() - ->get('http://127.0.0.1/foo/abc/xyz'); + ->get('http://127.0.0.2/foo/abc/xyz'); }); test('oninvalidsubdomain logic can be customized', function () { @@ -81,7 +82,7 @@ test('oninvalidsubdomain logic can be customized', function () { $this ->withoutExceptionHandling() - ->get('http://127.0.0.1/foo/abc/xyz') + ->get('http://127.0.0.2/foo/abc/xyz') ->assertSee('foo custom invalid subdomain handler'); }); @@ -106,26 +107,6 @@ test('we cant use a subdomain that doesnt belong to our central domains', functi ->get('http://foo.localhost/foo/abc/xyz'); }); -test('central domain is not a subdomain', function () { - config(['tenancy.central_domains' => [ - 'localhost', - ]]); - - $tenant = SubdomainTenant::create([ - 'id' => 'acme', - ]); - - $tenant->domains()->create([ - 'domain' => 'acme', - ]); - - pest()->expectException(NotASubdomainException::class); - - $this - ->withoutExceptionHandling() - ->get('http://localhost/foo/abc/xyz'); -}); - class SubdomainTenant extends Models\Tenant { use HasDomains; diff --git a/tests/UniversalRouteTest.php b/tests/UniversalRouteTest.php index 20723cca..d520e580 100644 --- a/tests/UniversalRouteTest.php +++ b/tests/UniversalRouteTest.php @@ -3,27 +3,24 @@ declare(strict_types=1); use Illuminate\Support\Facades\Route; -use Stancl\Tenancy\Features\UniversalRoutes; +use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException; use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; +use Stancl\Tenancy\Middleware\PreventAccessFromUnwantedDomains; use Stancl\Tenancy\Tests\Etc\Tenant; +use Illuminate\Contracts\Http\Kernel; -afterEach(function () { - InitializeTenancyByDomain::$onFail = null; -}); +test('a route can work in both central and tenant context', function (array $routeMiddleware, string|null $globalMiddleware) { + if ($globalMiddleware) { + app(Kernel::class)->pushMiddleware($globalMiddleware); + } -test('a route can work in both central and tenant context', function () { Route::middlewareGroup('universal', []); - config(['tenancy.features' => [UniversalRoutes::class]]); Route::get('/foo', function () { return tenancy()->initialized ? 'Tenancy is initialized.' : 'Tenancy is not initialized.'; - })->middleware(['universal', InitializeTenancyByDomain::class]); - - pest()->get('http://localhost/foo') - ->assertSuccessful() - ->assertSee('Tenancy is not initialized.'); + })->middleware($routeMiddleware); $tenant = Tenant::create([ 'id' => 'acme', @@ -32,28 +29,33 @@ test('a route can work in both central and tenant context', function () { 'domain' => 'acme.localhost', ]); - pest()->get('http://acme.localhost/foo') + pest()->get("http://localhost/foo") + ->assertSuccessful() + ->assertSee('Tenancy is not initialized.'); + + pest()->get("http://acme.localhost/foo") ->assertSuccessful() ->assertSee('Tenancy is initialized.'); -}); +})->with('identification types'); -test('making one route universal doesnt make all routes universal', function () { - Route::get('/bar', function () { - return tenant('id'); - })->middleware(InitializeTenancyByDomain::class); +test('making one route universal doesnt make all routes universal', function (array $routeMiddleware, string|null $globalMiddleware) { + if ($globalMiddleware) { + app(Kernel::class)->pushMiddleware($globalMiddleware); + } Route::middlewareGroup('universal', []); - config(['tenancy.features' => [UniversalRoutes::class]]); - Route::get('/foo', function () { - return tenancy()->initialized - ? 'Tenancy is initialized.' - : 'Tenancy is not initialized.'; - })->middleware(['universal', InitializeTenancyByDomain::class]); + Route::middleware($routeMiddleware)->group(function () { + Route::get('/nonuniversal', function () { + return tenant('id'); + }); - pest()->get('http://localhost/foo') - ->assertSuccessful() - ->assertSee('Tenancy is not initialized.'); + Route::get('/universal', function () { + return tenancy()->initialized + ? 'Tenancy is initialized.' + : 'Tenancy is not initialized.'; + })->middleware('universal'); + }); $tenant = Tenant::create([ 'id' => 'acme', @@ -62,16 +64,57 @@ test('making one route universal doesnt make all routes universal', function () 'domain' => 'acme.localhost', ]); - pest()->get('http://acme.localhost/foo') + pest()->get("http://localhost/universal") + ->assertSuccessful() + ->assertSee('Tenancy is not initialized.'); + + pest()->get("http://acme.localhost/universal") ->assertSuccessful() ->assertSee('Tenancy is initialized.'); tenancy()->end(); - pest()->get('http://localhost/bar') - ->assertStatus(500); + pest()->get('http://localhost/nonuniversal') + ->assertStatus(404); - pest()->get('http://acme.localhost/bar') + pest()->get('http://acme.localhost/nonuniversal') ->assertSuccessful() ->assertSee('acme'); -}); +})->with([ + 'early identification' => [ + 'route_middleware' => [PreventAccessFromUnwantedDomains::class], + 'global_middleware' => InitializeTenancyByDomain::class, + ], + 'route-level identification' => [ + 'route_middleware' => [PreventAccessFromUnwantedDomains::class, InitializeTenancyByDomain::class], + 'global_middleware' => null, + ] +]); + +test('it throws correct exception when route is universal and tenant does not exist', function (array $routeMiddleware, string|null $globalMiddleware) { + if ($globalMiddleware) { + app(Kernel::class)->pushMiddleware($globalMiddleware); + } + + Route::middlewareGroup('universal', []); + + Route::get('/foo', function () { + return tenancy()->initialized + ? 'Tenancy is initialized.' + : 'Tenancy is not initialized.'; + })->middleware($routeMiddleware); + + pest()->expectException(TenantCouldNotBeIdentifiedOnDomainException::class); + $this->withoutExceptionHandling()->get('http://acme.localhost/foo'); +})->with('identification types'); + +dataset('identification types', [ + 'early identification' => [ + 'route_middleware' => ['universal', PreventAccessFromUnwantedDomains::class], + 'global_middleware' => InitializeTenancyByDomain::class, + ], + 'route-level identification' => [ + 'route_middleware' => ['universal', PreventAccessFromUnwantedDomains::class, InitializeTenancyByDomain::class], + 'global_middleware' => null, + ] +]); From 5849089373a09a9661d933e3a41e601b60b44a2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Sun, 20 Nov 2022 02:32:25 +0100 Subject: [PATCH 2/2] Add SessionTenancyBootstrapper (#2) * Add SessionTenancyBootstrapper * Fix code style (php-cs-fixer) * add value to bootstrappers config * tenant aware call test * reproduce issue in tests * fix logic for calling tenant run from central context, finish tests * change laravel version back * bump laravel to ^9.38 * add listener to create tenant DBs Co-authored-by: PHP CS Fixer Co-authored-by: Abrar Ahmad --- assets/config.php | 1 + composer.json | 4 +- phpunit.xml | 1 + .../SessionTenancyBootstrapper.php | 66 ++++++++ tests/Etc/HttpKernel.php | 2 +- ...022_05_11_181442_create_sessions_table.php | 35 +++++ tests/SessionBootstrapperTest.php | 145 ++++++++++++++++++ 7 files changed, 251 insertions(+), 3 deletions(-) create mode 100644 src/Bootstrappers/SessionTenancyBootstrapper.php create mode 100644 tests/Etc/session_migrations/2022_05_11_181442_create_sessions_table.php create mode 100644 tests/SessionBootstrapperTest.php diff --git a/assets/config.php b/assets/config.php index dd3a0dd5..b0223638 100644 --- a/assets/config.php +++ b/assets/config.php @@ -102,6 +102,7 @@ return [ Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper::class, Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class, Stancl\Tenancy\Bootstrappers\BatchTenancyBootstrapper::class, + // Stancl\Tenancy\Bootstrappers\SessionTenancyBootstrapper::class, // Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed ], diff --git a/composer.json b/composer.json index 68f16f25..dfd590b0 100644 --- a/composer.json +++ b/composer.json @@ -17,14 +17,14 @@ "require": { "php": "^8.1", "ext-json": "*", - "illuminate/support": "^9.0", + "illuminate/support": "^9.38", "spatie/ignition": "^1.4", "ramsey/uuid": "^4.0", "stancl/jobpipeline": "^1.0", "stancl/virtualcolumn": "^1.3" }, "require-dev": { - "laravel/framework": "^9.0", + "laravel/framework": "^9.38", "orchestra/testbench": "^7.0", "league/flysystem-aws-s3-v3": "^3.0", "doctrine/dbal": "^2.10", diff --git a/phpunit.xml b/phpunit.xml index 9d2b9339..0e0a8481 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -23,6 +23,7 @@ + diff --git a/src/Bootstrappers/SessionTenancyBootstrapper.php b/src/Bootstrappers/SessionTenancyBootstrapper.php new file mode 100644 index 00000000..13dd5bcd --- /dev/null +++ b/src/Bootstrappers/SessionTenancyBootstrapper.php @@ -0,0 +1,66 @@ +resetDatabaseHandler(); + } + + public function revert(): void + { + // 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')); + } + + protected function resetDatabaseHandler(string $defaultConnection = null): void + { + $sessionDrivers = $this->session->getDrivers(); + + if (isset($sessionDrivers['database'])) { + /** @var \Illuminate\Session\Store $databaseDriver */ + $databaseDriver = $sessionDrivers['database']; + + $databaseDriver->setHandler($this->createDatabaseHandler($defaultConnection)); + } + } + + protected function createDatabaseHandler(string $defaultConnection = null): 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), + $this->config->get('session.table'), + $this->config->get('session.lifetime'), + $this->container, + ); + } +} diff --git a/tests/Etc/HttpKernel.php b/tests/Etc/HttpKernel.php index 3bb43c53..4fc4b7dc 100644 --- a/tests/Etc/HttpKernel.php +++ b/tests/Etc/HttpKernel.php @@ -30,7 +30,7 @@ class HttpKernel extends Kernel */ protected $middlewareGroups = [ 'web' => [ - \Orchestra\Testbench\Http\Middleware\EncryptCookies::class, + \Illuminate\Cookie\Middleware\EncryptCookies::class, \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, \Illuminate\Session\Middleware\StartSession::class, // \Illuminate\Session\Middleware\AuthenticateSession::class, diff --git a/tests/Etc/session_migrations/2022_05_11_181442_create_sessions_table.php b/tests/Etc/session_migrations/2022_05_11_181442_create_sessions_table.php new file mode 100644 index 00000000..88b4a316 --- /dev/null +++ b/tests/Etc/session_migrations/2022_05_11_181442_create_sessions_table.php @@ -0,0 +1,35 @@ +string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->text('payload'); + $table->integer('last_activity')->index(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('sessions'); + } +} diff --git a/tests/SessionBootstrapperTest.php b/tests/SessionBootstrapperTest.php new file mode 100644 index 00000000..772cb427 --- /dev/null +++ b/tests/SessionBootstrapperTest.php @@ -0,0 +1,145 @@ + 'database']); + config(['tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class]]); + + Event::listen( + TenantCreated::class, + JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener() + ); + + Event::listen(Events\TenancyInitialized::class, Listeners\BootstrapTenancy::class); + Event::listen(Events\TenancyEnded::class, Listeners\RevertToCentralContext::class); + + // Sessions table for central database + pest()->artisan('migrate', [ + '--path' => __DIR__ . '/Etc/session_migrations', + '--realpath' => true, + ])->assertExitCode(0); + }); + +test('central helper can be used in tenant requests', function (bool $enabled, bool $shouldThrow) { + if ($enabled) { + config()->set( + 'tenancy.bootstrappers', + array_merge(config('tenancy.bootstrappers'), [SessionTenancyBootstrapper::class]), + ); + } + + $tenant = Tenant::create(); + + $tenant->domains()->create(['domain' => 'foo.localhost']); + + // run for tenants + pest()->artisan('tenants:migrate', [ + '--path' => __DIR__ . '/Etc/session_migrations', + '--realpath' => true, + ])->assertExitCode(0); + + Route::middleware(['web', InitializeTenancyByDomain::class])->get('/bar', function () { + session(['message' => 'tenant session']); + + tenancy()->central(function () { + return 'central results'; + }); + + return session('message'); + }); + + // We initialize tenancy before making the request, since sessions work a bit differently in tests + // and we need the DB session handler to use the tenant connection (as it does in a real app on tenant requests). + tenancy()->initialize($tenant); + + try { + $this->withoutExceptionHandling() + ->get('http://foo.localhost/bar') + ->assertOk() + ->assertSee('tenant session'); + + if ($shouldThrow) { + pest()->fail('Exception not thrown'); + } + } catch (Throwable $e) { + if ($shouldThrow) { + pest()->assertTrue(true); // empty assertion to make the test pass + } else { + pest()->fail('Exception thrown: ' . $e->getMessage()); + } + } +})->with([ + ['enabled' => false, 'shouldThrow' => true], + ['enabled' => true, 'shouldThrow' => false], +]); + +test('tenant run helper can be used on central requests', function (bool $enabled, bool $shouldThrow) { + if ($enabled) { + config()->set( + 'tenancy.bootstrappers', + array_merge(config('tenancy.bootstrappers'), [SessionTenancyBootstrapper::class]), + ); + } + + Tenant::create(); + + // run for tenants + pest()->artisan('tenants:migrate', [ + '--path' => __DIR__ . '/Etc/session_migrations', + '--realpath' => true, + ])->assertExitCode(0); + + Route::middleware(['web'])->get('/bar', function () { + session(['message' => 'central session']); + + Tenant::first()->run(function () { + return 'tenant results'; + }); + + return session('message'); + }); + + try { + $this->withoutExceptionHandling() + ->get('http://localhost/bar') + ->assertOk() + ->assertSee('central session'); + + if ($shouldThrow) { + pest()->fail('Exception not thrown'); + } + } catch (Throwable $e) { + if ($shouldThrow) { + pest()->assertTrue(true); // empty assertion to make the test pass + } else { + pest()->fail('Exception thrown: ' . $e->getMessage()); + } + } +})->with([ + ['enabled' => false, 'shouldThrow' => true], + ['enabled' => true, 'shouldThrow' => false], +]);